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:
parent
77179b2b94
commit
4d7c8ac0bc
106
README.md
106
README.md
@ -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.
|
||||

|
||||
|
||||
<img width="1200" height="600" alt="Product screenshot" src="https://github.com/user-attachments/assets/14fdc392-0e96-43be-bc1f-cf819ab2afc4" />
|
||||
## What’s 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
|
||||
npm install
|
||||
npm run docs:dev
|
||||
```
|
||||
## Features (high level)
|
||||
|
||||
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 listing’s 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
|
||||
git clone <your-repo-url>
|
||||
cd Jobber # or whatever you named the directory
|
||||
cd Jobber
|
||||
|
||||
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).
|
||||
- **Scoring**: LLM ranking vs your profile (OpenAI, OpenRouter, OpenAI-compatible, Gemini, etc.).
|
||||
- **Resumes**: Tailored PDFs via [RxResume v4](https://v4.rxresu.me).
|
||||
- **Email**: Gmail integration for interview / offer / rejection signals.
|
||||
- **Data**: SQLite under `./data` when using the default compose setup.
|
||||
## Local development
|
||||
|
||||
From the repository root:
|
||||
|
||||
```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 Resume–shaped 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
|
||||
|
||||
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
|
||||
|
||||
**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).
|
||||
|
||||
@ -204,7 +204,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
OrchestratorFilters: ({
|
||||
onTabChange,
|
||||
onOpenCommandBar,
|
||||
onSourceFilterChange,
|
||||
onSourceSelectionChange,
|
||||
onSponsorFilterChange,
|
||||
onSalaryFilterChange,
|
||||
onResetFilters,
|
||||
@ -214,7 +214,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
}: {
|
||||
onTabChange: (t: FilterTab) => void;
|
||||
onOpenCommandBar: () => void;
|
||||
onSourceFilterChange: (source: string) => void;
|
||||
onSourceSelectionChange: (include: string[], exclude: string[]) => void;
|
||||
onSponsorFilterChange: (value: string) => void;
|
||||
onSalaryFilterChange: (value: {
|
||||
mode: "at_least" | "at_most" | "between";
|
||||
@ -241,7 +241,10 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
>
|
||||
Set Sort
|
||||
</button>
|
||||
<button type="button" onClick={() => onSourceFilterChange("linkedin")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSourceSelectionChange(["linkedin"], [])}
|
||||
>
|
||||
Set Source
|
||||
</button>
|
||||
<button type="button" onClick={() => onSponsorFilterChange("confirmed")}>
|
||||
@ -663,6 +666,9 @@ describe("OrchestratorPage", () => {
|
||||
fireEvent.click(screen.getByText("Reset Filters"));
|
||||
const locationText = screen.getByTestId("location").textContent || "";
|
||||
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("salaryMode=");
|
||||
expect(locationText).not.toContain("salaryMin=");
|
||||
|
||||
@ -25,6 +25,7 @@ import { usePipelineControls } from "./orchestrator/usePipelineControls";
|
||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
|
||||
import {
|
||||
getCountriesWithJobs,
|
||||
getEnabledSources,
|
||||
getJobCounts,
|
||||
getSourcesWithJobs,
|
||||
@ -35,8 +36,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
searchParams,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
setSourceSelection,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
setCountrySelection,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
workplaceFilter,
|
||||
@ -144,7 +149,10 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
@ -181,6 +189,31 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
const counts = useMemo(() => getJobCounts(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 {
|
||||
selectedJobIds,
|
||||
canSkipSelected,
|
||||
@ -199,11 +232,27 @@ export const OrchestratorPage: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || sourceFilter === "all") return;
|
||||
if (!sourcesWithJobs.includes(sourceFilter)) {
|
||||
setSourceFilter("all");
|
||||
}
|
||||
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
|
||||
if (isLoading) return;
|
||||
if (sourcesFilter.length === 0 && sourcesExcludeFilter.length === 0) return;
|
||||
const validI = sourcesFilter.filter((source) =>
|
||||
sourcesWithJobs.includes(source),
|
||||
);
|
||||
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) => {
|
||||
handleSelectJobId(id);
|
||||
@ -268,6 +317,9 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
for (const key of [
|
||||
"source",
|
||||
"sourceExclude",
|
||||
"countries",
|
||||
"countriesExclude",
|
||||
"sponsor",
|
||||
"salaryMode",
|
||||
"salaryMin",
|
||||
@ -386,8 +438,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onOpenCommandBar={() => setIsCommandBarOpen(true)}
|
||||
isFiltersOpen={isFiltersOpen}
|
||||
onFiltersOpenChange={setIsFiltersOpen}
|
||||
sourceFilter={sourceFilter}
|
||||
onSourceFilterChange={setSourceFilter}
|
||||
sourcesFilter={sourcesFilter}
|
||||
sourcesExcludeFilter={sourcesExcludeFilter}
|
||||
onSourceSelectionChange={setSourceSelection}
|
||||
countriesFilter={countriesFilter}
|
||||
countriesExcludeFilter={countriesExcludeFilter}
|
||||
onCountrySelectionChange={setCountrySelection}
|
||||
sponsorFilter={sponsorFilter}
|
||||
onSponsorFilterChange={setSponsorFilter}
|
||||
workplaceFilter={workplaceFilter}
|
||||
@ -395,6 +451,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
salaryFilter={salaryFilter}
|
||||
onSalaryFilterChange={setSalaryFilter}
|
||||
sourcesWithJobs={sourcesWithJobs}
|
||||
countriesWithJobs={countriesWithJobs}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onResetFilters={resetFilters}
|
||||
|
||||
@ -10,6 +10,8 @@ import type {
|
||||
} from "./constants";
|
||||
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||
|
||||
type FiltersProps = ComponentProps<typeof OrchestratorFilters>;
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeAll(() => {
|
||||
@ -26,10 +28,8 @@ afterAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderFilters = (
|
||||
overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>,
|
||||
) => {
|
||||
const props = {
|
||||
const renderFilters = (overrides?: Partial<FiltersProps>) => {
|
||||
const props: FiltersProps = {
|
||||
activeTab: "ready" as FilterTab,
|
||||
onTabChange: vi.fn(),
|
||||
counts: {
|
||||
@ -39,8 +39,12 @@ const renderFilters = (
|
||||
all: 6,
|
||||
},
|
||||
onOpenCommandBar: vi.fn(),
|
||||
sourceFilter: "all" as const,
|
||||
onSourceFilterChange: vi.fn(),
|
||||
sourcesFilter: [] as JobSource[],
|
||||
sourcesExcludeFilter: [] as JobSource[],
|
||||
onSourceSelectionChange: vi.fn(),
|
||||
countriesFilter: [] as string[],
|
||||
countriesExcludeFilter: [] as string[],
|
||||
onCountrySelectionChange: vi.fn(),
|
||||
sponsorFilter: "all" as SponsorFilter,
|
||||
onSponsorFilterChange: vi.fn(),
|
||||
workplaceFilter: "all" as WorkplaceFilter,
|
||||
@ -52,6 +56,7 @@ const renderFilters = (
|
||||
},
|
||||
onSalaryFilterChange: vi.fn(),
|
||||
sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[],
|
||||
countriesWithJobs: ["united kingdom"] as string[],
|
||||
sort: { key: "score", direction: "desc" } as JobSort,
|
||||
onSortChange: vi.fn(),
|
||||
onResetFilters: vi.fn(),
|
||||
@ -77,12 +82,23 @@ describe("OrchestratorFilters", () => {
|
||||
});
|
||||
|
||||
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(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" }));
|
||||
expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential");
|
||||
@ -134,6 +150,7 @@ describe("OrchestratorFilters", () => {
|
||||
it("resets filters and only shows sources present in jobs", async () => {
|
||||
const { props } = renderFilters({
|
||||
sourcesWithJobs: ["gradcracker", "manual"],
|
||||
countriesWithJobs: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { KbdHint } from "@client/components/KbdHint";
|
||||
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
|
||||
import { formatCountryLabel } from "@shared/location-support";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type React from "react";
|
||||
@ -41,8 +42,12 @@ interface OrchestratorFiltersProps {
|
||||
onTabChange: (value: FilterTab) => void;
|
||||
counts: Record<FilterTab, number>;
|
||||
onOpenCommandBar: () => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sourcesFilter: JobSource[];
|
||||
sourcesExcludeFilter: JobSource[];
|
||||
onSourceSelectionChange: (include: JobSource[], exclude: JobSource[]) => void;
|
||||
countriesFilter: string[];
|
||||
countriesExcludeFilter: string[];
|
||||
onCountrySelectionChange: (include: string[], exclude: string[]) => void;
|
||||
sponsorFilter: SponsorFilter;
|
||||
onSponsorFilterChange: (value: SponsorFilter) => void;
|
||||
workplaceFilter: WorkplaceFilter;
|
||||
@ -50,6 +55,7 @@ interface OrchestratorFiltersProps {
|
||||
salaryFilter: SalaryFilter;
|
||||
onSalaryFilterChange: (value: SalaryFilter) => void;
|
||||
sourcesWithJobs: JobSource[];
|
||||
countriesWithJobs: string[];
|
||||
sort: JobSort;
|
||||
onSortChange: (sort: JobSort) => void;
|
||||
onResetFilters: () => void;
|
||||
@ -130,8 +136,12 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
onTabChange,
|
||||
counts,
|
||||
onOpenCommandBar,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
onSourceSelectionChange,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
onCountrySelectionChange,
|
||||
sponsorFilter,
|
||||
onSponsorFilterChange,
|
||||
workplaceFilter,
|
||||
@ -139,6 +149,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
salaryFilter,
|
||||
onSalaryFilterChange,
|
||||
sourcesWithJobs,
|
||||
countriesWithJobs,
|
||||
sort,
|
||||
onSortChange,
|
||||
onResetFilters,
|
||||
@ -154,9 +165,42 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
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(
|
||||
() =>
|
||||
Number(sourceFilter !== "all") +
|
||||
Number(sourcesFilter.length > 0 || sourcesExcludeFilter.length > 0) +
|
||||
Number(countriesFilter.length > 0 || countriesExcludeFilter.length > 0) +
|
||||
Number(sponsorFilter !== "all") +
|
||||
Number(workplaceFilter !== "all") +
|
||||
Number(
|
||||
@ -164,7 +208,10 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
(typeof salaryFilter.max === "number" && salaryFilter.max > 0),
|
||||
),
|
||||
[
|
||||
sourceFilter,
|
||||
sourcesFilter.length,
|
||||
sourcesExcludeFilter.length,
|
||||
countriesFilter.length,
|
||||
countriesExcludeFilter.length,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter.min,
|
||||
@ -246,8 +293,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Refine sources, sponsor status, workplace (remote), salary,
|
||||
and sorting.
|
||||
Refine sources and country: each button cycles off → include
|
||||
→ exclude (red). Remote listings always bypass country
|
||||
filters. Sponsor, workplace, salary, and sort below.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@ -262,24 +310,87 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sourceFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => onSourceFilterChange("all")}
|
||||
variant={
|
||||
sourcesFilter.length === 0 &&
|
||||
sourcesExcludeFilter.length === 0
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => onSourceSelectionChange([], [])}
|
||||
>
|
||||
All sources
|
||||
</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's location. Jobs marked remote
|
||||
in the listing always match include and exclude country
|
||||
rules.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
sourceFilter === source ? "default" : "outline"
|
||||
countriesFilter.length === 0 &&
|
||||
countriesExcludeFilter.length === 0
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => onSourceFilterChange(source)}
|
||||
onClick={() => onCountrySelectionChange([], [])}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
All countries
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -31,7 +31,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -59,7 +62,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"ready",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -88,7 +94,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"confirmed",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -114,7 +123,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "between", min: 60000, max: 80000 },
|
||||
@ -143,7 +155,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -173,7 +188,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"remote",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -186,7 +204,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"not_remote",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -199,7 +220,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"unknown",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@ -208,4 +232,134 @@ describe("useFilteredJobs", () => {
|
||||
);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { inferCountryKeysFromJobLocation } from "@shared/search-cities";
|
||||
import type { JobListItem, JobSource } from "@shared/types";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
@ -19,7 +20,10 @@ const getSponsorCategory = (score: number | null): SponsorFilter => {
|
||||
export const useFilteredJobs = (
|
||||
jobs: JobListItem[],
|
||||
activeTab: FilterTab,
|
||||
sourceFilter: JobSource | "all",
|
||||
sourcesFilter: JobSource[],
|
||||
sourcesExcludeFilter: JobSource[],
|
||||
countriesFilter: string[],
|
||||
countriesExcludeFilter: string[],
|
||||
sponsorFilter: SponsorFilter,
|
||||
workplaceFilter: WorkplaceFilter,
|
||||
salaryFilter: SalaryFilter,
|
||||
@ -46,8 +50,32 @@ export const useFilteredJobs = (
|
||||
filtered = filtered.filter((job) => job.closedAt == null);
|
||||
}
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
if (sourcesFilter.length > 0) {
|
||||
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") {
|
||||
@ -106,7 +134,10 @@ export const useFilteredJobs = (
|
||||
}, [
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@ -39,4 +39,54 @@ describe("useOrchestratorFilters", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
@ -38,6 +43,9 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
|
||||
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||
|
||||
export const useOrchestratorFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@ -52,14 +60,109 @@ export const useOrchestratorFilters = () => {
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const sourceFilter =
|
||||
(searchParams.get("source") as JobSource | "all") || "all";
|
||||
const setSourceFilter = useCallback(
|
||||
(source: JobSource | "all") => {
|
||||
const { sourcesFilter, sourcesExcludeFilter } = useMemo(() => {
|
||||
const rawInc = searchParams.getAll("source");
|
||||
const include = rawInc
|
||||
.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(
|
||||
(prev) => {
|
||||
if (source !== "all") prev.set("source", source);
|
||||
else prev.delete("source");
|
||||
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;
|
||||
},
|
||||
{ replace: true },
|
||||
@ -192,6 +295,9 @@ export const useOrchestratorFilters = () => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
prev.delete("source");
|
||||
prev.delete("sourceExclude");
|
||||
prev.delete("countries");
|
||||
prev.delete("countriesExclude");
|
||||
prev.delete("sponsor");
|
||||
prev.delete("workplace");
|
||||
prev.delete("salaryMode");
|
||||
@ -207,8 +313,12 @@ export const useOrchestratorFilters = () => {
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
setSourceSelection,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
setCountrySelection,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
workplaceFilter,
|
||||
|
||||
@ -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 { FilterTab, JobSort } from "./constants";
|
||||
import {
|
||||
@ -165,6 +167,22 @@ export const getSourcesWithJobs = (jobs: JobListItem[]): JobSource[] => {
|
||||
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 = (
|
||||
settings: AppSettings | null,
|
||||
): JobSource[] => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferCountryKeyFromSearchGeography,
|
||||
inferCountryKeysFromJobLocation,
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
resolveSearchCities,
|
||||
@ -77,6 +78,20 @@ describe("search-cities", () => {
|
||||
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", () => {
|
||||
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
|
||||
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
|
||||
|
||||
@ -36,6 +36,26 @@ export function inferCountryKeyFromSearchGeography(
|
||||
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(
|
||||
value: string | null | undefined,
|
||||
): string[] {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user