diff --git a/orchestrator/src/client/pages/InProgressBoardPage.test.tsx b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx index a04798e..b5ff3d9 100644 --- a/orchestrator/src/client/pages/InProgressBoardPage.test.tsx +++ b/orchestrator/src/client/pages/InProgressBoardPage.test.tsx @@ -44,6 +44,7 @@ const makeJob = (overrides: Partial): 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", diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index aab3eda..7581ddf 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -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} diff --git a/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx b/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx index 647d1d2..9cefa2d 100644 --- a/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobRowContent.tsx @@ -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} )} + {job.discoveredAt?.trim() && ( +
+ Found {formatDiscoveredAt(job.discoveredAt)} +
+ )} {hasScore && ( diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx index 7818c9c..1a7cea0 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx @@ -45,6 +45,15 @@ const renderFilters = (overrides?: Partial) => { 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(); fireEvent.click(screen.getByRole("button", { name: /linkedin/i })); expect(props.onSourceSelectionChange).toHaveBeenCalledWith( diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx index 053d0cf..aab1885 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx @@ -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 = { 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 = ({ countriesFilter, countriesExcludeFilter, onCountrySelectionChange, + foundAfterYmd, + foundBeforeYmd, + onFoundDateRangeChange, + employerIncludeFilter, + employerExcludeFilter, + onEmployerIncludeChange, + onEmployerExcludeChange, + applySettingsCompanySkipList, + onApplySettingsCompanySkipListChange, sponsorFilter, onSponsorFilterChange, workplaceFilter, @@ -160,6 +190,8 @@ export const OrchestratorFilters: React.FC = ({ 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 = ({ () => 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 = ({ 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 = ({ 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. @@ -394,6 +438,117 @@ export const OrchestratorFilters: React.FC = ({ + + + Found in JobOps + + +

+ When this listing was first saved during discovery (your + local JobOps clock). Leave blank to show all dates. +

+
+
+ + { + const next = event.target.value; + onFoundDateRangeChange( + next ? next : null, + foundBeforeYmd, + ); + }} + /> +
+
+ + { + const next = event.target.value; + onFoundDateRangeChange( + foundAfterYmd, + next ? next : null, + ); + }} + /> +
+
+
+
+ + + + Employer keywords + + +

+ 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). +

+ +
+ { + onApplySettingsCompanySkipListChange( + checked === true, + ); + }} + /> +
+ +

+ Uncheck to temporarily show employers blocked in + Settings → Scoring (e.g. to clean up old rows). +

+
+
+ + + + +
+
+ Sponsor status diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts index 68f0e7b..d5acb05 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts @@ -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"]); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts index f5c373f..fa5dd86 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts @@ -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, ]); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx index bddd9f2..9c8f450 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx @@ -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(); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts index 7eb45f7..8243664 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts @@ -46,6 +46,43 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [ const allowedJobSources = new Set(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(); + 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(); + 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(); + 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, diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts index 8faae04..9eb90e3 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.test.ts @@ -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"); diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts index b9085fb..11658d2 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts @@ -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; }; -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) => diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index 52f3e7c..ecab543 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -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/*"], diff --git a/shared/src/resolve-blocked-company-keywords.ts b/shared/src/resolve-blocked-company-keywords.ts new file mode 100644 index 0000000..49a0c39 --- /dev/null +++ b/shared/src/resolve-blocked-company-keywords.ts @@ -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), + ); +}