diff --git a/docs-site/docs/extractors/hiring-cafe.md b/docs-site/docs/extractors/hiring-cafe.md index 99c7e31..8122064 100644 --- a/docs-site/docs/extractors/hiring-cafe.md +++ b/docs-site/docs/extractors/hiring-cafe.md @@ -33,6 +33,7 @@ It also supports term-by-term search and country-aware search state using the sa - selected country maps into Hiring Cafe location search state. - run budget path (`jobspyResultsWanted`) is reused as the max jobs-per-term cap. - optional **Search cities** narrow results by city. + - workplace type is forwarded from the automatic run modal as a global run filter. 4. Start the run and watch progress in the pipeline progress card. Defaults and constraints: @@ -42,6 +43,7 @@ Defaults and constraints: - Hiring Cafe is enabled by default in source selection. - `HIRING_CAFE_DATE_FETCHED_PAST_N_DAYS` controls recency window when running extractor directly (default `7`). - When a city is provided via `searchCities`, Hiring Cafe uses city radius search (default `1` mile) and strict city post-filtering. +- Workplace type is global to the run and is not configured separately per city in this integration. - City geocoding is resolved through Nominatim (OpenStreetMap data); if you scale extractor traffic, add attribution and cache repeated city lookups. Local run example: diff --git a/docs-site/docs/extractors/jobspy.md b/docs-site/docs/extractors/jobspy.md index 9d55e91..25832c6 100644 --- a/docs-site/docs/extractors/jobspy.md +++ b/docs-site/docs/extractors/jobspy.md @@ -27,6 +27,7 @@ Key environment variables: - `JOBSPY_HOURS_OLD` (default: `72`) - `JOBSPY_COUNTRY_INDEED` (default: `UK`) - `JOBSPY_LINKEDIN_FETCH_DESCRIPTION` (default: `true`) +- `JOBSPY_IS_REMOTE` (unset by default) ## 2) Orchestrator flow @@ -50,3 +51,14 @@ The service in `orchestrator/src/server/services/jobspy.ts`: - `JOBSPY_SEARCH_TERMS` can be JSON array or `|`, comma, newline-delimited text. - Set `JOBSPY_LINKEDIN_FETCH_DESCRIPTION=0` to speed runs. - Temp output files are stored under `data/imports/`. +- If workplace type is only `Remote`, JobSpy runs with `JOBSPY_IS_REMOTE=true`. +- If workplace type includes `Hybrid` or `Onsite`, JobSpy cannot enforce those filters precisely, so the JobSpy-backed sources run without a workplace-type filter and may return broader results. + +## Common Problems + +- `Hybrid` or `Onsite` was selected, but Indeed, LinkedIn, or Glassdoor still returned remote jobs. + JobSpy only supports a strict remote toggle. Any workplace-type selection that includes `Hybrid` or `Onsite` broadens those source results. +- A run returned fewer LinkedIn descriptions than expected. + `JOBSPY_LINKEDIN_FETCH_DESCRIPTION=0` disables description fetching to speed up runs. +- Different cities need different workplace-type filters. + This is not supported in the current automatic-run flow. JobSpy receives one global workplace-type selection per run/query invocation. \ No newline at end of file diff --git a/docs-site/docs/extractors/startup-jobs.md b/docs-site/docs/extractors/startup-jobs.md index 03bc925..a837957 100644 --- a/docs-site/docs/extractors/startup-jobs.md +++ b/docs-site/docs/extractors/startup-jobs.md @@ -29,6 +29,7 @@ Using the published package also keeps the integration small and makes it easier 3. Set your usual automatic run controls: - `searchTerms` are sent as `query`. - country or city filters are reused as the package `location` option. + - workplace type is passed through as the package `workplaceType` option. - run budget path (`jobspyResultsWanted`) is reused as `requestedCount` per term. 4. Start the run and monitor progress in the pipeline progress card. @@ -38,6 +39,7 @@ Defaults and constraints: - The integration runs with `enrichDetails: true`, so it opens job detail pages for richer records. - Browser binaries are not downloaded automatically with the package. Install them with `npx playwright install` before using this extractor in a fresh environment. - When **Search cities** is set, the extractor runs once per city and once per search term. +- Workplace type is a global run filter, not a per-city override. - Without explicit cities, the selected country is used as the location filter except for broad modes such as `worldwide` and `usa/ca`. ## Common problems diff --git a/docs-site/docs/features/pipeline-run.md b/docs-site/docs/features/pipeline-run.md index b6f028e..a150730 100644 --- a/docs-site/docs/features/pipeline-run.md +++ b/docs-site/docs/features/pipeline-run.md @@ -60,6 +60,16 @@ Incompatible sources are disabled with explanatory tooltips. - **Min suitability score** - **Max jobs discovered** (run budget cap) - **Search cities** (optional multi-city input; required for Glassdoor) +- **Workplace type** (`Remote`, `Hybrid`, `Onsite`) + +Workplace type applies globally to the run across all search terms and locations. + +Source behavior differs: + +- Hiring Cafe and startup.jobs support all three workplace types directly. +- Indeed, LinkedIn, and Glassdoor are backed by JobSpy and only support strict remote filtering. +- If workplace type is set to `Remote` only, JobSpy runs with a remote-only filter. +- If `Hybrid` or `Onsite` is included, JobSpy sources remain enabled but may return broader results. #### Search terms @@ -110,6 +120,12 @@ For accepted input formats, inference behavior, and limits, see [Manual Import E - Use `Fast` preset or lower `Max jobs discovered`. - Disable high-cost source combinations where acceptable. +### JobSpy results are broader than the selected workplace type + +- Indeed, LinkedIn, and Glassdoor only support strict remote filtering in this flow. +- Use `Remote` only when you need JobSpy sources filtered tightly. +- Hybrid or onsite selections are honored by Hiring Cafe and startup.jobs, but JobSpy-backed sources may still include broader results. + ## Related pages - [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow) diff --git a/extractors/hiringcafe/manifest.ts b/extractors/hiringcafe/manifest.ts index c5aa431..44aceaa 100644 --- a/extractors/hiringcafe/manifest.ts +++ b/extractors/hiringcafe/manifest.ts @@ -68,6 +68,9 @@ export const manifest: ExtractorManifest = { single: context.settings.searchCities ?? context.settings.jobspyLocation, }), + workplaceTypes: context.settings.workplaceTypes + ? JSON.parse(context.settings.workplaceTypes) + : undefined, maxJobsPerTerm, onProgress: (event) => { if (context.shouldCancel?.()) return; diff --git a/extractors/hiringcafe/src/default-search-state.ts b/extractors/hiringcafe/src/default-search-state.ts index 77fe33e..b71b410 100644 --- a/extractors/hiringcafe/src/default-search-state.ts +++ b/extractors/hiringcafe/src/default-search-state.ts @@ -35,10 +35,11 @@ export function createDefaultSearchState(args: { searchQuery: string; location: HiringCafeCountryLocation | null; dateFetchedPastNDays: number; + workplaceTypes?: Array<"Remote" | "Hybrid" | "Onsite">; }): HiringCafeSearchState { return { locations: args.location ? [args.location] : [], - workplaceTypes: ["Remote", "Hybrid", "Onsite"], + workplaceTypes: args.workplaceTypes ?? ["Remote", "Hybrid", "Onsite"], defaultToUserLocation: false, userLocation: null, commitmentTypes: [ diff --git a/extractors/hiringcafe/src/main.ts b/extractors/hiringcafe/src/main.ts index 30cad2c..3721f34 100644 --- a/extractors/hiringcafe/src/main.ts +++ b/extractors/hiringcafe/src/main.ts @@ -68,6 +68,8 @@ interface NominatimResult { address?: Record; } +type HiringCafeWorkplaceType = "Remote" | "Hybrid" | "Onsite"; + function emitProgress(payload: Record): void { if (process.env.JOBOPS_EMIT_PROGRESS !== "1") return; console.log(`${JOBOPS_PROGRESS_PREFIX}${JSON.stringify(payload)}`); @@ -79,6 +81,36 @@ function parsePositiveInt(input: string | undefined, fallback: number): number { return parsed; } +function parseWorkplaceTypes( + raw: string | undefined, +): HiringCafeWorkplaceType[] { + if (!raw) return ["Remote", "Hybrid", "Onsite"]; + + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return ["Remote", "Hybrid", "Onsite"]; + + const out: HiringCafeWorkplaceType[] = []; + const seen = new Set(); + for (const value of parsed) { + const mapped = + value === "remote" + ? "Remote" + : value === "hybrid" + ? "Hybrid" + : value === "onsite" + ? "Onsite" + : null; + if (!mapped || seen.has(mapped)) continue; + seen.add(mapped); + out.push(mapped); + } + return out.length > 0 ? out : ["Remote", "Hybrid", "Onsite"]; + } catch { + return ["Remote", "Hybrid", "Onsite"]; + } +} + function encodeSearchState(searchState: unknown): string { const json = JSON.stringify(searchState); const urlEncodedJson = encodeURIComponent(json); @@ -301,6 +333,7 @@ function createCitySearchState(args: { searchQuery: string; dateFetchedPastNDays: number; context: CityLocationContext; + workplaceTypes: HiringCafeWorkplaceType[]; }): Record { return { locations: [ @@ -340,7 +373,7 @@ function createCitySearchState(args: { }, }, ], - workplaceTypes: ["Remote", "Hybrid", "Onsite"], + workplaceTypes: args.workplaceTypes, defaultToUserLocation: true, userLocation: null, physicalEnvironments: [ @@ -553,6 +586,9 @@ async function run(): Promise { process.env.HIRING_CAFE_OUTPUT_JSON || join(__dirname, "../storage/datasets/default/jobs.json"); const headless = process.env.HIRING_CAFE_HEADLESS !== "false"; + const workplaceTypes = parseWorkplaceTypes( + process.env.HIRING_CAFE_WORKPLACE_TYPES, + ); let browser = await firefox.launch( await launchOptions({ @@ -620,11 +656,13 @@ async function run(): Promise { searchQuery: searchTerm, dateFetchedPastNDays, context: cityLocationContext, + workplaceTypes, }) : createDefaultSearchState({ searchQuery: searchTerm, location: countryLocation, dateFetchedPastNDays, + workplaceTypes, }); const encodedSearchState = encodeSearchState(searchState); diff --git a/extractors/hiringcafe/src/run.ts b/extractors/hiringcafe/src/run.ts index 8c6404a..04df97e 100644 --- a/extractors/hiringcafe/src/run.ts +++ b/extractors/hiringcafe/src/run.ts @@ -54,6 +54,7 @@ export interface RunHiringCafeOptions { country?: string; countryKey?: string; locations?: string[]; + workplaceTypes?: Array<"remote" | "hybrid" | "onsite">; locationRadiusMiles?: number; maxJobsPerTerm?: number; onProgress?: (event: HiringCafeProgressEvent) => void; @@ -216,6 +217,9 @@ export async function runHiringCafe( JOBOPS_EMIT_PROGRESS: "1", HIRING_CAFE_SEARCH_TERMS: JSON.stringify(searchTerms), HIRING_CAFE_COUNTRY: country, + HIRING_CAFE_WORKPLACE_TYPES: JSON.stringify( + options.workplaceTypes ?? ["remote", "hybrid", "onsite"], + ), HIRING_CAFE_MAX_JOBS_PER_TERM: String(maxJobsPerTerm), HIRING_CAFE_OUTPUT_JSON: DATASET_PATH, HIRING_CAFE_LOCATION_QUERY: strictLocationFilter ? location : "", diff --git a/extractors/jobspy/manifest.ts b/extractors/jobspy/manifest.ts index 79b9fce..f2eea14 100644 --- a/extractors/jobspy/manifest.ts +++ b/extractors/jobspy/manifest.ts @@ -32,6 +32,9 @@ export const manifest: ExtractorManifest = { ? parseInt(context.settings.jobspyResultsWanted, 10) : undefined, countryIndeed: context.settings.jobspyCountryIndeed, + workplaceTypes: context.settings.workplaceTypes + ? JSON.parse(context.settings.workplaceTypes) + : undefined, onProgress: (event) => { if (context.shouldCancel?.()) return; diff --git a/extractors/jobspy/src/run.ts b/extractors/jobspy/src/run.ts index 3344bec..7bd6eac 100644 --- a/extractors/jobspy/src/run.ts +++ b/extractors/jobspy/src/run.ts @@ -128,6 +128,7 @@ export interface RunJobSpyOptions { searchTerms?: string[]; location?: string; locations?: string[]; + workplaceTypes?: Array<"remote" | "hybrid" | "onsite">; resultsWanted?: number; hoursOld?: number; countryIndeed?: string; @@ -142,6 +143,14 @@ export interface JobSpyResult { error?: string; } +export function deriveIsRemoteFlag( + workplaceTypes: Array<"remote" | "hybrid" | "onsite"> | undefined, +): boolean | undefined { + return workplaceTypes?.length === 1 && workplaceTypes[0] === "remote" + ? true + : undefined; +} + export async function runJobSpy( options: RunJobSpyOptions = {}, ): Promise { @@ -224,7 +233,10 @@ export async function runJobSpy( "1", ), JOBSPY_IS_REMOTE: String( - options.isRemote ?? process.env.JOBSPY_IS_REMOTE ?? "0", + options.isRemote ?? + deriveIsRemoteFlag(options.workplaceTypes) ?? + process.env.JOBSPY_IS_REMOTE ?? + "0", ), JOBSPY_OUTPUT_CSV: outputCsv, JOBSPY_OUTPUT_JSON: outputJson, diff --git a/extractors/jobspy/tests/run.test.ts b/extractors/jobspy/tests/run.test.ts index 48a3b66..0ade945 100644 --- a/extractors/jobspy/tests/run.test.ts +++ b/extractors/jobspy/tests/run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseJobSpyProgressLine } from "../src/run"; +import { deriveIsRemoteFlag, parseJobSpyProgressLine } from "../src/run"; describe("parseJobSpyProgressLine", () => { it("parses term_start progress lines", () => { @@ -37,4 +37,15 @@ describe("parseJobSpyProgressLine", () => { it("returns null for non-progress lines", () => { expect(parseJobSpyProgressLine("Found 20 jobs")).toBeNull(); }); + + it("maps remote-only workplace types to isRemote", () => { + expect(deriveIsRemoteFlag(["remote"])).toBe(true); + }); + + it("does not force JobSpy remote filtering for hybrid or onsite selections", () => { + expect(deriveIsRemoteFlag(["hybrid"])).toBeUndefined(); + expect(deriveIsRemoteFlag(["onsite"])).toBeUndefined(); + expect(deriveIsRemoteFlag(["remote", "hybrid"])).toBeUndefined(); + expect(deriveIsRemoteFlag(["remote", "hybrid", "onsite"])).toBeUndefined(); + }); }); diff --git a/extractors/startupjobs/src/manifest.ts b/extractors/startupjobs/src/manifest.ts index 16b9328..cf4f606 100644 --- a/extractors/startupjobs/src/manifest.ts +++ b/extractors/startupjobs/src/manifest.ts @@ -63,6 +63,9 @@ export const manifest: ExtractorManifest = { single: context.settings.searchCities ?? context.settings.jobspyLocation, }), + workplaceTypes: context.settings.workplaceTypes + ? JSON.parse(context.settings.workplaceTypes) + : undefined, maxJobsPerTerm, shouldCancel: context.shouldCancel, onProgress: (event) => { diff --git a/extractors/startupjobs/src/run.ts b/extractors/startupjobs/src/run.ts index 6be97e0..7a0a12a 100644 --- a/extractors/startupjobs/src/run.ts +++ b/extractors/startupjobs/src/run.ts @@ -30,6 +30,7 @@ export interface RunStartupJobsOptions { searchTerms?: string[]; selectedCountry?: string; locations?: string[]; + workplaceTypes?: Array<"remote" | "hybrid" | "onsite">; maxJobsPerTerm?: number; onProgress?: (event: StartupJobsProgressEvent) => void; shouldCancel?: () => boolean; @@ -41,6 +42,18 @@ export interface StartupJobsResult { error?: string; } +type StartupJobsWorkplaceType = "remote" | "hybrid" | "on-site"; + +function mapWorkplaceTypes( + workplaceTypes: Array<"remote" | "hybrid" | "onsite"> | undefined, +): StartupJobsWorkplaceType[] | undefined { + if (!workplaceTypes || workplaceTypes.length === 0) return undefined; + + return workplaceTypes.map((workplaceType) => + workplaceType === "onsite" ? "on-site" : workplaceType, + ); +} + function toPositiveIntOrFallback( value: number | string | undefined, fallback: number, @@ -123,6 +136,7 @@ export async function runStartupJobs( locations: options.locations, }); const maxJobsPerTerm = toPositiveIntOrFallback(options.maxJobsPerTerm, 50); + const workplaceType = mapWorkplaceTypes(options.workplaceTypes); const termTotal = searchTerms.length * runLocations.length; const jobs: CreateJobInput[] = []; const seen = new Set(); @@ -149,6 +163,7 @@ export async function runStartupJobs( requestedCount: maxJobsPerTerm, enrichDetails: true, location: location ?? undefined, + workplaceType, }); let jobsFoundTerm = 0; diff --git a/extractors/startupjobs/tests/manifest.test.ts b/extractors/startupjobs/tests/manifest.test.ts index 77ad5ed..c9e91b9 100644 --- a/extractors/startupjobs/tests/manifest.test.ts +++ b/extractors/startupjobs/tests/manifest.test.ts @@ -35,4 +35,30 @@ describe("startupjobs manifest", () => { }), ); }); + + it("forwards workplace types to the runner", async () => { + const { manifest } = await import("../src/manifest"); + const { runStartupJobs } = await import("../src/run"); + const runStartupJobsMock = vi.mocked(runStartupJobs); + runStartupJobsMock.mockResolvedValue({ + success: true, + jobs: [], + }); + + await manifest.run({ + source: "startupjobs", + selectedSources: ["startupjobs"], + settings: { + workplaceTypes: '["remote","onsite"]', + }, + searchTerms: ["software engineer"], + selectedCountry: "united kingdom", + }); + + expect(runStartupJobsMock).toHaveBeenCalledWith( + expect.objectContaining({ + workplaceTypes: ["remote", "onsite"], + }), + ); + }); }); diff --git a/extractors/startupjobs/tests/run.test.ts b/extractors/startupjobs/tests/run.test.ts index 3595d6a..32227f5 100644 --- a/extractors/startupjobs/tests/run.test.ts +++ b/extractors/startupjobs/tests/run.test.ts @@ -72,4 +72,46 @@ describe("runStartupJobs", () => { }), ); }); + + it("passes workplaceType to the scraper", async () => { + const { scrapeStartupJobsViaAlgolia } = await import( + "startup-jobs-scraper" + ); + const scrapeMock = vi.mocked(scrapeStartupJobsViaAlgolia); + scrapeMock.mockResolvedValueOnce([]); + + const { runStartupJobs } = await import("../src/run"); + + await runStartupJobs({ + searchTerms: ["software engineer"], + workplaceTypes: ["remote", "hybrid"], + }); + + expect(scrapeMock).toHaveBeenCalledWith( + expect.objectContaining({ + workplaceType: ["remote", "hybrid"], + }), + ); + }); + + it("maps onsite workplaceType to the scraper's on-site value", async () => { + const { scrapeStartupJobsViaAlgolia } = await import( + "startup-jobs-scraper" + ); + const scrapeMock = vi.mocked(scrapeStartupJobsViaAlgolia); + scrapeMock.mockResolvedValueOnce([]); + + const { runStartupJobs } = await import("../src/run"); + + await runStartupJobs({ + searchTerms: ["software engineer"], + workplaceTypes: ["onsite"], + }); + + expect(scrapeMock).toHaveBeenCalledWith( + expect.objectContaining({ + workplaceType: ["on-site"], + }), + ); + }); }); diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 86fb483..ca7aeeb 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -63,6 +63,7 @@ let mockAutomaticRunValues: AutomaticRunValues = { runBudget: 150, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }; const jobFixture = createJob({ @@ -402,6 +403,7 @@ describe("OrchestratorPage", () => { runBudget: 150, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }; }); @@ -749,6 +751,7 @@ describe("OrchestratorPage", () => { await waitFor(() => { expect(api.updateSettings).toHaveBeenCalledWith({ searchTerms: ["backend"], + workplaceTypes: ["remote", "hybrid", "onsite"], jobspyResultsWanted: 150, gradcrackerMaxJobsPerTerm: 150, ukvisajobsMaxJobs: 150, @@ -780,6 +783,7 @@ describe("OrchestratorPage", () => { runBudget: 150, country: "united kingdom", cityLocations: ["London", "Manchester"], + workplaceTypes: ["remote", "hybrid", "onsite"], }; render( @@ -814,6 +818,7 @@ describe("OrchestratorPage", () => { runBudget: 150, country: "united kingdom", cityLocations: ["Leeds", "Manchester"], + workplaceTypes: ["remote", "hybrid", "onsite"], }; render( @@ -848,6 +853,7 @@ describe("OrchestratorPage", () => { runBudget: 150, country: "united kingdom", cityLocations: ["Leeds", "Manchester"], + workplaceTypes: ["remote", "hybrid", "onsite"], }; render( @@ -954,6 +960,7 @@ describe("OrchestratorPage", () => { runBudget: 150, country: "united states", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }; render( diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx index c919a43..3b896e2 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx @@ -344,4 +344,117 @@ describe("AutomaticRunTab", () => { ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled(); }); + + it("loads saved workplace types from settings", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Advanced settings" })); + + expect(screen.getByLabelText("Remote")).toBeChecked(); + expect(screen.getByLabelText("Onsite")).toBeChecked(); + expect(screen.getByLabelText("Hybrid")).not.toBeChecked(); + }); + + it("requires at least one workplace type", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Advanced settings" })); + fireEvent.click(screen.getByLabelText("Remote")); + fireEvent.click(screen.getByLabelText("Hybrid")); + fireEvent.click(screen.getByLabelText("Onsite")); + + expect( + screen.getByText("Select at least one workplace type."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Start run now" }), + ).toBeDisabled(); + }); + + it("shows JobSpy guidance when non-remote workplace types are selected", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Advanced settings" })); + + expect( + screen.getByText( + /Indeed, LinkedIn, and Glassdoor only support strict remote filtering\./i, + ), + ).toBeInTheDocument(); + }); + + it("submits workplace types in onSaveAndRun values", async () => { + const onSaveAndRun = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Advanced settings" })); + fireEvent.click(screen.getByLabelText("Hybrid")); + fireEvent.click(screen.getByLabelText("Onsite")); + fireEvent.click(screen.getByRole("button", { name: "Start run now" })); + + await waitFor(() => { + expect(onSaveAndRun).toHaveBeenCalledWith( + expect.objectContaining({ + workplaceTypes: ["remote"], + }), + ); + }); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx index f352e1b..75ecd11 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SearchableDropdown } from "@/components/ui/searchable-dropdown"; @@ -35,10 +36,13 @@ import { type AutomaticRunValues, calculateAutomaticEstimate, loadAutomaticRunMemory, + normalizeWorkplaceTypes, parseCityLocationsInput, parseCityLocationsSetting, parseSearchTermsInput, saveAutomaticRunMemory, + WORKPLACE_TYPE_OPTIONS, + type WorkplaceType, } from "./automatic-run"; import { TokenizedInput } from "./TokenizedInput"; @@ -60,6 +64,7 @@ const DEFAULT_VALUES: AutomaticRunValues = { runBudget: 200, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }; interface AutomaticRunFormValues { @@ -69,6 +74,7 @@ interface AutomaticRunFormValues { country: string; cityLocations: string[]; cityLocationDraft: string; + workplaceTypes: WorkplaceType[]; searchTerms: string[]; searchTermDraft: string; } @@ -108,6 +114,11 @@ function toNumber(input: string, min: number, max: number, fallback: number) { return Math.min(max, Math.max(min, parsed)); } +function formatWorkplaceTypeLabel(workplaceType: WorkplaceType): string { + if (workplaceType === "onsite") return "Onsite"; + return workplaceType.charAt(0).toUpperCase() + workplaceType.slice(1); +} + function getPresetSelection(values: { topN: number; minSuitabilityScore: number; @@ -159,6 +170,7 @@ export const AutomaticRunTab: React.FC = ({ country: DEFAULT_VALUES.country, cityLocations: [], cityLocationDraft: "", + workplaceTypes: DEFAULT_VALUES.workplaceTypes, searchTerms: DEFAULT_VALUES.searchTerms, searchTermDraft: "", }, @@ -170,6 +182,7 @@ export const AutomaticRunTab: React.FC = ({ const countryInput = watch("country"); const cityLocations = watch("cityLocations"); const cityLocationDraft = watch("cityLocationDraft"); + const workplaceTypes = watch("workplaceTypes"); const searchTerms = watch("searchTerms"); const searchTermDraft = watch("searchTermDraft"); @@ -212,6 +225,9 @@ export const AutomaticRunTab: React.FC = ({ normalizeCountryKey(location) !== normalizeCountryKey(rememberedCountryKey), ); + const rememberedWorkplaceTypes = normalizeWorkplaceTypes( + settings?.workplaceTypes?.value, + ); reset({ topN: String(topN), @@ -220,6 +236,7 @@ export const AutomaticRunTab: React.FC = ({ country: rememberedCountry || DEFAULT_VALUES.country, cityLocations: rememberedLocations, cityLocationDraft: "", + workplaceTypes: rememberedWorkplaceTypes, searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms, searchTermDraft: "", }); @@ -239,6 +256,7 @@ export const AutomaticRunTab: React.FC = ({ runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget), country: normalizedCountry || DEFAULT_VALUES.country, cityLocations, + workplaceTypes: normalizeWorkplaceTypes(workplaceTypes), searchTerms, }; }, [ @@ -247,9 +265,12 @@ export const AutomaticRunTab: React.FC = ({ runBudgetInput, countryInput, cityLocations, + workplaceTypes, searchTerms, ]); + const workplaceTypeSelectionInvalid = workplaceTypes.length === 0; + const isSourceAvailableForRun = useCallback( (source: JobSource) => { if (!isSourceAllowedForCountry(source, values.country)) return false; @@ -270,6 +291,15 @@ export const AutomaticRunTab: React.FC = ({ [pipelineSources, isSourceAvailableForRun], ); + const hasOnlyRemoteWorkplaceType = + workplaceTypes.length === 1 && workplaceTypes[0] === "remote"; + const hasJobSpySourceSelected = compatiblePipelineSources.some( + (source) => + source === "indeed" || source === "linkedin" || source === "glassdoor", + ); + const showJobSpyWorkplaceWarning = + hasJobSpySourceSelected && !hasOnlyRemoteWorkplaceType; + useEffect(() => { const filtered = pipelineSources.filter((source) => isSourceAvailableForRun(source), @@ -307,7 +337,19 @@ export const AutomaticRunTab: React.FC = ({ isPipelineRunning || isSaving || compatiblePipelineSources.length === 0 || - values.searchTerms.length === 0; + values.searchTerms.length === 0 || + workplaceTypeSelectionInvalid; + + const toggleWorkplaceType = ( + workplaceType: WorkplaceType, + checked: boolean, + ) => { + const next = checked + ? normalizeWorkplaceTypes([...workplaceTypes, workplaceType]) + : workplaceTypes.filter((value) => value !== workplaceType); + + setValue("workplaceTypes", next, { shouldDirty: true }); + }; const applyPreset = (presetId: AutomaticPresetId) => { const preset = AUTOMATIC_PRESETS[presetId]; @@ -473,6 +515,55 @@ export const AutomaticRunTab: React.FC = ({ removeLabelPrefix="Remove city" /> +
+ +
+ {WORKPLACE_TYPE_OPTIONS.map((workplaceType) => { + const checkboxId = `workplace-type-${workplaceType}`; + const checked = + workplaceTypes.includes(workplaceType); + return ( +
+ { + toggleWorkplaceType( + workplaceType, + nextChecked === true, + ); + }} + /> + +
+ ); + })} +
+

