diff --git a/README.md b/README.md index e646c98..6da438e 100644 --- a/README.md +++ b/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. +![Product screenshot](https://github.com/user-attachments/assets/14fdc392-0e96-43be-bc1f-cf819ab2afc4) -Product screenshot +## 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 -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). diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index ca7aeeb..e68ae00 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -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 - - {visibleSources.map((source) => ( + {visibleSources.map((source) => { + const excluded = sourcesExcludeFilter.includes(source); + const included = sourcesFilter.includes(source); + return ( + + ); + })} + + + + + + Country + + +

+ Parsed from each job's location. Jobs marked remote + in the listing always match include and exclude country + rules. +

+
- ))} + {countriesWithJobs.map((countryKey) => { + const excluded = + countriesExcludeFilter.includes(countryKey); + const included = countriesFilter.includes(countryKey); + return ( + + ); + })} +
diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts index 319bbed..68f0e7b 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts @@ -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"]); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts index 1d46539..f5c373f 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.ts @@ -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, diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx index d2720af..bddd9f2 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.test.tsx @@ -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"); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts index 66b5a23..7eb45f7 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorFilters.ts @@ -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(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, diff --git a/orchestrator/src/client/pages/orchestrator/utils.ts b/orchestrator/src/client/pages/orchestrator/utils.ts index 10428aa..816c472 100644 --- a/orchestrator/src/client/pages/orchestrator/utils.ts +++ b/orchestrator/src/client/pages/orchestrator/utils.ts @@ -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(); + 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[] => { diff --git a/shared/src/search-cities.test.ts b/shared/src/search-cities.test.ts index f75d630..a3512cc 100644 --- a/shared/src/search-cities.test.ts +++ b/shared/src/search-cities.test.ts @@ -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); diff --git a/shared/src/search-cities.ts b/shared/src/search-cities.ts index c41428a..7940921 100644 --- a/shared/src/search-cities.ts +++ b/shared/src/search-cities.ts @@ -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(); + 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[] {