feat(orchestrator): job list filters — multi source/country, URL sync, exclude

- Parse location strings into country keys (shared search-cities helper).
- URL params: source, sourceExclude, countries, countriesExclude.
- Chip cycle: off → include → exclude (destructive); remote bypasses country rules.
- README: document filter behaviour and query keys.

Unrelated local changes (scorer, notes, schema, etc.) remain unstaged.

Made-with: Cursor
This commit is contained in:
ilia 2026-04-06 15:50:47 -04:00
parent 77179b2b94
commit 4d7c8ac0bc
12 changed files with 729 additions and 84 deletions

106
README.md
View File

@ -1,50 +1,106 @@
# JobOps — job search orchestration (personal fork) # JobOps
Self-hosted stack: scrapes job boards, AI-scores fit, tailors resumes (RxResume), tracks application email. Self-hosted job search orchestration: discover roles from multiple sources, score fit with your profile, draft tailored resumes and cover letters, export PDFs, and track application email—**you still submit applications yourself**; JobOps prepares the work and keeps state organized.
You still apply yourself; the app finds roles, helps match CVs, and keeps status straight. Licensed under **AGPLv3 + Commons Clause** — see [LICENSE](LICENSE).
Docker-based. See [LICENSE](LICENSE) for terms. ![Product screenshot](https://github.com/user-attachments/assets/14fdc392-0e96-43be-bc1f-cf819ab2afc4)
<img width="1200" height="600" alt="Product screenshot" src="https://github.com/user-attachments/assets/14fdc392-0e96-43be-bc1f-cf819ab2afc4" /> ## Whats in this repo
## Documentation | Area | Role |
|------|------|
| **`orchestrator/`** | Express API, SQLite + Drizzle, pipeline, React (Vite) UI, LLM integrations, Reactive Resume PDF flow |
| **`shared/`** | Shared TypeScript types and settings registry |
| **`docs-site/`** | Docusaurus user and developer documentation |
| **`extractors/`** | Per-source crawlers (Adzuna, Gradcracker, UK Visa Jobs, Hiring Café, Startup Jobs, JobSpy helpers, etc.) |
Full docs live in this repo under `docs-site/`. Root `package.json` is an npm **workspace** root; day-to-day app commands usually run under `orchestrator/`.
```bash ## Features (high level)
npm install
npm run docs:dev
```
Then open the local URL Docusaurus prints (usually `http://localhost:3000`). - **Sources**: Multiple boards and aggregators (exact list evolves; see docs and extractor packages).
- **Scoring & tailoring**: LLM compares jobs to your resume profile; optional drafts for summary, headline, skills, and project selection.
- **PDFs**: Tailored exports via **Reactive Resume** (v4 or v5 API). Optional **local JSON resume** (`JOBOPS_LOCAL_RESUME_PATH` or Settings) as the base document for profile/tailoring; PDF export still uses RxResume when configured.
- **Pipeline**: Scheduled or manual runs (`POST /api/pipeline/run`, webhook trigger).
- **Post-application**: Optional Gmail-based inbox for interview/offer/rejection signals.
- **Job list filters** (orchestrator UI): Narrow the pipeline job list by **multiple sources** and **countries** (country is inferred from each listings location text). Filters sync to the URL (`source`, `sourceExclude`, `countries`, `countriesExclude`). Each source/country chip cycles **off → include → exclude** (exclude shows in red); listings marked **remote** still pass country include/exclude rules.
- **Data**: SQLite and generated artifacts under `./data` (default in Docker).
## Quick start ## Requirements
- **Node.js 22** (see `package.json` / Volta pin) for local development.
- **Docker + Compose v2** for the recommended production-style run.
## Quick start (Docker)
```bash ```bash
git clone <your-repo-url> git clone <your-repo-url>
cd Jobber # or whatever you named the directory cd Jobber
cp .env.example .env cp .env.example .env
# Edit .env: model / LLM keys, RXRESUME_*, search settings, etc. # Edit .env: MODEL / LLM keys, Reactive Resume or local resume path, optional BASIC_AUTH, etc.
docker compose up -d docker compose up -d --build
``` ```
Dashboard: `http://localhost:3005` (host port from `docker-compose.yml`; app listens on 3001 inside the container). - **UI**: `http://localhost:3005` (host port mapped in `docker-compose.yml`; app listens on **3001** inside the container).
- **Health**: `GET /health` on the same origin.
## Features (summary) Persist data by backing up the mounted **`./data`** directory.
- **Sources**: LinkedIn, Indeed, Glassdoor, Adzuna, Hiring Café, Gradcracker, UK Visa Jobs (and extensible extractors). ## Local development
- **Scoring**: LLM ranking vs your profile (OpenAI, OpenRouter, OpenAI-compatible, Gemini, etc.).
- **Resumes**: Tailored PDFs via [RxResume v4](https://v4.rxresu.me). From the repository root:
- **Email**: Gmail integration for interview / offer / rejection signals.
- **Data**: SQLite under `./data` when using the default compose setup. ```bash
npm install
```
Then use the orchestrator workspace (see **`orchestrator/README.md`** for API tables and scripts):
```bash
cd orchestrator
cp .env.example .env
npm run db:migrate
npm run dev
```
Typical dev URLs: API **http://localhost:3001**, Vite client **http://localhost:5173**.
## Configuration notes
- **LLM**: Configurable provider (OpenRouter default; also OpenAI, OpenAI-compatible endpoints, Gemini, Ollama, LM Studio, etc.). Keys can be set in `.env` or onboarding.
- **Reactive Resume**: v5 API key or v4 email/password; optional self-hosted `RXRESUME_URL`. Full behavior is documented in the docs site under **Reactive Resume**.
- **Local resume JSON**: Set `JOBOPS_LOCAL_RESUME_PATH` (or the Settings field) to a Reactive Resumeshaped export so you do not need a selected cloud “base resume” for profile and tailoring. PDF generation still performs a temporary import/print through RxResume when credentials are present.
## Documentation
| Resource | Contents |
|----------|----------|
| **`docs-site/`** | User-facing guides (build with `npm run docs:dev` from repo root) |
| **`orchestrator/README.md`** | Orchestrator layout, endpoints, dev commands |
| **`DEPLOY_GITEA_VM_CRON_TELEGRAM.md`** | VM/container deploy, cron, optional Telegram |
| **`AGENTS.md`** | API/logging/SSE conventions for contributors and automation |
## Deploy, cron, Telegram ## Deploy, cron, Telegram
See [DEPLOY_GITEA_VM_CRON_TELEGRAM.md](./DEPLOY_GITEA_VM_CRON_TELEGRAM.md) for VM or container deploy, scheduled `POST /api/pipeline/run`, and optional Telegram notifications. See **[DEPLOY_GITEA_VM_CRON_TELEGRAM.md](./DEPLOY_GITEA_VM_CRON_TELEGRAM.md)** for production deploy, scheduling `POST /api/pipeline/run`, and optional notifications.
## Verification (CI parity)
From the repo root, before merging substantial changes:
```bash
./orchestrator/node_modules/.bin/biome ci .
npm run check:types:shared
npm --workspace orchestrator run check:types
npm --workspace orchestrator run build:client
npm --workspace orchestrator run test:run
```
(Additional workspace typechecks apply in CI; see **`AGENTS.md`**.)
## License ## License
**AGPLv3 + Commons Clause** — self-host, use, and modify; you may not sell the software or offer paid hosting/support whose value substantially comes from this codebase. Details in [LICENSE](LICENSE). **AGPLv3 + Commons Clause** — you may self-host, use, and modify; you may not sell the software or offer paid hosting or support whose value substantially comes from this codebase. See [LICENSE](LICENSE).

View File

@ -204,7 +204,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
OrchestratorFilters: ({ OrchestratorFilters: ({
onTabChange, onTabChange,
onOpenCommandBar, onOpenCommandBar,
onSourceFilterChange, onSourceSelectionChange,
onSponsorFilterChange, onSponsorFilterChange,
onSalaryFilterChange, onSalaryFilterChange,
onResetFilters, onResetFilters,
@ -214,7 +214,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
}: { }: {
onTabChange: (t: FilterTab) => void; onTabChange: (t: FilterTab) => void;
onOpenCommandBar: () => void; onOpenCommandBar: () => void;
onSourceFilterChange: (source: string) => void; onSourceSelectionChange: (include: string[], exclude: string[]) => void;
onSponsorFilterChange: (value: string) => void; onSponsorFilterChange: (value: string) => void;
onSalaryFilterChange: (value: { onSalaryFilterChange: (value: {
mode: "at_least" | "at_most" | "between"; mode: "at_least" | "at_most" | "between";
@ -241,7 +241,10 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
> >
Set Sort Set Sort
</button> </button>
<button type="button" onClick={() => onSourceFilterChange("linkedin")}> <button
type="button"
onClick={() => onSourceSelectionChange(["linkedin"], [])}
>
Set Source Set Source
</button> </button>
<button type="button" onClick={() => onSponsorFilterChange("confirmed")}> <button type="button" onClick={() => onSponsorFilterChange("confirmed")}>
@ -663,6 +666,9 @@ describe("OrchestratorPage", () => {
fireEvent.click(screen.getByText("Reset Filters")); fireEvent.click(screen.getByText("Reset Filters"));
const locationText = screen.getByTestId("location").textContent || ""; const locationText = screen.getByTestId("location").textContent || "";
expect(locationText).not.toContain("source="); expect(locationText).not.toContain("source=");
expect(locationText).not.toContain("sourceExclude=");
expect(locationText).not.toContain("countries=");
expect(locationText).not.toContain("countriesExclude=");
expect(locationText).not.toContain("sponsor="); expect(locationText).not.toContain("sponsor=");
expect(locationText).not.toContain("salaryMode="); expect(locationText).not.toContain("salaryMode=");
expect(locationText).not.toContain("salaryMin="); expect(locationText).not.toContain("salaryMin=");

View File

@ -25,6 +25,7 @@ import { usePipelineControls } from "./orchestrator/usePipelineControls";
import { usePipelineSources } from "./orchestrator/usePipelineSources"; import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem"; import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
import { import {
getCountriesWithJobs,
getEnabledSources, getEnabledSources,
getJobCounts, getJobCounts,
getSourcesWithJobs, getSourcesWithJobs,
@ -35,8 +36,12 @@ export const OrchestratorPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
searchParams, searchParams,
sourceFilter, sourcesFilter,
setSourceFilter, sourcesExcludeFilter,
setSourceSelection,
countriesFilter,
countriesExcludeFilter,
setCountrySelection,
sponsorFilter, sponsorFilter,
setSponsorFilter, setSponsorFilter,
workplaceFilter, workplaceFilter,
@ -144,7 +149,10 @@ export const OrchestratorPage: React.FC = () => {
const activeJobs = useFilteredJobs( const activeJobs = useFilteredJobs(
jobs, jobs,
activeTab, activeTab,
sourceFilter, sourcesFilter,
sourcesExcludeFilter,
countriesFilter,
countriesExcludeFilter,
sponsorFilter, sponsorFilter,
workplaceFilter, workplaceFilter,
salaryFilter, salaryFilter,
@ -181,6 +189,31 @@ export const OrchestratorPage: React.FC = () => {
const counts = useMemo(() => getJobCounts(jobs), [jobs]); const counts = useMemo(() => getJobCounts(jobs), [jobs]);
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]); const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const countriesWithJobs = useMemo(() => getCountriesWithJobs(jobs), [jobs]);
useEffect(() => {
if (isLoading) return;
if (countriesFilter.length === 0 && countriesExcludeFilter.length === 0)
return;
const validI = countriesFilter.filter((key) =>
countriesWithJobs.includes(key),
);
const validE = countriesExcludeFilter.filter((key) =>
countriesWithJobs.includes(key),
);
if (
validI.length === countriesFilter.length &&
validE.length === countriesExcludeFilter.length
)
return;
setCountrySelection(validI, validE);
}, [
isLoading,
countriesFilter,
countriesExcludeFilter,
countriesWithJobs,
setCountrySelection,
]);
const { const {
selectedJobIds, selectedJobIds,
canSkipSelected, canSkipSelected,
@ -199,11 +232,27 @@ export const OrchestratorPage: React.FC = () => {
}); });
useEffect(() => { useEffect(() => {
if (isLoading || sourceFilter === "all") return; if (isLoading) return;
if (!sourcesWithJobs.includes(sourceFilter)) { if (sourcesFilter.length === 0 && sourcesExcludeFilter.length === 0) return;
setSourceFilter("all"); const validI = sourcesFilter.filter((source) =>
} sourcesWithJobs.includes(source),
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]); );
const validE = sourcesExcludeFilter.filter((source) =>
sourcesWithJobs.includes(source),
);
if (
validI.length === sourcesFilter.length &&
validE.length === sourcesExcludeFilter.length
)
return;
setSourceSelection(validI, validE);
}, [
isLoading,
sourcesFilter,
sourcesExcludeFilter,
setSourceSelection,
sourcesWithJobs,
]);
const handleSelectJob = (id: string) => { const handleSelectJob = (id: string) => {
handleSelectJobId(id); handleSelectJobId(id);
@ -268,6 +317,9 @@ export const OrchestratorPage: React.FC = () => {
const nextParams = new URLSearchParams(searchParams); const nextParams = new URLSearchParams(searchParams);
for (const key of [ for (const key of [
"source", "source",
"sourceExclude",
"countries",
"countriesExclude",
"sponsor", "sponsor",
"salaryMode", "salaryMode",
"salaryMin", "salaryMin",
@ -386,8 +438,12 @@ export const OrchestratorPage: React.FC = () => {
onOpenCommandBar={() => setIsCommandBarOpen(true)} onOpenCommandBar={() => setIsCommandBarOpen(true)}
isFiltersOpen={isFiltersOpen} isFiltersOpen={isFiltersOpen}
onFiltersOpenChange={setIsFiltersOpen} onFiltersOpenChange={setIsFiltersOpen}
sourceFilter={sourceFilter} sourcesFilter={sourcesFilter}
onSourceFilterChange={setSourceFilter} sourcesExcludeFilter={sourcesExcludeFilter}
onSourceSelectionChange={setSourceSelection}
countriesFilter={countriesFilter}
countriesExcludeFilter={countriesExcludeFilter}
onCountrySelectionChange={setCountrySelection}
sponsorFilter={sponsorFilter} sponsorFilter={sponsorFilter}
onSponsorFilterChange={setSponsorFilter} onSponsorFilterChange={setSponsorFilter}
workplaceFilter={workplaceFilter} workplaceFilter={workplaceFilter}
@ -395,6 +451,7 @@ export const OrchestratorPage: React.FC = () => {
salaryFilter={salaryFilter} salaryFilter={salaryFilter}
onSalaryFilterChange={setSalaryFilter} onSalaryFilterChange={setSalaryFilter}
sourcesWithJobs={sourcesWithJobs} sourcesWithJobs={sourcesWithJobs}
countriesWithJobs={countriesWithJobs}
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
onResetFilters={resetFilters} onResetFilters={resetFilters}

View File

@ -10,6 +10,8 @@ import type {
} from "./constants"; } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters"; import { OrchestratorFilters } from "./OrchestratorFilters";
type FiltersProps = ComponentProps<typeof OrchestratorFilters>;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeAll(() => { beforeAll(() => {
@ -26,10 +28,8 @@ afterAll(() => {
}); });
}); });
const renderFilters = ( const renderFilters = (overrides?: Partial<FiltersProps>) => {
overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>, const props: FiltersProps = {
) => {
const props = {
activeTab: "ready" as FilterTab, activeTab: "ready" as FilterTab,
onTabChange: vi.fn(), onTabChange: vi.fn(),
counts: { counts: {
@ -39,8 +39,12 @@ const renderFilters = (
all: 6, all: 6,
}, },
onOpenCommandBar: vi.fn(), onOpenCommandBar: vi.fn(),
sourceFilter: "all" as const, sourcesFilter: [] as JobSource[],
onSourceFilterChange: vi.fn(), sourcesExcludeFilter: [] as JobSource[],
onSourceSelectionChange: vi.fn(),
countriesFilter: [] as string[],
countriesExcludeFilter: [] as string[],
onCountrySelectionChange: vi.fn(),
sponsorFilter: "all" as SponsorFilter, sponsorFilter: "all" as SponsorFilter,
onSponsorFilterChange: vi.fn(), onSponsorFilterChange: vi.fn(),
workplaceFilter: "all" as WorkplaceFilter, workplaceFilter: "all" as WorkplaceFilter,
@ -52,6 +56,7 @@ const renderFilters = (
}, },
onSalaryFilterChange: vi.fn(), onSalaryFilterChange: vi.fn(),
sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[], sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[],
countriesWithJobs: ["united kingdom"] as string[],
sort: { key: "score", direction: "desc" } as JobSort, sort: { key: "score", direction: "desc" } as JobSort,
onSortChange: vi.fn(), onSortChange: vi.fn(),
onResetFilters: vi.fn(), onResetFilters: vi.fn(),
@ -77,12 +82,23 @@ describe("OrchestratorFilters", () => {
}); });
it("updates source, sponsor, salary range, and sort from the drawer", async () => { it("updates source, sponsor, salary range, and sort from the drawer", async () => {
const { props } = renderFilters(); const { props, rerender } = renderFilters();
fireEvent.click(screen.getByRole("button", { name: /^filters/i })); fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
fireEvent.click(await screen.findByRole("button", { name: /linkedin/i })); fireEvent.click(await screen.findByRole("button", { name: /linkedin/i }));
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin"); expect(props.onSourceSelectionChange).toHaveBeenCalledWith(
["linkedin"],
[],
);
props.onSourceSelectionChange.mockClear();
rerender(<OrchestratorFilters {...props} sourcesFilter={["linkedin"]} />);
fireEvent.click(screen.getByRole("button", { name: /linkedin/i }));
expect(props.onSourceSelectionChange).toHaveBeenCalledWith(
[],
["linkedin"],
);
fireEvent.click(screen.getByRole("button", { name: "Potential sponsor" })); fireEvent.click(screen.getByRole("button", { name: "Potential sponsor" }));
expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential"); expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential");
@ -134,6 +150,7 @@ describe("OrchestratorFilters", () => {
it("resets filters and only shows sources present in jobs", async () => { it("resets filters and only shows sources present in jobs", async () => {
const { props } = renderFilters({ const { props } = renderFilters({
sourcesWithJobs: ["gradcracker", "manual"], sourcesWithJobs: ["gradcracker", "manual"],
countriesWithJobs: [],
}); });
fireEvent.click(screen.getByRole("button", { name: /^filters/i })); fireEvent.click(screen.getByRole("button", { name: /^filters/i }));

View File

@ -1,5 +1,6 @@
import { KbdHint } from "@client/components/KbdHint"; import { KbdHint } from "@client/components/KbdHint";
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map"; import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
import { formatCountryLabel } from "@shared/location-support";
import type { JobSource } from "@shared/types.js"; import type { JobSource } from "@shared/types.js";
import { Filter, Search } from "lucide-react"; import { Filter, Search } from "lucide-react";
import type React from "react"; import type React from "react";
@ -41,8 +42,12 @@ interface OrchestratorFiltersProps {
onTabChange: (value: FilterTab) => void; onTabChange: (value: FilterTab) => void;
counts: Record<FilterTab, number>; counts: Record<FilterTab, number>;
onOpenCommandBar: () => void; onOpenCommandBar: () => void;
sourceFilter: JobSource | "all"; sourcesFilter: JobSource[];
onSourceFilterChange: (value: JobSource | "all") => void; sourcesExcludeFilter: JobSource[];
onSourceSelectionChange: (include: JobSource[], exclude: JobSource[]) => void;
countriesFilter: string[];
countriesExcludeFilter: string[];
onCountrySelectionChange: (include: string[], exclude: string[]) => void;
sponsorFilter: SponsorFilter; sponsorFilter: SponsorFilter;
onSponsorFilterChange: (value: SponsorFilter) => void; onSponsorFilterChange: (value: SponsorFilter) => void;
workplaceFilter: WorkplaceFilter; workplaceFilter: WorkplaceFilter;
@ -50,6 +55,7 @@ interface OrchestratorFiltersProps {
salaryFilter: SalaryFilter; salaryFilter: SalaryFilter;
onSalaryFilterChange: (value: SalaryFilter) => void; onSalaryFilterChange: (value: SalaryFilter) => void;
sourcesWithJobs: JobSource[]; sourcesWithJobs: JobSource[];
countriesWithJobs: string[];
sort: JobSort; sort: JobSort;
onSortChange: (sort: JobSort) => void; onSortChange: (sort: JobSort) => void;
onResetFilters: () => void; onResetFilters: () => void;
@ -130,8 +136,12 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onTabChange, onTabChange,
counts, counts,
onOpenCommandBar, onOpenCommandBar,
sourceFilter, sourcesFilter,
onSourceFilterChange, sourcesExcludeFilter,
onSourceSelectionChange,
countriesFilter,
countriesExcludeFilter,
onCountrySelectionChange,
sponsorFilter, sponsorFilter,
onSponsorFilterChange, onSponsorFilterChange,
workplaceFilter, workplaceFilter,
@ -139,6 +149,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
salaryFilter, salaryFilter,
onSalaryFilterChange, onSalaryFilterChange,
sourcesWithJobs, sourcesWithJobs,
countriesWithJobs,
sort, sort,
onSortChange, onSortChange,
onResetFilters, onResetFilters,
@ -154,9 +165,42 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
sourcesWithJobs.includes(source), sourcesWithJobs.includes(source),
); );
const cycleSource = (source: JobSource) => {
const inInclude = sourcesFilter.includes(source);
const inExclude = sourcesExcludeFilter.includes(source);
let nextInclude = [...sourcesFilter];
let nextExclude = [...sourcesExcludeFilter];
if (!inInclude && !inExclude) {
nextInclude.push(source);
} else if (inInclude) {
nextInclude = nextInclude.filter((value) => value !== source);
nextExclude.push(source);
} else {
nextExclude = nextExclude.filter((value) => value !== source);
}
onSourceSelectionChange(nextInclude, nextExclude);
};
const cycleCountry = (countryKey: string) => {
const inInclude = countriesFilter.includes(countryKey);
const inExclude = countriesExcludeFilter.includes(countryKey);
let nextInclude = [...countriesFilter];
let nextExclude = [...countriesExcludeFilter];
if (!inInclude && !inExclude) {
nextInclude.push(countryKey);
} else if (inInclude) {
nextInclude = nextInclude.filter((value) => value !== countryKey);
nextExclude.push(countryKey);
} else {
nextExclude = nextExclude.filter((value) => value !== countryKey);
}
onCountrySelectionChange(nextInclude, nextExclude);
};
const activeFilterCount = useMemo( const activeFilterCount = useMemo(
() => () =>
Number(sourceFilter !== "all") + Number(sourcesFilter.length > 0 || sourcesExcludeFilter.length > 0) +
Number(countriesFilter.length > 0 || countriesExcludeFilter.length > 0) +
Number(sponsorFilter !== "all") + Number(sponsorFilter !== "all") +
Number(workplaceFilter !== "all") + Number(workplaceFilter !== "all") +
Number( Number(
@ -164,7 +208,10 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
(typeof salaryFilter.max === "number" && salaryFilter.max > 0), (typeof salaryFilter.max === "number" && salaryFilter.max > 0),
), ),
[ [
sourceFilter, sourcesFilter.length,
sourcesExcludeFilter.length,
countriesFilter.length,
countriesExcludeFilter.length,
sponsorFilter, sponsorFilter,
workplaceFilter, workplaceFilter,
salaryFilter.min, salaryFilter.min,
@ -246,8 +293,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
)} )}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
Refine sources, sponsor status, workplace (remote), salary, Refine sources and country: each button cycles off include
and sorting. exclude (red). Remote listings always bypass country
filters. Sponsor, workplace, salary, and sort below.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@ -262,24 +310,87 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={sourceFilter === "all" ? "default" : "outline"} variant={
onClick={() => onSourceFilterChange("all")} sourcesFilter.length === 0 &&
sourcesExcludeFilter.length === 0
? "default"
: "outline"
}
onClick={() => onSourceSelectionChange([], [])}
> >
All sources All sources
</Button> </Button>
{visibleSources.map((source) => ( {visibleSources.map((source) => {
const excluded = sourcesExcludeFilter.includes(source);
const included = sourcesFilter.includes(source);
return (
<Button
key={source}
type="button"
size="sm"
variant={
excluded
? "destructive"
: included
? "default"
: "outline"
}
onClick={() => cycleSource(source)}
>
{sourceLabel[source]}
</Button>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Country</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xs text-muted-foreground">
Parsed from each job&apos;s location. Jobs marked remote
in the listing always match include and exclude country
rules.
</p>
<div className="flex flex-wrap gap-2">
<Button <Button
key={source}
type="button" type="button"
size="sm" size="sm"
variant={ variant={
sourceFilter === source ? "default" : "outline" countriesFilter.length === 0 &&
countriesExcludeFilter.length === 0
? "default"
: "outline"
} }
onClick={() => onSourceFilterChange(source)} onClick={() => onCountrySelectionChange([], [])}
> >
{sourceLabel[source]} All countries
</Button> </Button>
))} {countriesWithJobs.map((countryKey) => {
const excluded =
countriesExcludeFilter.includes(countryKey);
const included = countriesFilter.includes(countryKey);
return (
<Button
key={countryKey}
type="button"
size="sm"
variant={
excluded
? "destructive"
: included
? "default"
: "outline"
}
onClick={() => cycleCountry(countryKey)}
>
{formatCountryLabel(countryKey)}
</Button>
);
})}
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -31,7 +31,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"all", "all",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -59,7 +62,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"ready", "ready",
"all", [],
[],
[],
[],
"all", "all",
"all", "all",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -88,7 +94,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"confirmed", "confirmed",
"all", "all",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -114,7 +123,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"all", "all",
{ mode: "between", min: 60000, max: 80000 }, { mode: "between", min: 60000, max: 80000 },
@ -143,7 +155,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"all", "all",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -173,7 +188,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"remote", "remote",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -186,7 +204,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"not_remote", "not_remote",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -199,7 +220,10 @@ describe("useFilteredJobs", () => {
useFilteredJobs( useFilteredJobs(
jobs, jobs,
"all", "all",
"all", [],
[],
[],
[],
"all", "all",
"unknown", "unknown",
{ mode: "at_least", min: null, max: null }, { mode: "at_least", min: null, max: null },
@ -208,4 +232,134 @@ describe("useFilteredJobs", () => {
); );
expect(unknown.current.map((j) => j.id)).toEqual(["unknown"]); expect(unknown.current.map((j) => j.id)).toEqual(["unknown"]);
}); });
it("allows multiple source filters", () => {
const jobs: Job[] = [
{ ...baseJob, id: "li", source: "linkedin" },
{ ...baseJob, id: "in", source: "indeed" },
{ ...baseJob, id: "gc", source: "gradcracker" },
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
["linkedin", "indeed"],
[],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((j) => j.id).sort()).toEqual(["in", "li"]);
});
it("filters by country but always keeps remote listings", () => {
const jobs: Job[] = [
{
...baseJob,
id: "uk-onsite",
location: "London, UK",
isRemote: false,
},
{
...baseJob,
id: "us-remote",
location: "New York, NY, United States",
isRemote: true,
},
{
...baseJob,
id: "us-onsite",
location: "New York, NY, United States",
isRemote: false,
},
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
["united kingdom"],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((j) => j.id).sort()).toEqual([
"uk-onsite",
"us-remote",
]);
});
it("excludes by country but keeps remote listings", () => {
const jobs: Job[] = [
{
...baseJob,
id: "uk-onsite",
location: "London, UK",
isRemote: false,
},
{
...baseJob,
id: "uk-remote",
location: "London, UK",
isRemote: true,
},
{
...baseJob,
id: "us-onsite",
location: "New York, United States",
isRemote: false,
},
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
[],
[],
["united kingdom"],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((j) => j.id).sort()).toEqual([
"uk-remote",
"us-onsite",
]);
});
it("excludes sources", () => {
const jobs: Job[] = [
{ ...baseJob, id: "li", source: "linkedin" },
{ ...baseJob, id: "in", source: "indeed" },
];
const { result } = renderHook(() =>
useFilteredJobs(
jobs,
"all",
[],
["linkedin"],
[],
[],
"all",
"all",
{ mode: "at_least", min: null, max: null },
{ key: "score", direction: "desc" },
),
);
expect(result.current.map((j) => j.id)).toEqual(["in"]);
});
}); });

View File

@ -1,3 +1,4 @@
import { inferCountryKeysFromJobLocation } from "@shared/search-cities";
import type { JobListItem, JobSource } from "@shared/types"; import type { JobListItem, JobSource } from "@shared/types";
import { useMemo } from "react"; import { useMemo } from "react";
import type { import type {
@ -19,7 +20,10 @@ const getSponsorCategory = (score: number | null): SponsorFilter => {
export const useFilteredJobs = ( export const useFilteredJobs = (
jobs: JobListItem[], jobs: JobListItem[],
activeTab: FilterTab, activeTab: FilterTab,
sourceFilter: JobSource | "all", sourcesFilter: JobSource[],
sourcesExcludeFilter: JobSource[],
countriesFilter: string[],
countriesExcludeFilter: string[],
sponsorFilter: SponsorFilter, sponsorFilter: SponsorFilter,
workplaceFilter: WorkplaceFilter, workplaceFilter: WorkplaceFilter,
salaryFilter: SalaryFilter, salaryFilter: SalaryFilter,
@ -46,8 +50,32 @@ export const useFilteredJobs = (
filtered = filtered.filter((job) => job.closedAt == null); filtered = filtered.filter((job) => job.closedAt == null);
} }
if (sourceFilter !== "all") { if (sourcesFilter.length > 0) {
filtered = filtered.filter((job) => job.source === sourceFilter); const allow = new Set(sourcesFilter);
filtered = filtered.filter((job) => allow.has(job.source));
}
if (sourcesExcludeFilter.length > 0) {
const deny = new Set(sourcesExcludeFilter);
filtered = filtered.filter((job) => !deny.has(job.source));
}
if (countriesFilter.length > 0) {
const allowCountries = new Set(countriesFilter);
filtered = filtered.filter((job) => {
if (job.isRemote === true) return true;
const jobCountries = inferCountryKeysFromJobLocation(job.location);
return jobCountries.some((key) => allowCountries.has(key));
});
}
if (countriesExcludeFilter.length > 0) {
const denyCountries = new Set(countriesExcludeFilter);
filtered = filtered.filter((job) => {
if (job.isRemote === true) return true;
const jobCountries = inferCountryKeysFromJobLocation(job.location);
return !jobCountries.some((key) => denyCountries.has(key));
});
} }
if (sponsorFilter !== "all") { if (sponsorFilter !== "all") {
@ -106,7 +134,10 @@ export const useFilteredJobs = (
}, [ }, [
jobs, jobs,
activeTab, activeTab,
sourceFilter, sourcesFilter,
sourcesExcludeFilter,
countriesFilter,
countriesExcludeFilter,
sponsorFilter, sponsorFilter,
workplaceFilter, workplaceFilter,
salaryFilter, salaryFilter,

View File

@ -1,4 +1,4 @@
import { renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@ -39,4 +39,54 @@ describe("useOrchestratorFilters", () => {
expect(result.current.sort).toEqual(DEFAULT_SORT); expect(result.current.sort).toEqual(DEFAULT_SORT);
} }
}); });
it("parses multiple sources and countries from the query string", () => {
const { result } = renderHook(() => useOrchestratorFilters(), {
wrapper: createWrapper(
"/ready?source=indeed&source=linkedin&countries=united%20kingdom",
),
});
expect(result.current.sourcesFilter).toEqual(["indeed", "linkedin"]);
expect(result.current.countriesFilter).toEqual(["united kingdom"]);
});
it("writes multiple sources and countries to the query string", () => {
const { result } = renderHook(() => useOrchestratorFilters(), {
wrapper: createWrapper("/ready"),
});
act(() => {
result.current.setSourceSelection(["indeed", "linkedin"], []);
});
act(() => {
result.current.setCountrySelection(["united kingdom", "canada"], []);
});
const params = result.current.searchParams;
expect(params.getAll("source").sort()).toEqual(["indeed", "linkedin"]);
expect(params.get("countries")).toBe("canada,united kingdom");
});
it("parses and writes country and source excludes", () => {
const { result } = renderHook(() => useOrchestratorFilters(), {
wrapper: createWrapper(
"/ready?sourceExclude=linkedin&countriesExclude=united%20kingdom",
),
});
expect(result.current.sourcesExcludeFilter).toEqual(["linkedin"]);
expect(result.current.countriesExcludeFilter).toEqual(["united kingdom"]);
act(() => {
result.current.setSourceSelection(["indeed"], ["linkedin"]);
});
act(() => {
result.current.setCountrySelection(["canada"], ["united kingdom"]);
});
const params = result.current.searchParams;
expect(params.getAll("source")).toEqual(["indeed"]);
expect(params.getAll("sourceExclude")).toEqual(["linkedin"]);
expect(params.get("countries")).toBe("canada");
expect(params.get("countriesExclude")).toBe("united kingdom");
});
}); });

View File

@ -1,4 +1,9 @@
import type { JobSource } from "@shared/types.js"; import { EXTRACTOR_SOURCE_IDS } from "@shared/extractors";
import {
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support";
import type { JobSource } from "@shared/types";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import type { import type {
@ -38,6 +43,9 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
"unknown", "unknown",
]; ];
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
export const useOrchestratorFilters = () => { export const useOrchestratorFilters = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -52,14 +60,109 @@ export const useOrchestratorFilters = () => {
); );
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
const sourceFilter = const { sourcesFilter, sourcesExcludeFilter } = useMemo(() => {
(searchParams.get("source") as JobSource | "all") || "all"; const rawInc = searchParams.getAll("source");
const setSourceFilter = useCallback( const include = rawInc
(source: JobSource | "all") => { .map((value) => value.trim())
.filter(
(value): value is JobSource =>
Boolean(value) && allowedJobSources.has(value),
);
const includeSet = new Set(include);
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),
);
return {
sourcesFilter: [...new Set(include)],
sourcesExcludeFilter: [...new Set(exclude)],
};
}, [searchParams]);
const setSourceSelection = useCallback(
(include: JobSource[], exclude: JobSource[]) => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
if (source !== "all") prev.set("source", source); prev.delete("source");
else prev.delete("source"); prev.delete("sourceExclude");
const inc = [
...new Set(
include.filter((source) => allowedJobSources.has(source)),
),
].sort((left, right) => left.localeCompare(right));
const incSet = new Set(inc);
const exc = [
...new Set(
exclude.filter(
(source) =>
allowedJobSources.has(source) && !incSet.has(source),
),
),
].sort((left, right) => left.localeCompare(right));
for (const source of inc) prev.append("source", source);
for (const source of exc) prev.append("sourceExclude", source);
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
const { countriesFilter, countriesExcludeFilter } = useMemo(() => {
const parseList = (raw: string | null) => {
if (!raw?.trim()) return [];
return raw
.split(",")
.map((segment) => normalizeCountryKey(segment.trim()))
.filter((key) => key && allowedCountryKeys.has(key));
};
const include = [...new Set(parseList(searchParams.get("countries")))];
const includeSet = new Set(include);
const excludeRaw = parseList(searchParams.get("countriesExclude"));
const exclude = [
...new Set(excludeRaw.filter((key) => !includeSet.has(key))),
];
return {
countriesFilter: include.sort((left, right) => left.localeCompare(right)),
countriesExcludeFilter: exclude.sort((left, right) =>
left.localeCompare(right),
),
};
}, [searchParams]);
const setCountrySelection = useCallback(
(includeKeys: string[], excludeKeys: string[]) => {
setSearchParams(
(prev) => {
const inc = [
...new Set(
includeKeys
.map((key) => normalizeCountryKey(key))
.filter((key) => key && allowedCountryKeys.has(key)),
),
].sort((left, right) => left.localeCompare(right));
const incSet = new Set(inc);
const exc = [
...new Set(
excludeKeys
.map((key) => normalizeCountryKey(key))
.filter(
(key) =>
key && allowedCountryKeys.has(key) && !incSet.has(key),
),
),
].sort((left, right) => left.localeCompare(right));
if (inc.length === 0) prev.delete("countries");
else prev.set("countries", inc.join(","));
if (exc.length === 0) prev.delete("countriesExclude");
else prev.set("countriesExclude", exc.join(","));
return prev; return prev;
}, },
{ replace: true }, { replace: true },
@ -192,6 +295,9 @@ export const useOrchestratorFilters = () => {
setSearchParams( setSearchParams(
(prev) => { (prev) => {
prev.delete("source"); prev.delete("source");
prev.delete("sourceExclude");
prev.delete("countries");
prev.delete("countriesExclude");
prev.delete("sponsor"); prev.delete("sponsor");
prev.delete("workplace"); prev.delete("workplace");
prev.delete("salaryMode"); prev.delete("salaryMode");
@ -207,8 +313,12 @@ export const useOrchestratorFilters = () => {
return { return {
searchParams, searchParams,
sourceFilter, sourcesFilter,
setSourceFilter, sourcesExcludeFilter,
setSourceSelection,
countriesFilter,
countriesExcludeFilter,
setCountrySelection,
sponsorFilter, sponsorFilter,
setSponsorFilter, setSponsorFilter,
workplaceFilter, workplaceFilter,

View File

@ -1,3 +1,5 @@
import { formatCountryLabel } from "@shared/location-support";
import { inferCountryKeysFromJobLocation } from "@shared/search-cities";
import type { AppSettings, JobListItem, JobSource } from "@shared/types"; import type { AppSettings, JobListItem, JobSource } from "@shared/types";
import type { FilterTab, JobSort } from "./constants"; import type { FilterTab, JobSort } from "./constants";
import { import {
@ -165,6 +167,22 @@ export const getSourcesWithJobs = (jobs: JobListItem[]): JobSource[] => {
return orderedFilterSources.filter((source) => seen.has(source)); return orderedFilterSources.filter((source) => seen.has(source));
}; };
export const getCountriesWithJobs = (jobs: JobListItem[]): string[] => {
const seen = new Set<string>();
for (const job of jobs) {
for (const key of inferCountryKeysFromJobLocation(job.location)) {
seen.add(key);
}
}
return [...seen].sort((left, right) =>
formatCountryLabel(left).localeCompare(
formatCountryLabel(right),
undefined,
{ sensitivity: "base" },
),
);
};
export const getEnabledSources = ( export const getEnabledSources = (
settings: AppSettings | null, settings: AppSettings | null,
): JobSource[] => { ): JobSource[] => {

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
inferCountryKeyFromSearchGeography, inferCountryKeyFromSearchGeography,
inferCountryKeysFromJobLocation,
matchesRequestedCity, matchesRequestedCity,
parseSearchCitiesSetting, parseSearchCitiesSetting,
resolveSearchCities, resolveSearchCities,
@ -77,6 +78,20 @@ describe("search-cities", () => {
expect(inferCountryKeyFromSearchGeography(null, null)).toBeNull(); expect(inferCountryKeyFromSearchGeography(null, null)).toBeNull();
}); });
it("infers country keys from job location strings", () => {
expect(inferCountryKeysFromJobLocation("London, UK")).toEqual([
"united kingdom",
]);
expect(inferCountryKeysFromJobLocation("Toronto, Canada")).toEqual([
"canada",
]);
expect(inferCountryKeysFromJobLocation("United Kingdom")).toEqual([
"united kingdom",
]);
expect(inferCountryKeysFromJobLocation(null)).toEqual([]);
expect(inferCountryKeysFromJobLocation("Remote")).toEqual([]);
});
it("applies strict filter only when city differs from country", () => { it("applies strict filter only when city differs from country", () => {
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true); expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false); expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);

View File

@ -36,6 +36,26 @@ export function inferCountryKeyFromSearchGeography(
return null; return null;
} }
/**
* Parses a job listing location string and returns normalized country keys
* (e.g. "London, UK" ["united kingdom"]). Empty when no supported country tokens.
*/
export function inferCountryKeysFromJobLocation(
location: string | null | undefined,
): string[] {
if (!location?.trim()) return [];
const keys = new Set<string>();
for (const segment of location.split(/[,;|]/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
const key = normalizeCountryKey(trimmed);
if (supportedCountryKeySet.has(key)) keys.add(key);
}
const whole = normalizeCountryKey(location);
if (supportedCountryKeySet.has(whole)) keys.add(whole);
return [...keys];
}
export function parseSearchCitiesSetting( export function parseSearchCitiesSetting(
value: string | null | undefined, value: string | null | undefined,
): string[] { ): string[] {