+ Applies globally to all search terms and locations in + this run. +

+ {workplaceTypeSelectionInvalid ? ( +

+ Select at least one workplace type. +

+ ) : null} + {showJobSpyWorkplaceWarning ? ( +

+ Indeed, LinkedIn, and Glassdoor only support strict + remote filtering. Hybrid or Onsite selections will + broaden those source results. +

+ ) : null} +
diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts index 4a787a8..6ab2723 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.test.ts @@ -28,6 +28,7 @@ describe("automatic-run utilities", () => { runBudget: 100, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }, sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], }); @@ -72,6 +73,7 @@ describe("automatic-run utilities", () => { runBudget: 750, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }, sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"], }); @@ -99,6 +101,7 @@ describe("automatic-run utilities", () => { runBudget: 120, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }, sources: ["adzuna"], }); @@ -116,6 +119,7 @@ describe("automatic-run utilities", () => { runBudget: 120, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }, sources: ["hiringcafe"], }); @@ -133,6 +137,7 @@ describe("automatic-run utilities", () => { runBudget: 120, country: "united kingdom", cityLocations: [], + workplaceTypes: ["remote", "hybrid", "onsite"], }, sources: ["startupjobs"], }); diff --git a/orchestrator/src/client/pages/orchestrator/automatic-run.ts b/orchestrator/src/client/pages/orchestrator/automatic-run.ts index 9686ccd..9a8519a 100644 --- a/orchestrator/src/client/pages/orchestrator/automatic-run.ts +++ b/orchestrator/src/client/pages/orchestrator/automatic-run.ts @@ -5,6 +5,12 @@ import { import type { JobSource } from "@shared/types"; export type AutomaticPresetId = "fast" | "balanced" | "detailed"; +export type WorkplaceType = "remote" | "hybrid" | "onsite"; +export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [ + "remote", + "hybrid", + "onsite", +]; export interface AutomaticRunValues { topN: number; @@ -13,6 +19,7 @@ export interface AutomaticRunValues { runBudget: number; country: string; cityLocations: string[]; + workplaceTypes: WorkplaceType[]; } export interface AutomaticPresetValues { @@ -61,6 +68,22 @@ export interface AutomaticRunMemory { minSuitabilityScore: number; } +export function normalizeWorkplaceTypes( + workplaceTypes: WorkplaceType[] | null | undefined, +): WorkplaceType[] { + const seen = new Set(); + const out: WorkplaceType[] = []; + + for (const workplaceType of workplaceTypes ?? []) { + if (!WORKPLACE_TYPE_OPTIONS.includes(workplaceType)) continue; + if (seen.has(workplaceType)) continue; + seen.add(workplaceType); + out.push(workplaceType); + } + + return out.length > 0 ? out : [...WORKPLACE_TYPE_OPTIONS]; +} + export interface ExtractorLimits { jobspyResultsWanted: number; gradcrackerMaxJobsPerTerm: number; diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts index d87684c..16d7ec1 100644 --- a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts +++ b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts @@ -192,6 +192,7 @@ export function usePipelineControls( : formatCountryLabel(values.country); await api.updateSettings({ searchTerms: values.searchTerms, + workplaceTypes: values.workplaceTypes, jobspyResultsWanted: limits.jobspyResultsWanted, gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm, ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs, diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts index f81a8dd..3759361 100644 --- a/shared/src/settings-registry.test.ts +++ b/shared/src/settings-registry.test.ts @@ -87,6 +87,19 @@ describe("settingsRegistry helpers", () => { ); expect(settingsRegistry.searchTerms.serialize(null)).toBeNull(); }); + + it("parses valid workplace type arrays", () => { + expect( + settingsRegistry.workplaceTypes.parse('["remote","onsite"]'), + ).toEqual(["remote", "onsite"]); + }); + + it("rejects invalid workplace type arrays", () => { + expect( + settingsRegistry.workplaceTypes.parse('["remote","satellite"]'), + ).toBeNull(); + expect(settingsRegistry.workplaceTypes.parse("[]")).toBeNull(); + }); }); describe("Resume projects settings", () => { diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 544435a..2b0fc04 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -90,6 +90,35 @@ function createEnumParser( }; } +function createEnumArrayParser< + const TValues extends readonly [string, ...string[]], +>(values: TValues): (raw: string | undefined) => TValues[number][] | null { + const allowedValues = new Set(values); + + return (raw: string | undefined): TValues[number][] | null => { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + + const out: TValues[number][] = []; + const seen = new Set(); + for (const value of parsed) { + if (typeof value !== "string" || !allowedValues.has(value)) { + return null; + } + if (seen.has(value)) continue; + seen.add(value); + out.push(value as TValues[number]); + } + if (out.length === 0) return null; + return out; + } catch { + return null; + } + }; +} + const parseChatStyleLanguageModeOrNull = createEnumParser( CHAT_STYLE_LANGUAGE_MODE_VALUES, ); @@ -98,6 +127,9 @@ const parseChatStyleManualLanguageOrNull = createEnumParser( CHAT_STYLE_MANUAL_LANGUAGE_VALUES, ); +const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const; +const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES); + export const resumeProjectsSchema = z.object({ maxProjects: z.number().int().min(0).max(100), lockedProjectIds: z.array(z.string().trim().min(1)).max(200), @@ -271,6 +303,17 @@ export const settingsRegistry = { parse: parseJsonArrayOrNull, serialize: serializeNullableJsonArray, }, + workplaceTypes: { + kind: "typed" as const, + schema: z.array(z.enum(WORKPLACE_TYPE_VALUES)).min(1).max(3), + default: (): Array<(typeof WORKPLACE_TYPE_VALUES)[number]> => [ + "remote", + "hybrid", + "onsite", + ], + parse: parseWorkplaceTypesOrNull, + serialize: serializeNullableJsonArray, + }, blockedCompanyKeywords: { kind: "typed" as const, schema: z.array(z.string().trim().min(1).max(200)).max(200), diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 87edeff..380f06f 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -159,6 +159,11 @@ export const createAppSettings = ( default: ["Software Engineer"], override: null, }, + workplaceTypes: { + value: ["remote", "hybrid", "onsite"], + default: ["remote", "hybrid", "onsite"], + override: null, + }, blockedCompanyKeywords: { value: [], default: [], diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 438762b..af89c81 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -155,6 +155,7 @@ export interface AppSettings { gradcrackerMaxJobsPerTerm: Resolved; startupjobsMaxJobsPerTerm: Resolved; searchTerms: Resolved; + workplaceTypes: Resolved>; blockedCompanyKeywords: Resolved; scoringInstructions: Resolved; searchCities: Resolved;