feat(orchestrator): blocked company filters and keyword parsing
- Add shared helper to parse blocked company keywords from JSON or legacy text - Wire discover-jobs to resolved keywords for employer blocking; extend filters UI - Include vite/client in TS types for import.meta.env; fix JobListItem test fixture Made-with: Cursor
This commit is contained in:
parent
60b61ffe03
commit
e39341258a
@ -44,6 +44,7 @@ const makeJob = (overrides: Partial<JobListItem>): JobListItem => ({
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
discoveredAt: "2026-01-01T00:00:00.000Z",
|
||||
appliedAt: null,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
|
||||
@ -48,6 +48,14 @@ export const OrchestratorPage: React.FC = () => {
|
||||
setWorkplaceFilter,
|
||||
salaryFilter,
|
||||
setSalaryFilter,
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
setFoundDateRange,
|
||||
employerIncludeFilter,
|
||||
employerExcludeFilter,
|
||||
setEmployerFilterTokens,
|
||||
applySettingsCompanySkipList,
|
||||
setApplySettingsCompanySkipList,
|
||||
sort,
|
||||
setSort,
|
||||
resetFilters,
|
||||
@ -146,6 +154,31 @@ export const OrchestratorPage: React.FC = () => {
|
||||
navigateWithContext,
|
||||
});
|
||||
|
||||
const settingsSkipEmployerKeywords = useMemo(
|
||||
() => settings?.blockedCompanyKeywords?.value ?? [],
|
||||
[settings],
|
||||
);
|
||||
|
||||
const jobListFilterExtras = useMemo(
|
||||
() => ({
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
employerInclude: employerIncludeFilter,
|
||||
employerExclude: employerExcludeFilter,
|
||||
settingsBlockedEmployerKeywords: applySettingsCompanySkipList
|
||||
? settingsSkipEmployerKeywords
|
||||
: [],
|
||||
}),
|
||||
[
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
employerIncludeFilter,
|
||||
employerExcludeFilter,
|
||||
applySettingsCompanySkipList,
|
||||
settingsSkipEmployerKeywords,
|
||||
],
|
||||
);
|
||||
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
activeTab,
|
||||
@ -157,6 +190,21 @@ export const OrchestratorPage: React.FC = () => {
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
sort,
|
||||
jobListFilterExtras,
|
||||
);
|
||||
|
||||
const handleEmployerIncludeChange = useCallback(
|
||||
(nextInclude: string[]) => {
|
||||
setEmployerFilterTokens(nextInclude, employerExcludeFilter);
|
||||
},
|
||||
[employerExcludeFilter, setEmployerFilterTokens],
|
||||
);
|
||||
|
||||
const handleEmployerExcludeChange = useCallback(
|
||||
(nextExclude: string[]) => {
|
||||
setEmployerFilterTokens(employerIncludeFilter, nextExclude);
|
||||
},
|
||||
[employerIncludeFilter, setEmployerFilterTokens],
|
||||
);
|
||||
const setActiveTab = useCallback(
|
||||
(newTab: FilterTab) => {
|
||||
@ -444,6 +492,17 @@ export const OrchestratorPage: React.FC = () => {
|
||||
countriesFilter={countriesFilter}
|
||||
countriesExcludeFilter={countriesExcludeFilter}
|
||||
onCountrySelectionChange={setCountrySelection}
|
||||
foundAfterYmd={foundAfterYmd}
|
||||
foundBeforeYmd={foundBeforeYmd}
|
||||
onFoundDateRangeChange={setFoundDateRange}
|
||||
employerIncludeFilter={employerIncludeFilter}
|
||||
employerExcludeFilter={employerExcludeFilter}
|
||||
onEmployerIncludeChange={handleEmployerIncludeChange}
|
||||
onEmployerExcludeChange={handleEmployerExcludeChange}
|
||||
applySettingsCompanySkipList={applySettingsCompanySkipList}
|
||||
onApplySettingsCompanySkipListChange={
|
||||
setApplySettingsCompanySkipList
|
||||
}
|
||||
sponsorFilter={sponsorFilter}
|
||||
onSponsorFilterChange={setSponsorFilter}
|
||||
workplaceFilter={workplaceFilter}
|
||||
|
||||
@ -10,6 +10,15 @@ interface JobRowContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatDiscoveredAt(iso: string): string {
|
||||
const parsed = Date.parse(iso);
|
||||
if (!Number.isFinite(parsed)) return iso;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function getSuitabilityScoreTone(score: number): string {
|
||||
if (score >= 70) return "text-emerald-400/90";
|
||||
if (score >= 50) return "text-foreground/60";
|
||||
@ -60,6 +69,14 @@ export const JobRowContent = ({
|
||||
{job.salary}
|
||||
</div>
|
||||
)}
|
||||
{job.discoveredAt?.trim() && (
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground mt-0.5"
|
||||
title={`Found in JobOps: ${job.discoveredAt}`}
|
||||
>
|
||||
Found {formatDiscoveredAt(job.discoveredAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasScore && (
|
||||
|
||||
@ -45,6 +45,15 @@ const renderFilters = (overrides?: Partial<FiltersProps>) => {
|
||||
countriesFilter: [] as string[],
|
||||
countriesExcludeFilter: [] as string[],
|
||||
onCountrySelectionChange: vi.fn(),
|
||||
foundAfterYmd: null,
|
||||
foundBeforeYmd: null,
|
||||
onFoundDateRangeChange: vi.fn(),
|
||||
employerIncludeFilter: [],
|
||||
employerExcludeFilter: [],
|
||||
onEmployerIncludeChange: vi.fn(),
|
||||
onEmployerExcludeChange: vi.fn(),
|
||||
applySettingsCompanySkipList: true,
|
||||
onApplySettingsCompanySkipListChange: vi.fn(),
|
||||
sponsorFilter: "all" as SponsorFilter,
|
||||
onSponsorFilterChange: vi.fn(),
|
||||
workplaceFilter: "all" as WorkplaceFilter,
|
||||
@ -92,7 +101,7 @@ describe("OrchestratorFilters", () => {
|
||||
[],
|
||||
);
|
||||
|
||||
props.onSourceSelectionChange.mockClear();
|
||||
vi.mocked(props.onSourceSelectionChange).mockClear();
|
||||
rerender(<OrchestratorFilters {...props} sourcesFilter={["linkedin"]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /linkedin/i }));
|
||||
expect(props.onSourceSelectionChange).toHaveBeenCalledWith(
|
||||
|
||||
@ -7,6 +7,7 @@ import type React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@ -36,6 +37,7 @@ import type {
|
||||
WorkplaceFilter,
|
||||
} from "./constants";
|
||||
import { defaultSortDirection, orderedFilterSources, tabs } from "./constants";
|
||||
import { TokenizedInput } from "./TokenizedInput";
|
||||
|
||||
interface OrchestratorFiltersProps {
|
||||
activeTab: FilterTab;
|
||||
@ -48,6 +50,18 @@ interface OrchestratorFiltersProps {
|
||||
countriesFilter: string[];
|
||||
countriesExcludeFilter: string[];
|
||||
onCountrySelectionChange: (include: string[], exclude: string[]) => void;
|
||||
foundAfterYmd: string | null;
|
||||
foundBeforeYmd: string | null;
|
||||
onFoundDateRangeChange: (
|
||||
foundAfter: string | null,
|
||||
foundBefore: string | null,
|
||||
) => void;
|
||||
employerIncludeFilter: string[];
|
||||
employerExcludeFilter: string[];
|
||||
onEmployerIncludeChange: (values: string[]) => void;
|
||||
onEmployerExcludeChange: (values: string[]) => void;
|
||||
applySettingsCompanySkipList: boolean;
|
||||
onApplySettingsCompanySkipListChange: (value: boolean) => void;
|
||||
sponsorFilter: SponsorFilter;
|
||||
onSponsorFilterChange: (value: SponsorFilter) => void;
|
||||
workplaceFilter: WorkplaceFilter;
|
||||
@ -110,6 +124,13 @@ const sortFieldLabels: Record<JobSort["key"], string> = {
|
||||
employer: "Company",
|
||||
};
|
||||
|
||||
function parseEmployerTokenInput(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]/g)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const getDirectionOptions = (
|
||||
key: JobSort["key"],
|
||||
): Array<{ value: JobSort["direction"]; label: string }> => {
|
||||
@ -142,6 +163,15 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
onCountrySelectionChange,
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
onFoundDateRangeChange,
|
||||
employerIncludeFilter,
|
||||
employerExcludeFilter,
|
||||
onEmployerIncludeChange,
|
||||
onEmployerExcludeChange,
|
||||
applySettingsCompanySkipList,
|
||||
onApplySettingsCompanySkipListChange,
|
||||
sponsorFilter,
|
||||
onSponsorFilterChange,
|
||||
workplaceFilter,
|
||||
@ -160,6 +190,8 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isFiltersOpen = isFiltersOpenProp ?? internalOpen;
|
||||
const onFiltersOpenChange = onFiltersOpenChangeProp ?? setInternalOpen;
|
||||
const [employerIncludeDraft, setEmployerIncludeDraft] = useState("");
|
||||
const [employerExcludeDraft, setEmployerExcludeDraft] = useState("");
|
||||
|
||||
const visibleSources = orderedFilterSources.filter((source) =>
|
||||
sourcesWithJobs.includes(source),
|
||||
@ -201,6 +233,11 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
() =>
|
||||
Number(sourcesFilter.length > 0 || sourcesExcludeFilter.length > 0) +
|
||||
Number(countriesFilter.length > 0 || countriesExcludeFilter.length > 0) +
|
||||
Number(Boolean(foundAfterYmd) || Boolean(foundBeforeYmd)) +
|
||||
Number(
|
||||
employerIncludeFilter.length > 0 || employerExcludeFilter.length > 0,
|
||||
) +
|
||||
Number(!applySettingsCompanySkipList) +
|
||||
Number(sponsorFilter !== "all") +
|
||||
Number(workplaceFilter !== "all") +
|
||||
Number(
|
||||
@ -212,6 +249,11 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
sourcesExcludeFilter.length,
|
||||
countriesFilter.length,
|
||||
countriesExcludeFilter.length,
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
employerIncludeFilter.length,
|
||||
employerExcludeFilter.length,
|
||||
applySettingsCompanySkipList,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter.min,
|
||||
@ -295,7 +337,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
<SheetDescription>
|
||||
Refine sources and country: each button cycles off → include
|
||||
→ exclude (red). Remote listings always bypass country
|
||||
filters. Sponsor, workplace, salary, and sort below.
|
||||
filters. Filter by when the job was discovered in JobOps,
|
||||
employer keywords, sponsor, workplace, salary, and sort
|
||||
below.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@ -394,6 +438,117 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Found in JobOps</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When this listing was first saved during discovery (your
|
||||
local JobOps clock). Leave blank to show all dates.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="found-after-filter">
|
||||
On or after
|
||||
</Label>
|
||||
<Input
|
||||
id="found-after-filter"
|
||||
type="date"
|
||||
value={foundAfterYmd ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onFoundDateRangeChange(
|
||||
next ? next : null,
|
||||
foundBeforeYmd,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="found-before-filter">
|
||||
On or before
|
||||
</Label>
|
||||
<Input
|
||||
id="found-before-filter"
|
||||
type="date"
|
||||
value={foundBeforeYmd ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onFoundDateRangeChange(
|
||||
foundAfterYmd,
|
||||
next ? next : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Employer keywords</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Case-insensitive substring match on employer name.
|
||||
Include narrows to rows that match any token; exclude
|
||||
hides matching rows. When enabled, your Settings company
|
||||
skip list is applied here too (not only during
|
||||
discovery).
|
||||
</p>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<Checkbox
|
||||
id="apply-settings-company-skip"
|
||||
checked={applySettingsCompanySkipList}
|
||||
onCheckedChange={(checked) => {
|
||||
onApplySettingsCompanySkipListChange(
|
||||
checked === true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="apply-settings-company-skip"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Apply Settings company skip list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uncheck to temporarily show employers blocked in
|
||||
Settings → Scoring (e.g. to clean up old rows).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokenizedInput
|
||||
id="employer-include-filter"
|
||||
values={employerIncludeFilter}
|
||||
draft={employerIncludeDraft}
|
||||
parseInput={parseEmployerTokenInput}
|
||||
onDraftChange={setEmployerIncludeDraft}
|
||||
onValuesChange={onEmployerIncludeChange}
|
||||
placeholder="e.g. partial company name"
|
||||
helperText="Only show jobs whose employer contains at least one of these tokens."
|
||||
removeLabelPrefix="Remove include token"
|
||||
/>
|
||||
|
||||
<TokenizedInput
|
||||
id="employer-exclude-filter"
|
||||
values={employerExcludeFilter}
|
||||
draft={employerExcludeDraft}
|
||||
parseInput={parseEmployerTokenInput}
|
||||
onDraftChange={setEmployerExcludeDraft}
|
||||
onValuesChange={onEmployerExcludeChange}
|
||||
placeholder="e.g. agency name"
|
||||
helperText="Hide jobs whose employer contains any of these tokens (in addition to the Settings skip list when enabled above)."
|
||||
removeLabelPrefix="Remove exclude token"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Sponsor status</CardTitle>
|
||||
|
||||
@ -362,4 +362,119 @@ describe("useFilteredJobs", () => {
|
||||
);
|
||||
expect(result.current.map((j) => j.id)).toEqual(["in"]);
|
||||
});
|
||||
|
||||
it("filters by discovered date range in local time", () => {
|
||||
const jobs: Job[] = [
|
||||
{
|
||||
...baseJob,
|
||||
id: "early",
|
||||
discoveredAt: "2026-01-10T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
...baseJob,
|
||||
id: "late",
|
||||
discoveredAt: "2026-02-20T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
{
|
||||
foundAfterYmd: "2026-02-01",
|
||||
foundBeforeYmd: "2026-02-28",
|
||||
employerInclude: [],
|
||||
employerExclude: [],
|
||||
settingsBlockedEmployerKeywords: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.map((j) => j.id)).toEqual(["late"]);
|
||||
});
|
||||
|
||||
it("hides employers matching exclude tokens or settings skip list", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "agency", employer: "Acme Staffing Ltd" },
|
||||
{ ...baseJob, id: "good", employer: "Contoso" },
|
||||
];
|
||||
|
||||
const { result: localExclude } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
{
|
||||
foundAfterYmd: null,
|
||||
foundBeforeYmd: null,
|
||||
employerInclude: [],
|
||||
employerExclude: ["staffing"],
|
||||
settingsBlockedEmployerKeywords: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(localExclude.current.map((j) => j.id)).toEqual(["good"]);
|
||||
|
||||
const { result: settingsSkip } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
{
|
||||
foundAfterYmd: null,
|
||||
foundBeforeYmd: null,
|
||||
employerInclude: [],
|
||||
employerExclude: [],
|
||||
settingsBlockedEmployerKeywords: ["staffing"],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(settingsSkip.current.map((j) => j.id)).toEqual(["good"]);
|
||||
|
||||
const { result: includeOnly } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
{
|
||||
foundAfterYmd: null,
|
||||
foundBeforeYmd: null,
|
||||
employerInclude: ["contoso"],
|
||||
employerExclude: [],
|
||||
settingsBlockedEmployerKeywords: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(includeOnly.current.map((j) => j.id)).toEqual(["good"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,6 +10,29 @@ import type {
|
||||
} from "./constants";
|
||||
import { compareJobs, parseSalaryBounds } from "./utils";
|
||||
|
||||
export type JobListFilterExtras = {
|
||||
foundAfterYmd: string | null;
|
||||
foundBeforeYmd: string | null;
|
||||
employerInclude: string[];
|
||||
employerExclude: string[];
|
||||
settingsBlockedEmployerKeywords: string[];
|
||||
};
|
||||
|
||||
const startOfLocalDayMs = (ymd: string): number =>
|
||||
new Date(`${ymd}T00:00:00`).getTime();
|
||||
|
||||
const endOfLocalDayMs = (ymd: string): number =>
|
||||
new Date(`${ymd}T23:59:59.999`).getTime();
|
||||
|
||||
function employerMatchesAnyKeyword(
|
||||
employer: string,
|
||||
keywordsLower: string[],
|
||||
): boolean {
|
||||
if (keywordsLower.length === 0) return false;
|
||||
const normalized = employer.toLowerCase();
|
||||
return keywordsLower.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
const getSponsorCategory = (score: number | null): SponsorFilter => {
|
||||
if (score == null) return "unknown";
|
||||
if (score >= 95) return "confirmed";
|
||||
@ -28,8 +51,26 @@ export const useFilteredJobs = (
|
||||
workplaceFilter: WorkplaceFilter,
|
||||
salaryFilter: SalaryFilter,
|
||||
sort: JobSort,
|
||||
listExtras: JobListFilterExtras = {
|
||||
foundAfterYmd: null,
|
||||
foundBeforeYmd: null,
|
||||
employerInclude: [],
|
||||
employerExclude: [],
|
||||
settingsBlockedEmployerKeywords: [],
|
||||
},
|
||||
) =>
|
||||
useMemo(() => {
|
||||
const employerIncludeLower = listExtras.employerInclude.map((value) =>
|
||||
value.toLowerCase(),
|
||||
);
|
||||
const employerExcludeLower = [
|
||||
...listExtras.settingsBlockedEmployerKeywords.map((value) =>
|
||||
value.toLowerCase(),
|
||||
),
|
||||
...listExtras.employerExclude.map((value) => value.toLowerCase()),
|
||||
];
|
||||
const uniqueEmployerExcludeLower = [...new Set(employerExcludeLower)];
|
||||
|
||||
let filtered = [...jobs];
|
||||
|
||||
if (activeTab === "ready") {
|
||||
@ -101,6 +142,39 @@ export const useFilteredJobs = (
|
||||
Number.isFinite(salaryFilter.max) &&
|
||||
salaryFilter.max > 0;
|
||||
|
||||
if (listExtras.foundAfterYmd || listExtras.foundBeforeYmd) {
|
||||
filtered = filtered.filter((job) => {
|
||||
const jobMs = Date.parse(job.discoveredAt);
|
||||
if (!Number.isFinite(jobMs)) return false;
|
||||
if (
|
||||
listExtras.foundAfterYmd &&
|
||||
jobMs < startOfLocalDayMs(listExtras.foundAfterYmd)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
listExtras.foundBeforeYmd &&
|
||||
jobMs > endOfLocalDayMs(listExtras.foundBeforeYmd)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (employerIncludeLower.length > 0) {
|
||||
filtered = filtered.filter((job) =>
|
||||
employerMatchesAnyKeyword(job.employer, employerIncludeLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (uniqueEmployerExcludeLower.length > 0) {
|
||||
filtered = filtered.filter(
|
||||
(job) =>
|
||||
!employerMatchesAnyKeyword(job.employer, uniqueEmployerExcludeLower),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(salaryFilter.mode === "at_least" && hasMin) ||
|
||||
(salaryFilter.mode === "at_most" && hasMax) ||
|
||||
@ -142,4 +216,5 @@ export const useFilteredJobs = (
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
sort,
|
||||
listExtras,
|
||||
]);
|
||||
|
||||
@ -89,4 +89,26 @@ describe("useOrchestratorFilters", () => {
|
||||
expect(params.get("countries")).toBe("canada");
|
||||
expect(params.get("countriesExclude")).toBe("united kingdom");
|
||||
});
|
||||
|
||||
it("parses found date, employer tokens, skip list toggle, and reset clears them", () => {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper(
|
||||
"/ready?foundAfter=2026-04-01&foundBefore=2026-04-30&employer=acme&employerExclude=temp&skipList=0",
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.foundAfterYmd).toBe("2026-04-01");
|
||||
expect(result.current.foundBeforeYmd).toBe("2026-04-30");
|
||||
expect(result.current.employerIncludeFilter).toEqual(["acme"]);
|
||||
expect(result.current.employerExcludeFilter).toEqual(["temp"]);
|
||||
expect(result.current.applySettingsCompanySkipList).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.resetFilters();
|
||||
});
|
||||
const params = result.current.searchParams;
|
||||
expect(params.get("foundAfter")).toBeNull();
|
||||
expect(params.get("employer")).toBeNull();
|
||||
expect(params.get("skipList")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -46,6 +46,43 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
|
||||
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
|
||||
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||
|
||||
const MAX_EMPLOYER_FILTER_TOKENS = 25;
|
||||
const MAX_EMPLOYER_TOKEN_LENGTH = 120;
|
||||
|
||||
function normalizeEmployerFilterToken(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length > MAX_EMPLOYER_TOKEN_LENGTH) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseEmployerFilterList(
|
||||
searchParams: URLSearchParams,
|
||||
key: string,
|
||||
): string[] {
|
||||
const raw = searchParams.getAll(key);
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw) {
|
||||
const normalized = normalizeEmployerFilterToken(item);
|
||||
if (!normalized) continue;
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
if (out.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
|
||||
seen.add(dedupeKey);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseYmdFilter(raw: string | null): string | null {
|
||||
if (!raw?.trim()) return null;
|
||||
const candidate = raw.trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) return null;
|
||||
if (!Number.isFinite(Date.parse(`${candidate}T12:00:00`))) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export const useOrchestratorFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@ -72,12 +109,11 @@ export const useOrchestratorFilters = () => {
|
||||
const rawExc = searchParams.getAll("sourceExclude");
|
||||
const exclude = rawExc
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value): value is JobSource =>
|
||||
Boolean(value) &&
|
||||
allowedJobSources.has(value) &&
|
||||
!includeSet.has(value),
|
||||
);
|
||||
.filter((value): value is JobSource => {
|
||||
if (!value || !allowedJobSources.has(value)) return false;
|
||||
const source = value as JobSource;
|
||||
return !includeSet.has(source);
|
||||
});
|
||||
return {
|
||||
sourcesFilter: [...new Set(include)],
|
||||
sourcesExcludeFilter: [...new Set(exclude)],
|
||||
@ -253,6 +289,109 @@ export const useOrchestratorFilters = () => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const { foundAfterYmd, foundBeforeYmd } = useMemo(() => {
|
||||
return {
|
||||
foundAfterYmd: parseYmdFilter(searchParams.get("foundAfter")),
|
||||
foundBeforeYmd: parseYmdFilter(searchParams.get("foundBefore")),
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setFoundDateRange = useCallback(
|
||||
(foundAfter: string | null, foundBefore: string | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const nextAfter = parseYmdFilter(foundAfter);
|
||||
const nextBefore = parseYmdFilter(foundBefore);
|
||||
if (nextAfter) prev.set("foundAfter", nextAfter);
|
||||
else prev.delete("foundAfter");
|
||||
if (nextBefore) prev.set("foundBefore", nextBefore);
|
||||
else prev.delete("foundBefore");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const { employerIncludeFilter, employerExcludeFilter } = useMemo(() => {
|
||||
const include = parseEmployerFilterList(searchParams, "employer");
|
||||
const includeSet = new Set(include.map((value) => value.toLowerCase()));
|
||||
const exclude = parseEmployerFilterList(
|
||||
searchParams,
|
||||
"employerExclude",
|
||||
).filter((value) => !includeSet.has(value.toLowerCase()));
|
||||
return {
|
||||
employerIncludeFilter: include,
|
||||
employerExcludeFilter: exclude,
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setEmployerFilterTokens = useCallback(
|
||||
(include: string[], exclude: string[]) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
prev.delete("employer");
|
||||
prev.delete("employerExclude");
|
||||
|
||||
const normalizedInclude: string[] = [];
|
||||
const includeSeen = new Set<string>();
|
||||
for (const item of include) {
|
||||
const normalized = normalizeEmployerFilterToken(item);
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (includeSeen.has(key)) continue;
|
||||
if (normalizedInclude.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
|
||||
includeSeen.add(key);
|
||||
normalizedInclude.push(normalized);
|
||||
}
|
||||
const includeKeySet = new Set(
|
||||
normalizedInclude.map((value) => value.toLowerCase()),
|
||||
);
|
||||
|
||||
const normalizedExclude: string[] = [];
|
||||
const excludeSeenLow = new Set<string>();
|
||||
for (const item of exclude) {
|
||||
const normalized = normalizeEmployerFilterToken(item);
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (includeKeySet.has(key)) continue;
|
||||
if (excludeSeenLow.has(key)) continue;
|
||||
if (normalizedExclude.length >= MAX_EMPLOYER_FILTER_TOKENS) break;
|
||||
excludeSeenLow.add(key);
|
||||
normalizedExclude.push(normalized);
|
||||
}
|
||||
|
||||
for (const value of normalizedInclude) prev.append("employer", value);
|
||||
for (const value of normalizedExclude)
|
||||
prev.append("employerExclude", value);
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const applySettingsCompanySkipList = useMemo(
|
||||
() => searchParams.get("skipList") !== "0",
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const setApplySettingsCompanySkipList = useCallback(
|
||||
(value: boolean) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (value) prev.delete("skipList");
|
||||
else prev.set("skipList", "0");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const sort = useMemo((): JobSort => {
|
||||
const sortValue = searchParams.get("sort");
|
||||
if (!sortValue) return DEFAULT_SORT;
|
||||
@ -305,6 +444,11 @@ export const useOrchestratorFilters = () => {
|
||||
prev.delete("salaryMax");
|
||||
prev.delete("minSalary");
|
||||
prev.delete("sort");
|
||||
prev.delete("foundAfter");
|
||||
prev.delete("foundBefore");
|
||||
prev.delete("employer");
|
||||
prev.delete("employerExclude");
|
||||
prev.delete("skipList");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
@ -325,6 +469,14 @@ export const useOrchestratorFilters = () => {
|
||||
setWorkplaceFilter,
|
||||
salaryFilter,
|
||||
setSalaryFilter,
|
||||
foundAfterYmd,
|
||||
foundBeforeYmd,
|
||||
setFoundDateRange,
|
||||
employerIncludeFilter,
|
||||
employerExcludeFilter,
|
||||
setEmployerFilterTokens,
|
||||
applySettingsCompanySkipList,
|
||||
setApplySettingsCompanySkipList,
|
||||
sort,
|
||||
setSort,
|
||||
resetFilters,
|
||||
|
||||
@ -285,6 +285,48 @@ describe("discoverJobsStep", () => {
|
||||
expect(result.discoveredJobs[0]?.employer).toBe("Contoso");
|
||||
});
|
||||
|
||||
it("drops discovered jobs when blocked keywords use legacy comma-separated storage", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const registryModule = await import("@server/extractors/registry");
|
||||
|
||||
const jobspyManifest = {
|
||||
id: "jobspy",
|
||||
displayName: "JobSpy",
|
||||
providesSources: ["linkedin"],
|
||||
run: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
source: "linkedin",
|
||||
title: "Engineer",
|
||||
employer: "Acme Staffing",
|
||||
jobUrl: "https://example.com/job-legacy",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
blockedCompanyKeywords: "staffing, irrelevant",
|
||||
} as any);
|
||||
|
||||
vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue({
|
||||
manifests: new Map([["jobspy", jobspyManifest as any]]),
|
||||
manifestBySource: new Map([["linkedin", jobspyManifest as any]]),
|
||||
availableSources: ["linkedin"],
|
||||
} as any);
|
||||
|
||||
const result = await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...baseConfig,
|
||||
sources: ["linkedin"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.discoveredJobs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("applies shared city filtering for sources without native city filtering", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const registryModule = await import("@server/extractors/registry");
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
isSourceAllowedForCountry,
|
||||
normalizeCountryKey,
|
||||
} from "@shared/location-support.js";
|
||||
import { normalizeStringArray } from "@shared/normalize-string-array.js";
|
||||
import { resolveBlockedCompanyKeywordsFromStoredString } from "@shared/resolve-blocked-company-keywords.js";
|
||||
import {
|
||||
inferCountryKeyFromSearchGeography,
|
||||
matchesRequestedCity,
|
||||
@ -33,19 +33,6 @@ type DiscoverySourceTask = {
|
||||
run: () => Promise<DiscoveryTaskResult>;
|
||||
};
|
||||
|
||||
function parseBlockedCompanyKeywords(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return normalizeStringArray(
|
||||
parsed.filter((value): value is string => typeof value === "string"),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockedEmployer(
|
||||
employer: string | null | undefined,
|
||||
blockedKeywordsLowerCase: string[],
|
||||
@ -368,7 +355,7 @@ export async function discoverJobsStep(args: {
|
||||
});
|
||||
}
|
||||
|
||||
const blockedCompanyKeywords = parseBlockedCompanyKeywords(
|
||||
const blockedCompanyKeywords = resolveBlockedCompanyKeywordsFromStoredString(
|
||||
settings.blockedCompanyKeywords,
|
||||
);
|
||||
const blockedKeywordsLowerCase = blockedCompanyKeywords.map((value) =>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
|
||||
27
shared/src/resolve-blocked-company-keywords.ts
Normal file
27
shared/src/resolve-blocked-company-keywords.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { normalizeStringArray } from "./normalize-string-array";
|
||||
|
||||
/**
|
||||
* Parse stored settings value for blocked company keywords.
|
||||
* Accepts JSON string array (normal) or legacy plain comma/newline-separated text.
|
||||
*/
|
||||
export function resolveBlockedCompanyKeywordsFromStoredString(
|
||||
raw: string | undefined,
|
||||
): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return normalizeStringArray(
|
||||
parsed.filter((item): item is string => typeof item === "string"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Legacy or corrupted JSON: treat as token list
|
||||
}
|
||||
return normalizeStringArray(
|
||||
raw
|
||||
.split(/[\n,]/g)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user