Hotfix location in pipeline search (#108)

* feat(shared): centralize supported country list and source-country rules

* feat(orchestrator): add country selector and UK-only source gating in automatic run modal

* feat(orchestrator): persist country selection and run only compatible extractors

* fix(pipeline): enforce country-source compatibility during discovery

* test(orchestrator): cover country-based source gating and pipeline enforcement

* formatting

* test fix

* lint

* comments

* prevent auto focus grab

* verification

* command and popover

* make sure scroll is working
This commit is contained in:
Shaheer Sarfaraz 2026-02-08 13:02:52 +00:00 committed by GitHub
parent 9b80c2e05d
commit bd6834f99e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2156 additions and 974 deletions

View File

@ -56,3 +56,32 @@ Use consistent status/code mapping:
- Request/correlation IDs appear in logs and async workflows. - Request/correlation IDs appear in logs and async workflows.
- No raw sensitive payload logging or raw upstream body throws. - No raw sensitive payload logging or raw upstream body throws.
- New/changed webhook or LLM payloads are sanitized and documented. - New/changed webhook or LLM payloads are sanitized and documented.
## Validation / Verification
Before marking work complete, verify changes with the same checks used by CI.
### Required CI-parity checks
Run from repository root:
1. `./orchestrator/node_modules/.bin/biome ci .`
2. `npm run check:types:shared`
3. `npm --workspace orchestrator run check:types`
4. `npm --workspace gradcracker-extractor run check:types`
5. `npm --workspace ukvisajobs-extractor run check:types`
6. `npm --workspace orchestrator run build:client`
7. `npm --workspace orchestrator run test:run`
### Native module note (better-sqlite3)
If tests fail with a Node ABI mismatch for `better-sqlite3`, rebuild it before running tests:
- `npm --workspace orchestrator rebuild better-sqlite3`
CI runs on Node 22. If local behavior differs, verify with Node 22 before concluding a change is valid.
### Scope-specific checks
- For focused changes, run targeted tests first (for touched files/modules), then still run the full CI-parity list above before finalizing.
- A change is considered valid only when all required checks pass without ignored failures.

View File

@ -30,11 +30,12 @@
"@paralleldrive/cuid2": "^3.0.6", "@paralleldrive/cuid2": "^3.0.6",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "1.3.2",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
@ -48,23 +49,24 @@
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.38.2", "drizzle-orm": "^0.38.2",
"express": "^4.18.2",
"get-tsconfig": "^4.10.0", "get-tsconfig": "^4.10.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"tsx": "^4.19.2",
"express": "^4.18.2",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^2.12.5",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"recharts": "^2.12.5",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -78,8 +80,8 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "18.3.1",
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
@ -96,11 +98,11 @@
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-linux-x64-gnu": "^1.29.3",
"lightningcss-linux-arm64-gnu": "^1.29.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@rollup/rollup-linux-arm64-gnu": "^4.30.0", "@rollup/rollup-linux-arm64-gnu": "^4.30.0",
"@rollup/rollup-linux-x64-gnu": "^4.30.0" "@rollup/rollup-linux-x64-gnu": "^4.30.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"lightningcss-linux-arm64-gnu": "^1.29.3",
"lightningcss-linux-x64-gnu": "^1.29.3"
} }
} }

View File

