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
|
## 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 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
|
```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 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
|
## 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).
|
||||||
|
|||||||
@ -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=");
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 }));
|
||||||
|
|||||||
@ -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'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>
|
||||||
|
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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[] => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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[] {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user