City search (#217)
* wave 1, jobspy only * combine usa/ca to united states * strict city location filter * hide and show based on focus * UI changes * allow clicking cross! * pill animate in * animate out, uggo fix * animate out * framer motion * animate component height * adzuna * hiring cafe implementation * refactor: centralize shared search-city parsing and matching * feat: migrate city setting to searchCities with legacy fallback * docs: update pipeline and extractor city-search wording * fix(orchestrator): normalize tokenized paste behavior * fix(shared): tighten city matching semantics * docs(extractors): document city-location knobs and geocoding note
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"get-tsconfig": "^4.10.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jsdom": "^25.0.1",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { OrchestratorPage } from "./OrchestratorPage";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
@@ -51,14 +52,15 @@ let mockPipelineTerminalEvent: {
|
||||
token: number;
|
||||
} | null = null;
|
||||
let mockPipelineSources = ["linkedin"] as Array<
|
||||
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
|
||||
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs" | "adzuna" | "hiringcafe"
|
||||
>;
|
||||
let mockAutomaticRunValues = {
|
||||
let mockAutomaticRunValues: AutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
const jobFixture = createJob({
|
||||
@@ -325,13 +327,7 @@ vi.mock("./orchestrator/RunModeModal", () => ({
|
||||
RunModeModal: ({
|
||||
onSaveAndRunAutomatic,
|
||||
}: {
|
||||
onSaveAndRunAutomatic: (values: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
}) => Promise<void>;
|
||||
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -386,6 +382,7 @@ describe("OrchestratorPage", () => {
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -701,7 +698,7 @@ describe("OrchestratorPage", () => {
|
||||
ukvisajobsMaxJobs: 150,
|
||||
adzunaMaxJobsPerTerm: 150,
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
searchCities: "United Kingdom",
|
||||
});
|
||||
});
|
||||
expect(api.runPipeline).toHaveBeenCalledWith({
|
||||
@@ -714,6 +711,108 @@ describe("OrchestratorPage", () => {
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("stores multiple cities for JobSpy sources in automatic mode", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["linkedin"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["London", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "London|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores multiple cities when only adzuna is selected", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["adzuna"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "Leeds|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores multiple cities when only hiringcafe is selected", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["hiringcafe"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/jobs/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchCities: "Leeds|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows completion toast from hook terminal state", async () => {
|
||||
mockPipelineTerminalEvent = {
|
||||
status: "completed",
|
||||
@@ -797,6 +896,7 @@ describe("OrchestratorPage", () => {
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united states",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
render(
|
||||
|
||||
@@ -22,7 +22,10 @@ import * as api from "../api";
|
||||
import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar";
|
||||
import { KeyboardShortcutDialog } from "../components/KeyboardShortcutDialog";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
|
||||
import {
|
||||
deriveExtractorLimits,
|
||||
serializeCityLocationsSetting,
|
||||
} from "./orchestrator/automatic-run";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
import { tabs } from "./orchestrator/constants";
|
||||
import { FloatingJobActionsBar } from "./orchestrator/FloatingJobActionsBar";
|
||||
@@ -291,10 +294,21 @@ export const OrchestratorPage: React.FC = () => {
|
||||
searchTerms: values.searchTerms,
|
||||
sources: compatibleSources,
|
||||
});
|
||||
const jobspyLocation = compatibleSources.includes("glassdoor")
|
||||
? (values.glassdoorLocation ?? "").trim() ||
|
||||
formatCountryLabel(values.country)
|
||||
: formatCountryLabel(values.country);
|
||||
const hasJobSpySite = compatibleSources.some(
|
||||
(source) =>
|
||||
source === "indeed" ||
|
||||
source === "linkedin" ||
|
||||
source === "glassdoor",
|
||||
);
|
||||
const hasAdzuna = compatibleSources.includes("adzuna");
|
||||
const hasHiringCafe = compatibleSources.includes("hiringcafe");
|
||||
const serializedCities = serializeCityLocationsSetting(
|
||||
values.cityLocations,
|
||||
);
|
||||
const searchCities =
|
||||
(hasJobSpySite || hasAdzuna || hasHiringCafe) && serializedCities
|
||||
? serializedCities
|
||||
: formatCountryLabel(values.country);
|
||||
await api.updateSettings({
|
||||
searchTerms: values.searchTerms,
|
||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||
@@ -302,7 +316,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||
adzunaMaxJobsPerTerm: limits.adzunaMaxJobsPerTerm,
|
||||
jobspyCountryIndeed: values.country,
|
||||
jobspyLocation,
|
||||
searchCities,
|
||||
});
|
||||
await refreshSettings();
|
||||
await startPipelineRun({
|
||||
|
||||
@@ -70,8 +70,8 @@ const baseSettings = createAppSettings({
|
||||
defaultJobspyResultsWanted: 200,
|
||||
jobspyCountryIndeed: "UK",
|
||||
defaultJobspyCountryIndeed: "UK",
|
||||
jobspyLocation: "UK",
|
||||
defaultJobspyLocation: "UK",
|
||||
searchCities: "London",
|
||||
defaultSearchCities: "London",
|
||||
searchTerms: ["engineer"],
|
||||
defaultSearchTerms: ["engineer"],
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@@ -41,6 +41,29 @@ describe("AutomaticRunTab", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps legacy usa/ca country to United States in the picker", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "usa/ca",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
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();
|
||||
|
||||
@@ -50,7 +73,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
@@ -76,7 +99,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@@ -103,7 +126,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "japan",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@@ -134,7 +157,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
searchCities: "United Kingdom",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
@@ -152,7 +175,7 @@ describe("AutomaticRunTab", () => {
|
||||
const glassdoorButton = screen.getByRole("button", { name: "Glassdoor" });
|
||||
expect(glassdoorButton).toBeDisabled();
|
||||
expect(glassdoorButton.getAttribute("title")).toContain(
|
||||
"Set a Glassdoor city in Advanced settings to enable Glassdoor.",
|
||||
"Add at least one city in Advanced settings to enable Glassdoor.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -163,7 +186,7 @@ describe("AutomaticRunTab", () => {
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer", "frontend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "",
|
||||
searchCities: "",
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
@@ -175,6 +198,7 @@ describe("AutomaticRunTab", () => {
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Type and press Enter");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "Backspace" });
|
||||
|
||||
expect(
|
||||
@@ -184,4 +208,34 @@ describe("AutomaticRunTab", () => {
|
||||
screen.getByRole("button", { name: "Remove frontend engineer" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads multiple saved cities and keeps glassdoor enabled", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "London|Manchester",
|
||||
})}
|
||||
enabledSources={["linkedin", "glassdoor"]}
|
||||
pipelineSources={["linkedin", "glassdoor"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
fireEvent.focus(screen.getByLabelText("Cities"));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city London" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city Manchester" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support.js";
|
||||
import type { AppSettings, JobSource } from "@shared/types";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@@ -33,9 +33,12 @@ import {
|
||||
type AutomaticRunValues,
|
||||
calculateAutomaticEstimate,
|
||||
loadAutomaticRunMemory,
|
||||
parseCityLocationsInput,
|
||||
parseCityLocationsSetting,
|
||||
parseSearchTermsInput,
|
||||
saveAutomaticRunMemory,
|
||||
} from "./automatic-run";
|
||||
import { TokenizedInput } from "./TokenizedInput";
|
||||
|
||||
interface AutomaticRunTabProps {
|
||||
open: boolean;
|
||||
@@ -54,6 +57,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
||||
searchTerms: ["web developer"],
|
||||
runBudget: 200,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
};
|
||||
|
||||
interface AutomaticRunFormValues {
|
||||
@@ -61,7 +65,8 @@ interface AutomaticRunFormValues {
|
||||
minSuitabilityScore: string;
|
||||
runBudget: string;
|
||||
country: string;
|
||||
glassdoorLocation: string;
|
||||
cityLocations: string[];
|
||||
cityLocationDraft: string;
|
||||
searchTerms: string[];
|
||||
searchTermDraft: string;
|
||||
}
|
||||
@@ -71,8 +76,15 @@ type AutomaticPresetSelection = AutomaticPresetId | "custom";
|
||||
const GLASSDOOR_COUNTRY_REASON =
|
||||
"Glassdoor is not available for the selected country.";
|
||||
const GLASSDOOR_LOCATION_REASON =
|
||||
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
|
||||
"Add at least one city in Advanced settings to enable Glassdoor.";
|
||||
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
|
||||
const HIDDEN_COUNTRY_KEYS = new Set(["usa/ca"]);
|
||||
|
||||
function normalizeUiCountryKey(value: string): string {
|
||||
const normalized = normalizeCountryKey(value);
|
||||
if (normalized === "usa/ca") return "united states";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getSourceDisabledReason(
|
||||
source: JobSource,
|
||||
@@ -138,25 +150,25 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||
{
|
||||
defaultValues: {
|
||||
topN: String(DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||
country: DEFAULT_VALUES.country,
|
||||
glassdoorLocation: "",
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
const { watch, reset, setValue } = useForm<AutomaticRunFormValues>({
|
||||
defaultValues: {
|
||||
topN: String(DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||
country: DEFAULT_VALUES.country,
|
||||
cityLocations: [],
|
||||
cityLocationDraft: "",
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const topNInput = watch("topN");
|
||||
const minScoreInput = watch("minSuitabilityScore");
|
||||
const runBudgetInput = watch("runBudget");
|
||||
const countryInput = watch("country");
|
||||
const glassdoorLocationInput = watch("glassdoorLocation");
|
||||
const cityLocations = watch("cityLocations");
|
||||
const cityLocationDraft = watch("cityLocationDraft");
|
||||
const searchTerms = watch("searchTerms");
|
||||
const searchTermDraft = watch("searchTermDraft");
|
||||
|
||||
@@ -173,48 +185,35 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
settings?.gradcrackerMaxJobsPerTerm ??
|
||||
settings?.ukvisajobsMaxJobs ??
|
||||
DEFAULT_VALUES.runBudget;
|
||||
const rememberedCountry = normalizeCountryKey(
|
||||
const rememberedCountry = normalizeUiCountryKey(
|
||||
settings?.jobspyCountryIndeed ??
|
||||
settings?.jobspyLocation ??
|
||||
settings?.searchCities ??
|
||||
DEFAULT_VALUES.country,
|
||||
);
|
||||
const rememberedCountryKey = rememberedCountry || DEFAULT_VALUES.country;
|
||||
const rememberedLocationRaw = settings?.jobspyLocation?.trim() ?? "";
|
||||
const rememberedLocationNormalized = normalizeCountryKey(
|
||||
rememberedLocationRaw,
|
||||
const rememberedLocations = parseCityLocationsSetting(
|
||||
settings?.searchCities,
|
||||
).filter(
|
||||
(location) =>
|
||||
normalizeCountryKey(location) !==
|
||||
normalizeCountryKey(rememberedCountryKey),
|
||||
);
|
||||
const rememberedGlassdoorLocation =
|
||||
rememberedLocationRaw &&
|
||||
rememberedLocationNormalized &&
|
||||
rememberedLocationNormalized !== normalizeCountryKey(rememberedCountryKey)
|
||||
? rememberedLocationRaw
|
||||
: "";
|
||||
|
||||
reset({
|
||||
topN: String(topN),
|
||||
minSuitabilityScore: String(minSuitabilityScore),
|
||||
runBudget: String(rememberedRunBudget),
|
||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||
glassdoorLocation: rememberedGlassdoorLocation,
|
||||
cityLocations: rememberedLocations,
|
||||
cityLocationDraft: "",
|
||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
setAdvancedOpen(false);
|
||||
}, [open, settings, reset]);
|
||||
|
||||
const addSearchTerms = (input: string) => {
|
||||
const parsed = parseSearchTermsInput(input);
|
||||
if (parsed.length === 0) return;
|
||||
const current = getValues("searchTerms");
|
||||
const next = [...current];
|
||||
for (const term of parsed) {
|
||||
if (!next.includes(term)) next.push(term);
|
||||
}
|
||||
setValue("searchTerms", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const values = useMemo<AutomaticRunValues>(() => {
|
||||
const normalizedCountry = normalizeCountryKey(countryInput);
|
||||
const normalizedCountry = normalizeUiCountryKey(countryInput);
|
||||
return {
|
||||
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: toNumber(
|
||||
@@ -225,7 +224,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
),
|
||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
||||
country: normalizedCountry || DEFAULT_VALUES.country,
|
||||
glassdoorLocation: glassdoorLocationInput.trim() || undefined,
|
||||
cityLocations,
|
||||
searchTerms,
|
||||
};
|
||||
}, [
|
||||
@@ -233,17 +232,18 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
minScoreInput,
|
||||
runBudgetInput,
|
||||
countryInput,
|
||||
glassdoorLocationInput,
|
||||
cityLocations,
|
||||
searchTerms,
|
||||
]);
|
||||
|
||||
const isSourceAvailableForRun = useCallback(
|
||||
(source: JobSource) => {
|
||||
if (!isSourceAllowedForCountry(source, values.country)) return false;
|
||||
if (source === "glassdoor" && !values.glassdoorLocation) return false;
|
||||
if (source === "glassdoor" && values.cityLocations.length === 0)
|
||||
return false;
|
||||
return true;
|
||||
},
|
||||
[values.country, values.glassdoorLocation],
|
||||
[values.country, values.cityLocations.length],
|
||||
);
|
||||
|
||||
const compatibleEnabledSources = useMemo(
|
||||
@@ -319,7 +319,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
|
||||
const countryOptions = useMemo(
|
||||
() =>
|
||||
SUPPORTED_COUNTRY_KEYS.map((country) => ({
|
||||
SUPPORTED_COUNTRY_KEYS.filter(
|
||||
(country) => !HIDDEN_COUNTRY_KEYS.has(country),
|
||||
).map((country) => ({
|
||||
value: country,
|
||||
label: formatCountryLabel(country),
|
||||
})),
|
||||
@@ -389,7 +391,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={advancedOpen ? "advanced" : undefined}
|
||||
value={advancedOpen ? "advanced" : ""}
|
||||
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
|
||||
>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
@@ -438,21 +440,24 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label htmlFor="glassdoor-location">Glassdoor city</Label>
|
||||
<Input
|
||||
id="glassdoor-location"
|
||||
value={glassdoorLocationInput}
|
||||
onChange={(event) =>
|
||||
setValue("glassdoorLocation", event.target.value, {
|
||||
<Label htmlFor="city-locations-input">Cities</Label>
|
||||
<TokenizedInput
|
||||
id="city-locations-input"
|
||||
values={cityLocations}
|
||||
draft={cityLocationDraft}
|
||||
parseInput={parseCityLocationsInput}
|
||||
onDraftChange={(value) =>
|
||||
setValue("cityLocationDraft", value)
|
||||
}
|
||||
onValuesChange={(value) =>
|
||||
setValue("cityLocations", value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
placeholder='e.g. "London"'
|
||||
helperText="Optional for all sources, required when Glassdoor is selected."
|
||||
removeLabelPrefix="Remove city"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required only for Glassdoor. Use a city (not country) to
|
||||
keep results localized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
@@ -465,58 +470,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Search terms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
<CardContent>
|
||||
<TokenizedInput
|
||||
id="search-terms-input"
|
||||
value={searchTermDraft}
|
||||
onChange={(event) =>
|
||||
setValue("searchTermDraft", event.target.value)
|
||||
values={searchTerms}
|
||||
draft={searchTermDraft}
|
||||
parseInput={parseSearchTermsInput}
|
||||
onDraftChange={(value) => setValue("searchTermDraft", value)}
|
||||
onValuesChange={(value) =>
|
||||
setValue("searchTerms", value, { shouldDirty: true })
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
addSearchTerms(searchTermDraft);
|
||||
setValue("searchTermDraft", "");
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const parsed = parseSearchTermsInput(pasted);
|
||||
if (parsed.length > 1) {
|
||||
event.preventDefault();
|
||||
addSearchTerms(pasted);
|
||||
}
|
||||
}}
|
||||
placeholder="Type and press Enter"
|
||||
helperText="Add multiple terms by separating with commas or pressing Enter."
|
||||
removeLabelPrefix="Remove"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add multiple terms by separating with commas or pressing Enter.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchTerms.map((term) => (
|
||||
<button
|
||||
type="button"
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
|
||||
aria-label={`Remove ${term}`}
|
||||
onClick={() =>
|
||||
setValue(
|
||||
"searchTerms",
|
||||
searchTerms.filter((value) => value !== term),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
{term}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FloatingJobActionsBarProps {
|
||||
selectedCount: number;
|
||||
@@ -26,84 +25,71 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
onRescoreSelected,
|
||||
onClear,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
setIsMounted(true);
|
||||
const enterTimer = window.setTimeout(() => setIsVisible(true), 10);
|
||||
return () => window.clearTimeout(enterTimer);
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
const exitTimer = window.setTimeout(() => setIsMounted(false), 180);
|
||||
return () => window.clearTimeout(exitTimer);
|
||||
}, [selectedCount]);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
|
||||
"transition-all duration-200 ease-out",
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
{canMoveSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onMoveToReady}
|
||||
>
|
||||
Move to Ready
|
||||
</Button>
|
||||
)}
|
||||
{canSkipSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onSkipSelected}
|
||||
>
|
||||
Skip selected
|
||||
</Button>
|
||||
)}
|
||||
{canRescoreSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onRescoreSelected}
|
||||
>
|
||||
Recalculate match
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClear}
|
||||
disabled={jobActionInFlight}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{selectedCount > 0 ? (
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
<div className="pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
{canMoveSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onMoveToReady}
|
||||
>
|
||||
Move to Ready
|
||||
</Button>
|
||||
)}
|
||||
{canSkipSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onSkipSelected}
|
||||
>
|
||||
Skip selected
|
||||
</Button>
|
||||
)}
|
||||
{canRescoreSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={jobActionInFlight}
|
||||
onClick={onRescoreSelected}
|
||||
>
|
||||
Recalculate match
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClear}
|
||||
disabled={jobActionInFlight}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCityLocationsInput } from "./automatic-run";
|
||||
import { TokenizedInput } from "./TokenizedInput";
|
||||
|
||||
function buildClipboardData(text: string): DataTransfer {
|
||||
return {
|
||||
getData: (type: string) => (type === "text" ? text : ""),
|
||||
} as DataTransfer;
|
||||
}
|
||||
|
||||
function renderCityInput() {
|
||||
let values: string[] = [];
|
||||
let draft = "";
|
||||
|
||||
const setValues = (next: string[]) => {
|
||||
values = next;
|
||||
rerenderInput();
|
||||
};
|
||||
const setDraft = (next: string) => {
|
||||
draft = next;
|
||||
rerenderInput();
|
||||
};
|
||||
|
||||
const renderInput = () => (
|
||||
<TokenizedInput
|
||||
id="cities"
|
||||
values={values}
|
||||
draft={draft}
|
||||
parseInput={parseCityLocationsInput}
|
||||
onDraftChange={setDraft}
|
||||
onValuesChange={setValues}
|
||||
placeholder='e.g. "London"'
|
||||
helperText="City helper"
|
||||
removeLabelPrefix="Remove city"
|
||||
/>
|
||||
);
|
||||
|
||||
const { rerender } = render(renderInput());
|
||||
|
||||
const rerenderInput = () => {
|
||||
rerender(renderInput());
|
||||
};
|
||||
|
||||
return {
|
||||
getInput: () =>
|
||||
screen.getByPlaceholderText('e.g. "London"') as HTMLInputElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TokenizedInput", () => {
|
||||
it("tokenizes single-value paste and clears draft", () => {
|
||||
const { getInput } = renderCityInput();
|
||||
const input = getInput();
|
||||
|
||||
fireEvent.change(input, { target: { value: "foo" } });
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: buildClipboardData("Leeds"),
|
||||
});
|
||||
|
||||
expect(input.value).toBe("");
|
||||
expect(screen.getByText("Currently selected: Leeds")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tokenizes multi-value paste and removes duplicates", () => {
|
||||
const { getInput } = renderCityInput();
|
||||
const input = getInput();
|
||||
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: buildClipboardData("Leeds, London, leeds"),
|
||||
});
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect(input.value).toBe("");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city Leeds" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove city London" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Remove city leeds" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface TokenizedInputProps {
|
||||
id: string;
|
||||
values: string[];
|
||||
draft: string;
|
||||
parseInput: (input: string) => string[];
|
||||
onDraftChange: (value: string) => void;
|
||||
onValuesChange: (values: string[]) => void;
|
||||
placeholder: string;
|
||||
helperText: string;
|
||||
removeLabelPrefix: string;
|
||||
collapsedTextLimit?: number;
|
||||
}
|
||||
|
||||
function mergeUnique(values: string[], nextValues: string[]): string[] {
|
||||
const seen = new Set(values.map((value) => value.toLowerCase()));
|
||||
const out = [...values];
|
||||
for (const value of nextValues) {
|
||||
const key = value.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const TokenizedInput: React.FC<TokenizedInputProps> = ({
|
||||
id,
|
||||
values,
|
||||
draft,
|
||||
parseInput,
|
||||
onDraftChange,
|
||||
onValuesChange,
|
||||
placeholder,
|
||||
helperText,
|
||||
removeLabelPrefix,
|
||||
collapsedTextLimit = 3,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const tokensRef = useRef<HTMLDivElement | null>(null);
|
||||
const summaryRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [tokensHeight, setTokensHeight] = useState(20);
|
||||
const [summaryHeight, setSummaryHeight] = useState(20);
|
||||
const updateHeights = useCallback(() => {
|
||||
if (tokensRef.current) {
|
||||
setTokensHeight(Math.max(20, tokensRef.current.scrollHeight));
|
||||
}
|
||||
if (summaryRef.current) {
|
||||
setSummaryHeight(Math.max(20, summaryRef.current.scrollHeight));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const collapsedSummary = useMemo(() => {
|
||||
if (values.length === 0) return "";
|
||||
const visibleCount = Math.max(0, Math.floor(collapsedTextLimit));
|
||||
if (visibleCount === 0) return `and ${values.length} more`;
|
||||
|
||||
const visibleValues = values.slice(0, visibleCount);
|
||||
const hiddenCount = values.length - visibleValues.length;
|
||||
if (hiddenCount <= 0) return visibleValues.join(", ");
|
||||
return `${visibleValues.join(", ")} and ${hiddenCount} more`;
|
||||
}, [collapsedTextLimit, values]);
|
||||
|
||||
const addValues = (input: string) => {
|
||||
const parsed = parseInput(input);
|
||||
if (parsed.length === 0) return;
|
||||
onValuesChange(mergeUnique(values, parsed));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHeights();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver(updateHeights);
|
||||
if (tokensRef.current) observer.observe(tokensRef.current);
|
||||
if (summaryRef.current) observer.observe(summaryRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateHeights]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHeights();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
id={id}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
addValues(draft);
|
||||
onDraftChange("");
|
||||
return;
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
addValues(draft);
|
||||
onDraftChange("");
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const parsed = parseInput(pasted);
|
||||
if (parsed.length > 0) {
|
||||
event.preventDefault();
|
||||
addValues(pasted);
|
||||
onDraftChange("");
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||
{values.length > 0 ? (
|
||||
<motion.div
|
||||
className="relative overflow-hidden"
|
||||
animate={{ height: isFocused ? tokensHeight : summaryHeight }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden={!isFocused}
|
||||
ref={tokensRef}
|
||||
className="absolute inset-x-0 top-0 flex flex-wrap gap-2"
|
||||
animate={{
|
||||
opacity: isFocused ? 1 : 0,
|
||||
y: isFocused ? 0 : -4,
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ pointerEvents: isFocused ? "auto" : "none" }}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{values.map((value) => (
|
||||
<motion.div
|
||||
key={value}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.96, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: -4 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto rounded-full px-2 py-1 text-xs text-muted-foreground"
|
||||
aria-label={`${removeLabelPrefix} ${value}`}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
onValuesChange(
|
||||
values.filter((existing) => existing !== value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
aria-hidden={isFocused}
|
||||
ref={summaryRef}
|
||||
className="absolute inset-x-0 top-0 text-xs text-muted-foreground"
|
||||
animate={{
|
||||
opacity: isFocused ? 0 : 1,
|
||||
y: isFocused ? 4 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ pointerEvents: isFocused ? "none" : "auto" }}
|
||||
>
|
||||
Currently selected: {collapsedSummary}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 100,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@@ -59,6 +60,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: [],
|
||||
runBudget: 750,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@@ -85,6 +87,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["adzuna"],
|
||||
});
|
||||
@@ -101,6 +104,7 @@ describe("automatic-run utilities", () => {
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
},
|
||||
sources: ["hiringcafe"],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
parseSearchCitiesSetting,
|
||||
serializeSearchCitiesSetting,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { JobSource } from "@shared/types";
|
||||
|
||||
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
||||
@@ -8,7 +12,7 @@ export interface AutomaticRunValues {
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
glassdoorLocation?: string;
|
||||
cityLocations: string[];
|
||||
}
|
||||
|
||||
export interface AutomaticPresetValues {
|
||||
@@ -115,6 +119,29 @@ export function parseSearchTermsInput(input: string): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseCityLocationsInput(input: string): string[] {
|
||||
const parsed = parseSearchTermsInput(input);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const city of parsed) {
|
||||
const key = city.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(city);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCityLocationsSetting(
|
||||
location: string | null | undefined,
|
||||
): string[] {
|
||||
return parseSearchCitiesSetting(location);
|
||||
}
|
||||
|
||||
export function serializeCityLocationsSetting(cities: string[]): string | null {
|
||||
return serializeSearchCitiesSetting(cities);
|
||||
}
|
||||
|
||||
export function stringifySearchTerms(terms: string[]): string {
|
||||
return terms.join("\n");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
|
||||
backupEnabled: "0",
|
||||
backupHour: "2",
|
||||
backupMaxCount: "5",
|
||||
jobspyLocation: "United States",
|
||||
searchCities: "United States",
|
||||
jobspyResultsWanted: "25",
|
||||
jobspyCountryIndeed: "US",
|
||||
resumeProjects: JSON.stringify({
|
||||
|
||||
@@ -116,6 +116,35 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes serialized multi-city locations to JobSpy", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "London|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["linkedin"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
location: "London|Manchester",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out glassdoor for unsupported countries", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
@@ -201,6 +230,37 @@ describe("discoverJobsStep", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured city locations to adzuna", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const adzuna = await import("../../services/adzuna");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
searchCities: "Leeds|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(adzuna.runAdzuna).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["adzuna"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(adzuna.runAdzuna)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "gb",
|
||||
countryKey: "united kingdom",
|
||||
locations: ["Leeds", "Manchester"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips adzuna for unsupported countries", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const adzuna = await import("../../services/adzuna");
|
||||
@@ -257,12 +317,46 @@ describe("discoverJobsStep", () => {
|
||||
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "united states",
|
||||
countryKey: "united states",
|
||||
locations: [],
|
||||
searchTerms: ["engineer"],
|
||||
maxJobsPerTerm: 25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured city locations to hiringcafe", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const hiringCafe = await import("../../services/hiring-cafe");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyResultsWanted: "25",
|
||||
searchCities: "Leeds|Manchester",
|
||||
} as any);
|
||||
|
||||
vi.mocked(hiringCafe.runHiringCafe).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [],
|
||||
} as any);
|
||||
|
||||
await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["hiringcafe"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(vi.mocked(hiringCafe.runHiringCafe)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
country: "united kingdom",
|
||||
countryKey: "united kingdom",
|
||||
locations: ["Leeds", "Manchester"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates Hiring Cafe terms and pages via progress callbacks", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const hiringCafe = await import("../../services/hiring-cafe");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isSourceAllowedForCountry,
|
||||
normalizeCountryKey,
|
||||
} from "@shared/location-support.js";
|
||||
import { parseSearchCitiesSetting } from "@shared/search-cities.js";
|
||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
@@ -59,7 +60,10 @@ export async function discoverJobsStep(args: {
|
||||
}
|
||||
|
||||
const selectedCountry = normalizeCountryKey(
|
||||
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
|
||||
settings.jobspyCountryIndeed ??
|
||||
settings.searchCities ??
|
||||
settings.jobspyLocation ??
|
||||
"united kingdom",
|
||||
);
|
||||
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
||||
isSourceAllowedForCountry(source, selectedCountry),
|
||||
@@ -100,7 +104,8 @@ export async function discoverJobsStep(args: {
|
||||
const jobSpyResult = await runJobSpy({
|
||||
sites: jobSpySites,
|
||||
searchTerms,
|
||||
location: settings.jobspyLocation ?? undefined,
|
||||
location:
|
||||
settings.searchCities ?? settings.jobspyLocation ?? undefined,
|
||||
resultsWanted: settings.jobspyResultsWanted
|
||||
? parseInt(settings.jobspyResultsWanted, 10)
|
||||
: undefined,
|
||||
@@ -172,6 +177,10 @@ export async function discoverJobsStep(args: {
|
||||
|
||||
const adzunaResult = await runAdzuna({
|
||||
country: adzunaCountryCode,
|
||||
countryKey: selectedCountry,
|
||||
locations: parseSearchCitiesSetting(
|
||||
settings.searchCities ?? settings.jobspyLocation,
|
||||
),
|
||||
searchTerms,
|
||||
maxJobsPerTerm: adzunaMaxJobsPerTerm,
|
||||
onProgress: (event) => {
|
||||
@@ -249,6 +258,10 @@ export async function discoverJobsStep(args: {
|
||||
|
||||
const hiringCafeResult = await runHiringCafe({
|
||||
country: selectedCountry,
|
||||
countryKey: selectedCountry,
|
||||
locations: parseSearchCitiesSetting(
|
||||
settings.searchCities ?? settings.jobspyLocation,
|
||||
),
|
||||
searchTerms,
|
||||
maxJobsPerTerm: hiringCafeMaxJobsPerTerm,
|
||||
onProgress: (event) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SettingKey =
|
||||
| "adzunaMaxJobsPerTerm"
|
||||
| "gradcrackerMaxJobsPerTerm"
|
||||
| "searchTerms"
|
||||
| "searchCities"
|
||||
| "jobspyLocation"
|
||||
| "jobspyResultsWanted"
|
||||
| "jobspyCountryIndeed"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
matchesRequestedLocation,
|
||||
shouldApplyStrictLocationFilter,
|
||||
} from "./adzuna";
|
||||
|
||||
describe("adzuna strict location filtering", () => {
|
||||
it("enables strict filtering when city differs from country", () => {
|
||||
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables strict filtering when location is country-level", () => {
|
||||
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
|
||||
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches requested location by case-insensitive contains", () => {
|
||||
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
|
||||
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logger } from "@infra/logger";
|
||||
import { normalizeCountryKey } from "@shared/location-support.js";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
|
||||
@@ -44,6 +50,8 @@ export type AdzunaProgressEvent =
|
||||
export interface RunAdzunaOptions {
|
||||
searchTerms?: string[];
|
||||
country?: string;
|
||||
countryKey?: string;
|
||||
locations?: string[];
|
||||
maxJobsPerTerm?: number;
|
||||
onProgress?: (event: AdzunaProgressEvent) => void;
|
||||
}
|
||||
@@ -54,6 +62,27 @@ export interface AdzunaResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryKey: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryKey);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunAdzunaOptions): string[] {
|
||||
const raw = options.locations?.length
|
||||
? options.locations
|
||||
: parseSearchCitiesSetting(process.env.ADZUNA_LOCATION_QUERY ?? "");
|
||||
return raw.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTsxCliPath(): string | null {
|
||||
try {
|
||||
return require.resolve("tsx/dist/cli.mjs");
|
||||
@@ -170,11 +199,15 @@ export async function runAdzuna(
|
||||
}
|
||||
|
||||
const country = (options.country || "gb").trim().toLowerCase();
|
||||
const countryKey = normalizeCountryKey(options.countryKey ?? "");
|
||||
const maxJobsPerTerm = options.maxJobsPerTerm ?? 50;
|
||||
const searchTerms =
|
||||
options.searchTerms && options.searchTerms.length > 0
|
||||
? options.searchTerms
|
||||
: ["web developer"];
|
||||
const locations = resolveLocations(options);
|
||||
const runLocations = locations.length > 0 ? locations : [null];
|
||||
const termTotal = searchTerms.length * runLocations.length;
|
||||
const useNpmCommand = canRunNpmCommand();
|
||||
if (!useNpmCommand && !TSX_CLI_PATH) {
|
||||
return {
|
||||
@@ -185,66 +218,95 @@ export async function runAdzuna(
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const extractorEnv = {
|
||||
...process.env,
|
||||
JOBOPS_EMIT_PROGRESS: "1",
|
||||
ADZUNA_APP_ID: appId,
|
||||
ADZUNA_APP_KEY: appKey,
|
||||
ADZUNA_COUNTRY: country,
|
||||
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||
ADZUNA_OUTPUT_JSON: DATASET_PATH,
|
||||
};
|
||||
const child = useNpmCommand
|
||||
? spawn("npm", ["run", "start"], {
|
||||
cwd: ADZUNA_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
})
|
||||
: (() => {
|
||||
const tsxCliPath = TSX_CLI_PATH;
|
||||
if (!tsxCliPath) {
|
||||
throw new Error(
|
||||
"Unable to execute Adzuna extractor (npm/tsx unavailable)",
|
||||
);
|
||||
}
|
||||
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
|
||||
const location = runLocations[runIndex];
|
||||
const strictLocationFilter =
|
||||
location !== null &&
|
||||
shouldApplyStrictLocationFilter(location, countryKey);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const extractorEnv = {
|
||||
...process.env,
|
||||
JOBOPS_EMIT_PROGRESS: "1",
|
||||
ADZUNA_APP_ID: appId,
|
||||
ADZUNA_APP_KEY: appKey,
|
||||
ADZUNA_COUNTRY: country,
|
||||
ADZUNA_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
ADZUNA_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||
ADZUNA_OUTPUT_JSON: DATASET_PATH,
|
||||
ADZUNA_LOCATION_QUERY: strictLocationFilter ? location : "",
|
||||
};
|
||||
const child = useNpmCommand
|
||||
? spawn("npm", ["run", "start"], {
|
||||
cwd: ADZUNA_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
})
|
||||
: (() => {
|
||||
const tsxCliPath = TSX_CLI_PATH;
|
||||
if (!tsxCliPath) {
|
||||
throw new Error(
|
||||
"Unable to execute Adzuna extractor (npm/tsx unavailable)",
|
||||
);
|
||||
}
|
||||
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
|
||||
cwd: ADZUNA_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
});
|
||||
})();
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseAdzunaProgressLine(line);
|
||||
if (progressEvent) {
|
||||
const termOffset = runIndex * searchTerms.length;
|
||||
options.onProgress?.({
|
||||
...progressEvent,
|
||||
termIndex: termOffset + progressEvent.termIndex,
|
||||
termTotal,
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseAdzunaProgressLine(line);
|
||||
if (progressEvent) {
|
||||
options.onProgress?.(progressEvent);
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Adzuna extractor exited with code ${code}`));
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Adzuna extractor exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const jobs = await readDataset();
|
||||
const runJobs = await readDataset();
|
||||
const filtered = strictLocationFilter
|
||||
? runJobs.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: runJobs;
|
||||
|
||||
for (const job of filtered) {
|
||||
const key = job.sourceJobId || job.jobUrl;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
matchesRequestedLocation,
|
||||
shouldApplyStrictLocationFilter,
|
||||
} from "./hiring-cafe";
|
||||
|
||||
describe("hiringcafe strict location filtering", () => {
|
||||
it("enables strict filtering when city differs from country", () => {
|
||||
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables strict filtering when location is country-level", () => {
|
||||
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
|
||||
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches requested location by case-insensitive contains", () => {
|
||||
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
|
||||
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logger } from "@infra/logger";
|
||||
import { sanitizeUnknown } from "@infra/sanitize";
|
||||
import { normalizeCountryKey } from "@shared/location-support.js";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
|
||||
@@ -50,6 +56,9 @@ export type HiringCafeProgressEvent =
|
||||
export interface RunHiringCafeOptions {
|
||||
searchTerms?: string[];
|
||||
country?: string;
|
||||
countryKey?: string;
|
||||
locations?: string[];
|
||||
locationRadiusMiles?: number;
|
||||
maxJobsPerTerm?: number;
|
||||
onProgress?: (event: HiringCafeProgressEvent) => void;
|
||||
}
|
||||
@@ -60,6 +69,27 @@ export interface HiringCafeResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryKey: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryKey);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunHiringCafeOptions): string[] {
|
||||
const raw = options.locations?.length
|
||||
? options.locations
|
||||
: parseSearchCitiesSetting(process.env.HIRING_CAFE_LOCATION_QUERY ?? "");
|
||||
return raw.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveTsxCliPath(): string | null {
|
||||
try {
|
||||
return require.resolve("tsx/dist/cli.mjs");
|
||||
@@ -182,7 +212,15 @@ export async function runHiringCafe(
|
||||
? options.searchTerms
|
||||
: ["web developer"];
|
||||
const country = (options.country || "united kingdom").trim().toLowerCase();
|
||||
const countryKey = normalizeCountryKey(options.countryKey ?? "");
|
||||
const maxJobsPerTerm = options.maxJobsPerTerm ?? 200;
|
||||
const locationRadiusMiles = Math.max(
|
||||
1,
|
||||
Math.floor(options.locationRadiusMiles ?? 1),
|
||||
);
|
||||
const locations = resolveLocations(options);
|
||||
const runLocations = locations.length > 0 ? locations : [null];
|
||||
const termTotal = searchTerms.length * runLocations.length;
|
||||
|
||||
const useNpmCommand = canRunNpmCommand();
|
||||
if (!useNpmCommand && !TSX_CLI_PATH) {
|
||||
@@ -194,70 +232,102 @@ export async function runHiringCafe(
|
||||
}
|
||||
|
||||
try {
|
||||
await clearStorageDataset();
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const extractorEnv = {
|
||||
...process.env,
|
||||
JOBOPS_EMIT_PROGRESS: "1",
|
||||
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||
HIRING_CAFE_COUNTRY: country,
|
||||
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
|
||||
};
|
||||
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
|
||||
const location = runLocations[runIndex];
|
||||
const strictLocationFilter =
|
||||
location !== null &&
|
||||
shouldApplyStrictLocationFilter(location, countryKey);
|
||||
|
||||
const child = useNpmCommand
|
||||
? spawn("npm", ["run", "start"], {
|
||||
cwd: HIRING_CAFE_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
})
|
||||
: (() => {
|
||||
const tsxCliPath = TSX_CLI_PATH;
|
||||
if (!tsxCliPath) {
|
||||
throw new Error(
|
||||
"Unable to execute Hiring Cafe extractor (npm/tsx unavailable)",
|
||||
);
|
||||
}
|
||||
await clearStorageDataset();
|
||||
|
||||
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const extractorEnv = {
|
||||
...process.env,
|
||||
JOBOPS_EMIT_PROGRESS: "1",
|
||||
HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms),
|
||||
HIRING_CAFE_COUNTRY: country,
|
||||
HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm),
|
||||
HIRING_CAFE_OUTPUT_JSON: DATASET_PATH,
|
||||
HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "",
|
||||
HIRING_CAFE_LOCATION_RADIUS_MILES: strictLocationFilter
|
||||
? String(locationRadiusMiles)
|
||||
: "",
|
||||
};
|
||||
|
||||
const child = useNpmCommand
|
||||
? spawn("npm", ["run", "start"], {
|
||||
cwd: HIRING_CAFE_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
})
|
||||
: (() => {
|
||||
const tsxCliPath = TSX_CLI_PATH;
|
||||
if (!tsxCliPath) {
|
||||
throw new Error(
|
||||
"Unable to execute Hiring Cafe extractor (npm/tsx unavailable)",
|
||||
);
|
||||
}
|
||||
|
||||
return spawn(process.execPath, [tsxCliPath, "src/main.ts"], {
|
||||
cwd: HIRING_CAFE_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: extractorEnv,
|
||||
});
|
||||
})();
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseProgressLine(line);
|
||||
if (progressEvent) {
|
||||
const termOffset = runIndex * searchTerms.length;
|
||||
options.onProgress?.({
|
||||
...progressEvent,
|
||||
termIndex: termOffset + progressEvent.termIndex,
|
||||
termTotal,
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const progressEvent = parseProgressLine(line);
|
||||
if (progressEvent) {
|
||||
options.onProgress?.(progressEvent);
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(new Error(`Hiring Cafe extractor exited with code ${code}`));
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(new Error(`Hiring Cafe extractor exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const jobs = await readDataset();
|
||||
const runJobs = await readDataset();
|
||||
const filtered = strictLocationFilter
|
||||
? runJobs.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: runJobs;
|
||||
|
||||
for (const job of filtered) {
|
||||
const key = job.sourceJobId || job.jobUrl;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJobSpyProgressLine } from "./jobspy";
|
||||
import {
|
||||
matchesRequestedLocation,
|
||||
parseJobSpyProgressLine,
|
||||
shouldApplyStrictLocationFilter,
|
||||
} from "./jobspy";
|
||||
|
||||
describe("parseJobSpyProgressLine", () => {
|
||||
it("parses term_start progress lines", () => {
|
||||
@@ -38,3 +42,24 @@ describe("parseJobSpyProgressLine", () => {
|
||||
expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("strict location filtering", () => {
|
||||
it("enables strict filtering when location differs from country", () => {
|
||||
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables strict filtering when location is country-level", () => {
|
||||
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false);
|
||||
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches location using case-insensitive contains checks", () => {
|
||||
expect(matchesRequestedLocation("Leeds, England, UK", "leeds")).toBe(true);
|
||||
expect(matchesRequestedLocation("Halifax, England, UK", "leeds")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesRequestedLocation(undefined, "leeds")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ import { mkdir, readFile, unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
shouldApplyStrictCityFilter,
|
||||
} from "@shared/search-cities.js";
|
||||
import type { CreateJobInput, JobSource } from "@shared/types";
|
||||
import { toNumberOrNull, toStringOrNull } from "@shared/utils/type-conversion";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
@@ -144,6 +149,7 @@ export interface RunJobSpyOptions {
|
||||
sites?: Array<JobSource>;
|
||||
searchTerms?: string[];
|
||||
location?: string;
|
||||
locations?: string[];
|
||||
resultsWanted?: number;
|
||||
hoursOld?: number;
|
||||
countryIndeed?: string;
|
||||
@@ -158,6 +164,20 @@ export interface JobSpyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function shouldApplyStrictLocationFilter(
|
||||
location: string,
|
||||
countryIndeed: string,
|
||||
): boolean {
|
||||
return shouldApplyStrictCityFilter(location, countryIndeed);
|
||||
}
|
||||
|
||||
export function matchesRequestedLocation(
|
||||
jobLocation: string | undefined,
|
||||
requestedLocation: string,
|
||||
): boolean {
|
||||
return matchesRequestedCity(jobLocation, requestedLocation);
|
||||
}
|
||||
|
||||
export async function runJobSpy(
|
||||
options: RunJobSpyOptions = {},
|
||||
): Promise<JobSpyResult> {
|
||||
@@ -170,6 +190,9 @@ export async function runJobSpy(
|
||||
.join(",");
|
||||
|
||||
const searchTerms = resolveSearchTerms(options);
|
||||
const locations = resolveLocations(options);
|
||||
const countryIndeed =
|
||||
options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? "UK";
|
||||
if (searchTerms.length === 0) {
|
||||
return { success: true, jobs: [] };
|
||||
}
|
||||
@@ -178,93 +201,105 @@ export async function runJobSpy(
|
||||
const jobs: CreateJobInput[] = [];
|
||||
const seenJobUrls = new Set<string>();
|
||||
|
||||
for (let i = 0; i < searchTerms.length; i++) {
|
||||
const searchTerm = searchTerms[i];
|
||||
const suffix = `${i + 1}_${slugForFilename(searchTerm)}`;
|
||||
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
|
||||
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
|
||||
const totalRuns = searchTerms.length * locations.length;
|
||||
let runIndex = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
|
||||
cwd: JOBSPY_DIR,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
|
||||
JOBSPY_SEARCH_TERM: searchTerm,
|
||||
JOBSPY_TERM_INDEX: String(i + 1),
|
||||
JOBSPY_TERM_TOTAL: String(searchTerms.length),
|
||||
JOBSPY_LOCATION:
|
||||
options.location ?? process.env.JOBSPY_LOCATION ?? "UK",
|
||||
JOBSPY_RESULTS_WANTED: String(
|
||||
options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200,
|
||||
),
|
||||
JOBSPY_HOURS_OLD: String(
|
||||
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
|
||||
),
|
||||
JOBSPY_COUNTRY_INDEED:
|
||||
options.countryIndeed ??
|
||||
process.env.JOBSPY_COUNTRY_INDEED ??
|
||||
"UK",
|
||||
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
|
||||
options.linkedinFetchDescription ??
|
||||
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
|
||||
"1",
|
||||
),
|
||||
JOBSPY_IS_REMOTE: String(
|
||||
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0",
|
||||
),
|
||||
JOBSPY_OUTPUT_CSV: outputCsv,
|
||||
JOBSPY_OUTPUT_JSON: outputJson,
|
||||
},
|
||||
for (const searchTerm of searchTerms) {
|
||||
for (const location of locations) {
|
||||
runIndex += 1;
|
||||
const suffix = `${runIndex}_${slugForFilename(searchTerm)}_${slugForFilename(location)}`;
|
||||
const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`);
|
||||
const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const child = spawn(pythonPath, [JOBSPY_SCRIPT], {
|
||||
cwd: JOBSPY_DIR,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
JOBSPY_SITES: sites || "indeed,linkedin,glassdoor",
|
||||
JOBSPY_SEARCH_TERM: searchTerm,
|
||||
JOBSPY_TERM_INDEX: String(runIndex),
|
||||
JOBSPY_TERM_TOTAL: String(totalRuns),
|
||||
JOBSPY_LOCATION: location,
|
||||
JOBSPY_RESULTS_WANTED: String(
|
||||
options.resultsWanted ??
|
||||
process.env.JOBSPY_RESULTS_WANTED ??
|
||||
200,
|
||||
),
|
||||
JOBSPY_HOURS_OLD: String(
|
||||
options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72,
|
||||
),
|
||||
JOBSPY_COUNTRY_INDEED: countryIndeed,
|
||||
JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String(
|
||||
options.linkedinFetchDescription ??
|
||||
process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ??
|
||||
"1",
|
||||
),
|
||||
JOBSPY_IS_REMOTE: String(
|
||||
options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0",
|
||||
),
|
||||
JOBSPY_OUTPUT_CSV: outputCsv,
|
||||
JOBSPY_OUTPUT_JSON: outputJson,
|
||||
},
|
||||
});
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const event = parseJobSpyProgressLine(line);
|
||||
if (event) {
|
||||
options.onProgress?.(event);
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`JobSpy exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const handleLine = (line: string, stream: NodeJS.WriteStream) => {
|
||||
const event = parseJobSpyProgressLine(line);
|
||||
if (event) {
|
||||
options.onProgress?.(event);
|
||||
return;
|
||||
}
|
||||
stream.write(`${line}\n`);
|
||||
};
|
||||
const raw = await readFile(outputJson, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
const mapped = mapJobSpyRows(parsed);
|
||||
const strictLocationFilter = shouldApplyStrictLocationFilter(
|
||||
location,
|
||||
countryIndeed,
|
||||
);
|
||||
const filtered = strictLocationFilter
|
||||
? mapped.filter((job) =>
|
||||
matchesRequestedLocation(job.location, location),
|
||||
)
|
||||
: mapped;
|
||||
|
||||
const stdoutRl = child.stdout
|
||||
? createInterface({ input: child.stdout })
|
||||
: null;
|
||||
const stderrRl = child.stderr
|
||||
? createInterface({ input: child.stderr })
|
||||
: null;
|
||||
for (const job of filtered) {
|
||||
const url = job.jobUrl;
|
||||
if (seenJobUrls.has(url)) continue;
|
||||
seenJobUrls.add(url);
|
||||
jobs.push(job);
|
||||
}
|
||||
|
||||
stdoutRl?.on("line", (line) => handleLine(line, process.stdout));
|
||||
stderrRl?.on("line", (line) => handleLine(line, process.stderr));
|
||||
|
||||
child.on("close", (code) => {
|
||||
stdoutRl?.close();
|
||||
stderrRl?.close();
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`JobSpy exited with code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
const raw = await readFile(outputJson, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
const mapped = mapJobSpyRows(parsed);
|
||||
|
||||
for (const job of mapped) {
|
||||
const url = job.jobUrl;
|
||||
if (seenJobUrls.has(url)) continue;
|
||||
seenJobUrls.add(url);
|
||||
jobs.push(job);
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(outputJson);
|
||||
await unlink(outputCsv);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
try {
|
||||
await unlink(outputJson);
|
||||
await unlink(outputCsv);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +310,16 @@ export async function runJobSpy(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLocations(options: RunJobSpyOptions): string[] {
|
||||
const fromOptions = options.locations?.length ? options.locations : null;
|
||||
const fromSingle = options.location?.trim();
|
||||
const fromEnv = process.env.JOBSPY_LOCATION?.trim();
|
||||
const raw =
|
||||
fromOptions ?? parseSearchCitiesSetting(fromSingle ?? fromEnv ?? "UK");
|
||||
const out = raw.map((value) => value.trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : ["UK"];
|
||||
}
|
||||
|
||||
function resolveSearchTerms(options: RunJobSpyOptions): string[] {
|
||||
const fromOptions = options.searchTerms?.length ? options.searchTerms : null;
|
||||
const fromEnv = parseSearchTermsEnv(process.env.JOBSPY_SEARCH_TERMS);
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("settings-conversion", () => {
|
||||
|
||||
it("uses string defaults when override is empty", () => {
|
||||
process.env.JOBSPY_LOCATION = "Remote";
|
||||
const resolved = resolveSettingValue("jobspyLocation", "");
|
||||
const resolved = resolveSettingValue("searchCities", "");
|
||||
expect(resolved.defaultValue).toBe("Remote");
|
||||
expect(resolved.overrideValue).toBe("");
|
||||
expect(resolved.value).toBe("Remote");
|
||||
|
||||
@@ -10,7 +10,7 @@ type SettingsConversionValueMap = {
|
||||
adzunaMaxJobsPerTerm: number;
|
||||
gradcrackerMaxJobsPerTerm: number;
|
||||
searchTerms: string[];
|
||||
jobspyLocation: string;
|
||||
searchCities: string;
|
||||
jobspyResultsWanted: number;
|
||||
jobspyCountryIndeed: string;
|
||||
showSponsorInfo: boolean;
|
||||
@@ -124,8 +124,9 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
|
||||
serialize: serializeNullableJsonArray,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyLocation: {
|
||||
defaultValue: () => process.env.JOBSPY_LOCATION || "UK",
|
||||
searchCities: {
|
||||
defaultValue: () =>
|
||||
process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK",
|
||||
parseOverride: (raw) => raw ?? null,
|
||||
serialize: (value) => value ?? null,
|
||||
resolve: resolveWithEmptyStringFallback,
|
||||
|
||||
@@ -177,8 +177,12 @@ export const settingsUpdateRegistry: Partial<{
|
||||
searchTerms: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("searchTerms", value)] }),
|
||||
),
|
||||
searchCities: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("searchCities", value)] }),
|
||||
),
|
||||
// Deprecated legacy key; persist into canonical searchCities setting.
|
||||
jobspyLocation: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspyLocation", value)] }),
|
||||
result({ actions: [metadataPersistAction("searchCities", value)] }),
|
||||
),
|
||||
jobspyResultsWanted: singleAction(({ value }) =>
|
||||
result({
|
||||
|
||||
@@ -123,13 +123,13 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrideSearchTerms = searchTermsSetting.overrideValue;
|
||||
const searchTerms = searchTermsSetting.value;
|
||||
|
||||
const jobspyLocationSetting = resolveSettingValue(
|
||||
"jobspyLocation",
|
||||
overrides.jobspyLocation,
|
||||
const searchCitiesSetting = resolveSettingValue(
|
||||
"searchCities",
|
||||
overrides.searchCities ?? overrides.jobspyLocation,
|
||||
);
|
||||
const defaultJobspyLocation = jobspyLocationSetting.defaultValue;
|
||||
const overrideJobspyLocation = jobspyLocationSetting.overrideValue;
|
||||
const jobspyLocation = jobspyLocationSetting.value;
|
||||
const defaultSearchCities = searchCitiesSetting.defaultValue;
|
||||
const overrideSearchCities = searchCitiesSetting.overrideValue;
|
||||
const searchCities = searchCitiesSetting.value;
|
||||
|
||||
const jobspyResultsWantedSetting = resolveSettingValue(
|
||||
"jobspyResultsWanted",
|
||||
@@ -278,9 +278,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
searchTerms,
|
||||
defaultSearchTerms,
|
||||
overrideSearchTerms,
|
||||
jobspyLocation,
|
||||
defaultJobspyLocation,
|
||||
overrideJobspyLocation,
|
||||
searchCities,
|
||||
defaultSearchCities,
|
||||
overrideSearchCities,
|
||||
jobspyResultsWanted,
|
||||
defaultJobspyResultsWanted,
|
||||
overrideJobspyResultsWanted,
|
||||
|
||||
Reference in New Issue
Block a user