@ -22,6 +22,16 @@ vi.mock("../api", () => ({
})); }));
let mockIsPipelineRunning = false; let mockIsPipelineRunning = false;
let mockPipelineSources = ["linkedin"] as Array<
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
>;
let mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
};
const jobFixture: Job = { const jobFixture: Job = {
id: "job-1", id: "job-1",
@ -119,7 +129,7 @@ vi.mock("./orchestrator/useOrchestratorData", () => ({
vi.mock("./orchestrator/usePipelineSources", () => ({ vi.mock("./orchestrator/usePipelineSources", () => ({
usePipelineSources: () => ({ usePipelineSources: () => ({
pipelineSources: ["linkedin"], pipelineSources: mockPipelineSources,
setPipelineSources: vi.fn(), setPipelineSources: vi.fn(),
toggleSource: vi.fn(), toggleSource: vi.fn(),
}), }),
@ -300,19 +310,13 @@ vi.mock("./orchestrator/RunModeModal", () => ({
minSuitabilityScore: number; minSuitabilityScore: number;
searchTerms: string[]; searchTerms: string[];
runBudget: number; runBudget: number;
country: string;
}) => Promise<void>; }) => Promise<void>;
}) => ( }) => (
<button <button
type="button" type="button"
data-testid="run-automatic" data-testid="run-automatic"
onClick={() => onClick={() => void onSaveAndRunAutomatic(mockAutomaticRunValues)}
void onSaveAndRunAutomatic({
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
})
}
> >
Run automatic Run automatic
</button> </button>
@ -334,6 +338,14 @@ describe("OrchestratorPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockIsPipelineRunning = false; mockIsPipelineRunning = false;
mockPipelineSources = ["linkedin"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united kingdom",
};
}); });
it("syncs tab selection to the URL", () => { it("syncs tab selection to the URL", () => {
@ -583,12 +595,49 @@ describe("OrchestratorPage", () => {
jobspyResultsWanted: 150, jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150, gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150, ukvisajobsMaxJobs: 150,
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
}); });
}); });
expect(api.runPipeline).toHaveBeenCalledWith({
topN: 12,
minSuitabilityScore: 55,
sources: ["linkedin"],
});
setIntervalSpy.mockRestore(); setIntervalSpy.mockRestore();
}); });
it("blocks automatic run when no sources are compatible for selected country", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
mockPipelineSources = ["gradcracker", "ukvisajobs"];
mockAutomaticRunValues = {
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
country: "united states",
};
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).not.toHaveBeenCalled();
expect(api.runPipeline).not.toHaveBeenCalled();
});
});
it("shows and hides bulk Recalculate match based on selected statuses", async () => { it("shows and hides bulk Recalculate match based on selected statuses", async () => {
window.matchMedia = createMatchMedia( window.matchMedia = createMatchMedia(
true, true,

View File

@ -3,6 +3,10 @@
*/ */
import { useSettings } from "@client/hooks/useSettings"; import { useSettings } from "@client/hooks/useSettings";
import {
formatCountryLabel,
getCompatibleSourcesForCountry,
} from "@shared/location-support.js";
import type { JobSource } from "@shared/types.js"; import type { JobSource } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@ -121,7 +125,8 @@ export const OrchestratorPage: React.FC = () => {
() => getEnabledSources(settings ?? null), () => getEnabledSources(settings ?? null),
[settings], [settings],
); );
const { pipelineSources, toggleSource } = usePipelineSources(enabledSources); const { pipelineSources, setPipelineSources, toggleSource } =
usePipelineSources(enabledSources);
const activeJobs = useFilteredJobs( const activeJobs = useFilteredJobs(
jobs, jobs,
@ -240,22 +245,35 @@ export const OrchestratorPage: React.FC = () => {
const handleSaveAndRunAutomatic = useCallback( const handleSaveAndRunAutomatic = useCallback(
async (values: AutomaticRunValues) => { async (values: AutomaticRunValues) => {
const compatibleSources = getCompatibleSourcesForCountry(
pipelineSources,
values.country,
);
if (compatibleSources.length === 0) {
toast.error(
"No compatible sources for the selected country. Choose another country or source.",
);
return;
}
const limits = deriveExtractorLimits({ const limits = deriveExtractorLimits({
budget: values.runBudget, budget: values.runBudget,
searchTerms: values.searchTerms, searchTerms: values.searchTerms,
sources: pipelineSources, sources: compatibleSources,
}); });
await api.updateSettings({ await api.updateSettings({
searchTerms: values.searchTerms, searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted, jobspyResultsWanted: limits.jobspyResultsWanted,
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm, gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs, ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
jobspyCountryIndeed: values.country,
jobspyLocation: formatCountryLabel(values.country),
}); });
await refreshSettings(); await refreshSettings();
await startPipelineRun({ await startPipelineRun({
topN: values.topN, topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore, minSuitabilityScore: values.minSuitabilityScore,
sources: pipelineSources, sources: compatibleSources,
}); });
setIsRunModeModalOpen(false); setIsRunModeModalOpen(false);
}, },
@ -419,6 +437,7 @@ export const OrchestratorPage: React.FC = () => {
enabledSources={enabledSources} enabledSources={enabledSources}
pipelineSources={pipelineSources} pipelineSources={pipelineSources}
onToggleSource={toggleSource} onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
isPipelineRunning={isPipelineRunning} isPipelineRunning={isPipelineRunning}
onOpenChange={setIsRunModeModalOpen} onOpenChange={setIsRunModeModalOpen}
onModeChange={setRunMode} onModeChange={setRunMode}

View File

@ -0,0 +1,99 @@
import type { AppSettings } from "@shared/types";
import { render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { AutomaticRunTab } from "./AutomaticRunTab";
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
describe("AutomaticRunTab", () => {
it("loads persisted country from settings", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us",
} as AppSettings
}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole("combobox", { name: "United States" }),
).toBeInTheDocument();
});
it("disables and prunes UK-only sources for non-UK country", async () => {
const onSetPipelineSources = vi.fn();
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
} as AppSettings
}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
onToggleSource={vi.fn()}
onSetPipelineSources={onSetPipelineSources}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
await waitFor(() => {
expect(onSetPipelineSources).toHaveBeenCalledWith(["linkedin"]);
});
expect(screen.getByRole("button", { name: "Gradcracker" })).toBeDisabled();
expect(screen.getByRole("button", { name: "UK Visa Jobs" })).toBeDisabled();
});
it("shows disabled source guidance copy for UK-only source", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
} as AppSettings
}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByTitle(
"Gradcracker is available only when country is United Kingdom.",
),
).toBeInTheDocument();
});
});

View File

@ -1,5 +1,13 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
formatCountryLabel,
getCompatibleSourcesForCountry,
isSourceAllowedForCountry,
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { AppSettings, JobSource } from "@shared/types"; import type { AppSettings, JobSource } from "@shared/types";
import { Loader2, Sparkles, X } from "lucide-react"; import { Check, ChevronsUpDown, Loader2, Sparkles, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { import {
@ -10,10 +18,25 @@ import {
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { sourceLabel } from "@/lib/utils"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, sourceLabel } from "@/lib/utils";
import { import {
AUTOMATIC_PRESETS, AUTOMATIC_PRESETS,
type AutomaticPresetId, type AutomaticPresetId,
@ -30,6 +53,7 @@ interface AutomaticRunTabProps {
enabledSources: JobSource[]; enabledSources: JobSource[];
pipelineSources: JobSource[]; pipelineSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void; onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void;
isPipelineRunning: boolean; isPipelineRunning: boolean;
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>; onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
} }
@ -39,12 +63,14 @@ const DEFAULT_VALUES: AutomaticRunValues = {
minSuitabilityScore: 50, minSuitabilityScore: 50,
searchTerms: ["web developer"], searchTerms: ["web developer"],
runBudget: 200, runBudget: 200,
country: "united kingdom",
}; };
interface AutomaticRunFormValues { interface AutomaticRunFormValues {
topN: string; topN: string;
minSuitabilityScore: string; minSuitabilityScore: string;
runBudget: string; runBudget: string;
country: string;
searchTerms: string[]; searchTerms: string[];
searchTermDraft: string; searchTermDraft: string;
} }
@ -94,17 +120,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
enabledSources, enabledSources,
pipelineSources, pipelineSources,
onToggleSource, onToggleSource,
onSetPipelineSources,
isPipelineRunning, isPipelineRunning,
onSaveAndRun, onSaveAndRun,
}) => { }) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [countryMenuOpen, setCountryMenuOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>( const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
{ {
defaultValues: { defaultValues: {
topN: String(DEFAULT_VALUES.topN), topN: String(DEFAULT_VALUES.topN),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore), minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
runBudget: String(DEFAULT_VALUES.runBudget), runBudget: String(DEFAULT_VALUES.runBudget),
country: DEFAULT_VALUES.country,
searchTerms: DEFAULT_VALUES.searchTerms, searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "", searchTermDraft: "",
}, },
@ -114,6 +143,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const topNInput = watch("topN"); const topNInput = watch("topN");
const minScoreInput = watch("minSuitabilityScore"); const minScoreInput = watch("minSuitabilityScore");
const runBudgetInput = watch("runBudget"); const runBudgetInput = watch("runBudget");
const countryInput = watch("country");
const searchTerms = watch("searchTerms"); const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft"); const searchTermDraft = watch("searchTermDraft");
@ -129,14 +159,22 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
settings?.gradcrackerMaxJobsPerTerm ?? settings?.gradcrackerMaxJobsPerTerm ??
settings?.ukvisajobsMaxJobs ?? settings?.ukvisajobsMaxJobs ??
DEFAULT_VALUES.runBudget; DEFAULT_VALUES.runBudget;
const rememberedCountry = normalizeCountryKey(
settings?.jobspyCountryIndeed ??
settings?.jobspyLocation ??
DEFAULT_VALUES.country,
);
reset({ reset({
topN: String(topN), topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore), minSuitabilityScore: String(minSuitabilityScore),
runBudget: String(rememberedRunBudget), runBudget: String(rememberedRunBudget),
country: rememberedCountry || DEFAULT_VALUES.country,
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms, searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "", searchTermDraft: "",
}); });
setAdvancedOpen(false); setAdvancedOpen(false);
setCountryMenuOpen(false);
}, [open, settings, reset]); }, [open, settings, reset]);
const addSearchTerms = (input: string) => { const addSearchTerms = (input: string) => {
@ -151,6 +189,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
}; };
const values = useMemo<AutomaticRunValues>(() => { const values = useMemo<AutomaticRunValues>(() => {
const normalizedCountry = normalizeCountryKey(countryInput);
return { return {
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN), topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
minSuitabilityScore: toNumber( minSuitabilityScore: toNumber(
@ -159,15 +198,54 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
100, 100,
DEFAULT_VALUES.minSuitabilityScore, DEFAULT_VALUES.minSuitabilityScore,
), ),
searchTerms,
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget), runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
country: normalizedCountry || DEFAULT_VALUES.country,
searchTerms,
}; };
}, [topNInput, minScoreInput, searchTerms, runBudgetInput]); }, [topNInput, minScoreInput, runBudgetInput, countryInput, searchTerms]);
const compatibleEnabledSources = useMemo(
() =>
enabledSources.filter((source) =>
isSourceAllowedForCountry(source, values.country),
),
[enabledSources, values.country],
);
const compatiblePipelineSources = useMemo(
() => getCompatibleSourcesForCountry(pipelineSources, values.country),
[pipelineSources, values.country],
);
useEffect(() => {
const filtered = getCompatibleSourcesForCountry(
pipelineSources,
values.country,
);
if (filtered.length === pipelineSources.length) return;
if (filtered.length > 0) {
onSetPipelineSources(filtered);
return;
}
if (compatibleEnabledSources.length > 0) {
onSetPipelineSources([compatibleEnabledSources[0]]);
}
}, [
compatibleEnabledSources,
onSetPipelineSources,
pipelineSources,
values.country,
]);
const estimate = useMemo( const estimate = useMemo(
() => calculateAutomaticEstimate({ values, sources: pipelineSources }), () =>
[values, pipelineSources], calculateAutomaticEstimate({
values,
sources: compatiblePipelineSources,
}),
[values, compatiblePipelineSources],
); );
const activePreset = useMemo<AutomaticPresetSelection>( const activePreset = useMemo<AutomaticPresetSelection>(
() => getPresetSelection(values), () => getPresetSelection(values),
[values], [values],
@ -176,7 +254,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
const runDisabled = const runDisabled =
isPipelineRunning || isPipelineRunning ||
isSaving || isSaving ||
pipelineSources.length === 0 || compatiblePipelineSources.length === 0 ||
values.searchTerms.length === 0; values.searchTerms.length === 0;
const applyPreset = (presetId: AutomaticPresetId) => { const applyPreset = (presetId: AutomaticPresetId) => {
@ -201,6 +279,8 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
} }
}; };
const countryOptions = SUPPORTED_COUNTRY_KEYS;
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 space-y-4 overflow-y-auto pr-1"> <div className="min-h-0 space-y-4 overflow-y-auto pr-1">
@ -242,6 +322,73 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
<Label className="text-base font-semibold">Country</Label>
<Popover open={countryMenuOpen} onOpenChange={setCountryMenuOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={countryMenuOpen}
aria-label={formatCountryLabel(values.country)}
className="h-9 w-full justify-between md:max-w-xs"
>
{formatCountryLabel(values.country)}
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverPrimitive.Content
align="start"
sideOffset={4}
className={cn(
"z-50 w-[320px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"origin-[--radix-popover-content-transform-origin]",
)}
>
<Command loop>
<CommandInput placeholder="Search country..." />
<CommandList
className="max-h-56"
onWheelCapture={(event) => event.stopPropagation()}
>
<CommandEmpty>No matching countries.</CommandEmpty>
<CommandGroup>
{countryOptions.map((country) => {
const selected = values.country === country;
const label = formatCountryLabel(country);
return (
<CommandItem
key={country}
value={`${country} ${label}`}
onSelect={() => {
setValue("country", country, {
shouldDirty: true,
});
setCountryMenuOpen(false);
}}
>
{label}
<Check
className={cn(
"ml-auto h-4 w-4",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverPrimitive.Content>
</Popover>
</div>
<Separator /> <Separator />
<Accordion <Accordion
type="single" type="single"
@ -372,25 +519,49 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle> <CardTitle>
Sources ({pipelineSources.length}/{enabledSources.length}) Sources ({compatiblePipelineSources.length}/
{compatibleEnabledSources.length})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap gap-2"> <CardContent className="flex flex-wrap gap-2">
{enabledSources.map((source) => ( <TooltipProvider>
{enabledSources.map((source) => {
const allowed = isSourceAllowedForCountry(
source,
values.country,
);
const selected = compatiblePipelineSources.includes(source);
const disabledReason = `${sourceLabel[source]} is available only when country is United Kingdom.`;
const button = (
<Button <Button
key={source} key={source}
type="button" type="button"
size="sm" size="sm"
variant={ variant={selected ? "default" : "outline"}
pipelineSources.includes(source) ? "default" : "outline" disabled={!allowed}
} onClick={() => onToggleSource(source, !selected)}
onClick={() =>
onToggleSource(source, !pipelineSources.includes(source))
}
> >
{sourceLabel[source]} {sourceLabel[source]}
</Button> </Button>
))} );
if (allowed) {
return button;
}
return (
<Tooltip key={source}>
<TooltipTrigger asChild>
<span className="inline-flex" title={disabledReason}>
{button}
</span>
</TooltipTrigger>
<TooltipContent side="top">{disabledReason}</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -22,6 +22,7 @@ describe("RunModeModal", () => {
enabledSources={["linkedin"]} enabledSources={["linkedin"]}
pipelineSources={["linkedin"]} pipelineSources={["linkedin"]}
onToggleSource={vi.fn()} onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false} isPipelineRunning={false}
onOpenChange={vi.fn()} onOpenChange={vi.fn()}
onModeChange={vi.fn()} onModeChange={vi.fn()}

View File

@ -21,6 +21,7 @@ interface RunModeModalProps {
enabledSources: JobSource[]; enabledSources: JobSource[];
pipelineSources: JobSource[]; pipelineSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void; onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void;
isPipelineRunning: boolean; isPipelineRunning: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onModeChange: (mode: RunMode) => void; onModeChange: (mode: RunMode) => void;
@ -35,6 +36,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
enabledSources, enabledSources,
pipelineSources, pipelineSources,
onToggleSource, onToggleSource,
onSetPipelineSources,
isPipelineRunning, isPipelineRunning,
onOpenChange, onOpenChange,
onModeChange, onModeChange,
@ -73,6 +75,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
enabledSources={enabledSources} enabledSources={enabledSources}
pipelineSources={pipelineSources} pipelineSources={pipelineSources}
onToggleSource={onToggleSource} onToggleSource={onToggleSource}
onSetPipelineSources={onSetPipelineSources}
isPipelineRunning={isPipelineRunning} isPipelineRunning={isPipelineRunning}
onSaveAndRun={onSaveAndRunAutomatic} onSaveAndRun={onSaveAndRunAutomatic}
/> />

View File

@ -26,6 +26,7 @@ describe("automatic-run utilities", () => {
minSuitabilityScore: 50, minSuitabilityScore: 50,
searchTerms: ["backend", "platform"], searchTerms: ["backend", "platform"],
runBudget: 100, runBudget: 100,
country: "united kingdom",
}, },
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
}); });
@ -57,6 +58,7 @@ describe("automatic-run utilities", () => {
minSuitabilityScore: 50, minSuitabilityScore: 50,
searchTerms: [], searchTerms: [],
runBudget: 750, runBudget: 750,
country: "united kingdom",
}, },
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
}); });

View File

@ -7,6 +7,7 @@ export interface AutomaticRunValues {
minSuitabilityScore: number; minSuitabilityScore: number;
searchTerms: string[]; searchTerms: string[];
runBudget: number; runBudget: number;
country: string;
} }
export interface AutomaticPresetValues { export interface AutomaticPresetValues {

View File

@ -1,5 +1,10 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { JobspyValues } from "@client/pages/settings/types"; import type { JobspyValues } from "@client/pages/settings/types";
import {
formatCountryLabel,
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react"; import type React from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
@ -24,119 +29,6 @@ type JobspySectionProps = {
isSaving: boolean; isSaving: boolean;
}; };
const JOBSPY_INDEED_COUNTRIES = [
"argentina",
"australia",
"austria",
"bahrain",
"bangladesh",
"belgium",
"bulgaria",
"brazil",
"canada",
"chile",
"china",
"colombia",
"costa rica",
"croatia",
"cyprus",
"czech republic",
"czechia",
"denmark",
"ecuador",
"egypt",
"estonia",
"finland",
"france",
"germany",
"greece",
"hong kong",
"hungary",
"india",
"indonesia",
"ireland",
"israel",
"italy",
"japan",
"kuwait",
"latvia",
"lithuania",
"luxembourg",
"malaysia",
"malta",
"mexico",
"morocco",
"netherlands",
"new zealand",
"nigeria",
"norway",
"oman",
"pakistan",
"panama",
"peru",
"philippines",
"poland",
"portugal",
"qatar",
"romania",
"saudi arabia",
"singapore",
"slovakia",
"slovenia",
"south africa",
"south korea",
"spain",
"sweden",
"switzerland",
"taiwan",
"thailand",
"türkiye",
"turkey",
"ukraine",
"united arab emirates",
"uk",
"united kingdom",
"usa",
"us",
"united states",
"uruguay",
"venezuela",
"vietnam",
"usa/ca",
"worldwide",
];
const COUNTRY_ALIASES: Record<string, string> = {
uk: "united kingdom",
us: "united states",
usa: "united states",
türkiye: "turkey",
"czech republic": "czechia",
};
const COUNTRY_LABELS: Record<string, string> = {
"united kingdom": "United Kingdom",
"united states": "United States",
"usa/ca": "USA/CA",
turkey: "Turkey",
czechia: "Czechia",
};
const normalizeCountryValue = (value: string) =>
COUNTRY_ALIASES[value] ?? value;
const formatCountryLabel = (value: string) =>
COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase());
const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from(
new Map(
JOBSPY_INDEED_COUNTRIES.map((country) => {
const normalized = normalizeCountryValue(country);
return [normalized, normalized];
}),
).values(),
);
export const JobspySection: React.FC<JobspySectionProps> = ({ export const JobspySection: React.FC<JobspySectionProps> = ({
values, values,
isLoading, isLoading,
@ -332,8 +224,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
countryIndeed.default ?? countryIndeed.default ??
"" ""
).toLowerCase(); ).toLowerCase();
const normalizedValue = normalizeCountryValue(currentValue); const normalizedValue = normalizeCountryKey(currentValue);
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes( const displayValue = SUPPORTED_COUNTRY_KEYS.includes(
normalizedValue, normalizedValue,
) )
? normalizedValue ? normalizedValue
@ -365,7 +257,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<SelectItem value="__default__"> <SelectItem value="__default__">
{`Use default (${countryIndeed.default || "UK"})`} {`Use default (${countryIndeed.default || "UK"})`}
</SelectItem> </SelectItem>
{JOBSPY_INDEED_COUNTRY_OPTIONS.map((country) => ( {SUPPORTED_COUNTRY_KEYS.map((country) => (
<SelectItem key={country} value={country}> <SelectItem key={country} value={country}>
{formatCountryLabel(country)} {formatCountryLabel(country)}
</SelectItem> </SelectItem>

View File

@ -0,0 +1,150 @@
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,122 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,31 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -225,6 +225,87 @@ describe("discoverJobsStep", () => {
expect(progress.crawlingJobPagesProcessed).toBe(18); expect(progress.crawlingJobPagesProcessed).toBe(18);
}); });
it("skips UK-only sources for non-UK country and runs compatible sources", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
const crawler = await import("../../services/crawler");
const ukVisa = await import("../../services/ukvisajobs");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united states",
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
success: true,
jobs: [
{
source: "linkedin",
title: "Engineer",
employer: "ACME",
jobUrl: "https://example.com/job",
},
],
} as any);
const result = await discoverJobsStep({
mergedConfig: {
...config,
sources: ["linkedin", "gradcracker", "ukvisajobs"],
},
});
expect(result.discoveredJobs).toHaveLength(1);
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledTimes(1);
expect(vi.mocked(crawler.runCrawler)).not.toHaveBeenCalled();
expect(vi.mocked(ukVisa.runUkVisaJobs)).not.toHaveBeenCalled();
});
it("throws when all requested sources are incompatible for country", async () => {
const settingsRepo = await import("../../repositories/settings");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united states",
} as any);
await expect(
discoverJobsStep({
mergedConfig: {
...config,
sources: ["gradcracker", "ukvisajobs"],
},
}),
).rejects.toThrow(
"No compatible sources for selected country: United States",
);
});
it("does not throw when no sources are requested", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
const crawler = await import("../../services/crawler");
const ukVisa = await import("../../services/ukvisajobs");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspyCountryIndeed: "united states",
} as any);
const result = await discoverJobsStep({
mergedConfig: {
...config,
sources: [],
},
});
expect(result.discoveredJobs).toEqual([]);
expect(result.sourceErrors).toEqual([]);
expect(vi.mocked(jobSpy.runJobSpy)).not.toHaveBeenCalled();
expect(vi.mocked(crawler.runCrawler)).not.toHaveBeenCalled();
expect(vi.mocked(ukVisa.runUkVisaJobs)).not.toHaveBeenCalled();
});
it("tracks source completion counters across source transitions", async () => { it("tracks source completion counters across source transitions", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy"); const jobSpy = await import("../../services/jobspy");

View File

@ -1,4 +1,9 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import {
formatCountryLabel,
isSourceAllowedForCountry,
normalizeCountryKey,
} from "@shared/location-support.js";
import type { CreateJobInput, PipelineConfig } from "@shared/types"; import type { CreateJobInput, PipelineConfig } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs"; import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings"; import * as settingsRepo from "../../repositories/settings";
@ -35,7 +40,33 @@ export async function discoverJobsStep(args: {
.filter(Boolean); .filter(Boolean);
} }
let jobSpySites = args.mergedConfig.sources.filter( const selectedCountry = normalizeCountryKey(
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
);
const compatibleSources = args.mergedConfig.sources.filter((source) =>
isSourceAllowedForCountry(source, selectedCountry),
);
const skippedSources = args.mergedConfig.sources.filter(
(source) => !compatibleSources.includes(source),
);
if (skippedSources.length > 0) {
logger.info("Skipping incompatible sources for selected country", {
step: "discover-jobs",
country: selectedCountry,
countryLabel: formatCountryLabel(selectedCountry),
requestedSources: args.mergedConfig.sources,
skippedSources,
});
}
if (args.mergedConfig.sources.length > 0 && compatibleSources.length === 0) {
throw new Error(
`No compatible sources for selected country: ${formatCountryLabel(selectedCountry)}`,
);
}
let jobSpySites = compatibleSources.filter(
(source): source is "indeed" | "linkedin" => (source): source is "indeed" | "linkedin" =>
source === "indeed" || source === "linkedin", source === "indeed" || source === "linkedin",
); );
@ -53,9 +84,8 @@ export async function discoverJobsStep(args: {
} }
const shouldRunJobSpy = jobSpySites.length > 0; const shouldRunJobSpy = jobSpySites.length > 0;
const shouldRunGradcracker = const shouldRunGradcracker = compatibleSources.includes("gradcracker");
args.mergedConfig.sources.includes("gradcracker"); const shouldRunUkVisaJobs = compatibleSources.includes("ukvisajobs");
const shouldRunUkVisaJobs = args.mergedConfig.sources.includes("ukvisajobs");
const totalSources = const totalSources =
Number(shouldRunJobSpy) + Number(shouldRunJobSpy) +

1912
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,3 @@
export * from "./location-support";
export * from "./types"; export * from "./types";
export * from "./utils/type-conversion"; export * from "./utils/type-conversion";

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
formatCountryLabel,
getCompatibleSourcesForCountry,
isSourceAllowedForCountry,
isUkCountry,
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "./location-support";
describe("location-support", () => {
it("normalizes country aliases", () => {
expect(normalizeCountryKey("UK")).toBe("united kingdom");
expect(normalizeCountryKey("us")).toBe("united states");
expect(normalizeCountryKey("usa")).toBe("united states");
expect(normalizeCountryKey("czech republic")).toBe("czechia");
});
it("formats country labels", () => {
expect(formatCountryLabel("united kingdom")).toBe("United Kingdom");
expect(formatCountryLabel("usa/ca")).toBe("USA/CA");
expect(formatCountryLabel("south korea")).toBe("South Korea");
});
it("keeps supported country keys unique and canonical", () => {
expect(SUPPORTED_COUNTRY_KEYS).toContain("united kingdom");
expect(SUPPORTED_COUNTRY_KEYS).toContain("united states");
expect(SUPPORTED_COUNTRY_KEYS).toContain("worldwide");
expect(SUPPORTED_COUNTRY_KEYS).not.toContain("uk");
expect(SUPPORTED_COUNTRY_KEYS).not.toContain("us");
});
it("treats only united kingdom as UK country", () => {
expect(isUkCountry("united kingdom")).toBe(true);
expect(isUkCountry("UK")).toBe(true);
expect(isUkCountry("worldwide")).toBe(false);
expect(isUkCountry("usa/ca")).toBe(false);
expect(isUkCountry("united states")).toBe(false);
});
it("applies source compatibility rules by country", () => {
expect(isSourceAllowedForCountry("gradcracker", "united kingdom")).toBe(
true,
);
expect(isSourceAllowedForCountry("ukvisajobs", "uk")).toBe(true);
expect(isSourceAllowedForCountry("gradcracker", "united states")).toBe(
false,
);
expect(isSourceAllowedForCountry("ukvisajobs", "worldwide")).toBe(false);
expect(isSourceAllowedForCountry("indeed", "united states")).toBe(true);
expect(isSourceAllowedForCountry("linkedin", "worldwide")).toBe(true);
});
it("filters incompatible sources while preserving compatible order", () => {
expect(
getCompatibleSourcesForCountry(
["gradcracker", "indeed", "ukvisajobs", "linkedin"],
"united states",
),
).toEqual(["indeed", "linkedin"]);
});
});

View File

@ -0,0 +1,141 @@
import type { JobSource } from "./types";
const COUNTRY_ALIASES: Record<string, string> = {
uk: "united kingdom",
us: "united states",
usa: "united states",
türkiye: "turkey",
"czech republic": "czechia",
};
const COUNTRY_LABELS: Record<string, string> = {
"united kingdom": "United Kingdom",
"united states": "United States",
"usa/ca": "USA/CA",
turkey: "Turkey",
czechia: "Czechia",
};
// Keep this list aligned with the JobSpy supported country inputs.
export const SUPPORTED_COUNTRY_INPUTS = [
"argentina",
"australia",
"austria",
"bahrain",
"bangladesh",
"belgium",
"bulgaria",
"brazil",
"canada",
"chile",
"china",
"colombia",
"costa rica",
"croatia",
"cyprus",
"czech republic",
"czechia",
"denmark",
"ecuador",
"egypt",
"estonia",
"finland",
"france",
"germany",
"greece",
"hong kong",
"hungary",
"india",
"indonesia",
"ireland",
"israel",
"italy",
"japan",
"kuwait",
"latvia",
"lithuania",
"luxembourg",
"malaysia",
"malta",
"mexico",
"morocco",
"netherlands",
"new zealand",
"nigeria",
"norway",
"oman",
"pakistan",
"panama",
"peru",
"philippines",
"poland",
"portugal",
"qatar",
"romania",
"saudi arabia",
"singapore",
"slovakia",
"slovenia",
"south africa",
"south korea",
"spain",
"sweden",
"switzerland",
"taiwan",
"thailand",
"türkiye",
"turkey",
"ukraine",
"united arab emirates",
"uk",
"united kingdom",
"usa",
"us",
"united states",
"uruguay",
"venezuela",
"vietnam",
"usa/ca",
"worldwide",
] as const;
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
export function normalizeCountryKey(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase() ?? "";
return COUNTRY_ALIASES[normalized] ?? normalized;
}
export function formatCountryLabel(value: string): string {
const normalized = normalizeCountryKey(value);
if (!normalized) return "";
return (
COUNTRY_LABELS[normalized] ||
normalized.replace(/\b\w/g, (char) => char.toUpperCase())
);
}
export const SUPPORTED_COUNTRY_KEYS = Array.from(
new Set(
SUPPORTED_COUNTRY_INPUTS.map((country) => normalizeCountryKey(country)),
),
).filter(Boolean);
export function isUkCountry(country: string | null | undefined): boolean {
return normalizeCountryKey(country) === "united kingdom";
}
export function isSourceAllowedForCountry(
source: JobSource,
country: string | null | undefined,
): boolean {
if (!UK_ONLY_SOURCES.has(source)) return true;
return isUkCountry(country);
}
export function getCompatibleSourcesForCountry(
sources: JobSource[],
country: string | null | undefined,
): JobSource[] {
return sources.filter((source) => isSourceAllowedForCountry(source, country));
}