feat: add support for indicating workplaceTypes (#296)

This commit is contained in:
Ryan Foote 2026-03-21 16:43:43 -04:00 committed by GitHub
parent 8274ec4e14
commit 0b22c08d7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 497 additions and 5 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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;

View File

@ -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: [

View File

@ -68,6 +68,8 @@ interface NominatimResult {
address?: Record<string, unknown>;
}
type HiringCafeWorkplaceType = "Remote" | "Hybrid" | "Onsite";
function emitProgress(payload: Record<string, unknown>): 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<HiringCafeWorkplaceType>();
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<string, unknown> {
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<void> {
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<void> {
searchQuery: searchTerm,
dateFetchedPastNDays,
context: cityLocationContext,
workplaceTypes,
})
: createDefaultSearchState({
searchQuery: searchTerm,
location: countryLocation,
dateFetchedPastNDays,
workplaceTypes,
});
const encodedSearchState = encodeSearchState(searchState);

View File

@ -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 : "",

View File

@ -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;

View File

@ -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<JobSpyResult> {
@ -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,

View File

@ -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();
});
});

View File

@ -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) => {

View File

@ -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<string>();
@ -149,6 +163,7 @@ export async function runStartupJobs(
requestedCount: maxJobsPerTerm,
enrichDetails: true,
location: location ?? undefined,
workplaceType,
});
let jobsFoundTerm = 0;

View File

@ -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"],
}),
);
});
});

View File

@ -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"],
}),
);
});
});

View File

@ -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(

View File

@ -344,4 +344,117 @@ describe("AutomaticRunTab", () => {
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
});
it("loads saved workplace types from settings", () => {
render(
<AutomaticRunTab
open
settings={createAppSettings({
workplaceTypes: {
value: ["remote", "onsite"],
default: ["remote", "hybrid", "onsite"],
override: ["remote", "onsite"],
},
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
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(
<AutomaticRunTab
open
settings={createAppSettings()}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
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(
<AutomaticRunTab
open
settings={createAppSettings({
workplaceTypes: {
value: ["remote", "hybrid"],
default: ["remote", "hybrid", "onsite"],
override: ["remote", "hybrid"],
},
})}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
/>,
);
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(
<AutomaticRunTab
open
settings={createAppSettings()}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
onSetPipelineSources={vi.fn()}
isPipelineRunning={false}
onSaveAndRun={onSaveAndRun}
/>,
);
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"],
}),
);
});
});
});

View File

@ -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<AutomaticRunTabProps> = ({
country: DEFAULT_VALUES.country,
cityLocations: [],
cityLocationDraft: "",
workplaceTypes: DEFAULT_VALUES.workplaceTypes,
searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
},
@ -170,6 +182,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
normalizeCountryKey(location) !==
normalizeCountryKey(rememberedCountryKey),
);
const rememberedWorkplaceTypes = normalizeWorkplaceTypes(
settings?.workplaceTypes?.value,
);
reset({
topN: String(topN),
@ -220,6 +236,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
[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<AutomaticRunTabProps> = ({
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<AutomaticRunTabProps> = ({
removeLabelPrefix="Remove city"
/>
</div>
<div className="space-y-2 md:col-span-3">
<Label>Workplace type</Label>
<div className="flex flex-wrap gap-4">
{WORKPLACE_TYPE_OPTIONS.map((workplaceType) => {
const checkboxId = `workplace-type-${workplaceType}`;
const checked =
workplaceTypes.includes(workplaceType);
return (
<div
key={workplaceType}
className="flex items-center gap-2"
>
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(nextChecked) => {
toggleWorkplaceType(
workplaceType,
nextChecked === true,
);
}}
/>
<label
htmlFor={checkboxId}
className="cursor-pointer text-sm font-medium"
>
{formatWorkplaceTypeLabel(workplaceType)}
</label>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Applies globally to all search terms and locations in
this run.
</p>
{workplaceTypeSelectionInvalid ? (
<p className="text-xs text-destructive">
Select at least one workplace type.
</p>
) : null}
{showJobSpyWorkplaceWarning ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
Indeed, LinkedIn, and Glassdoor only support strict
remote filtering. Hybrid or Onsite selections will
broaden those source results.
</p>
) : null}
</div>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -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"],
});

View File

@ -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<WorkplaceType>();
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;

View File

@ -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,

View File

@ -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", () => {

View File

@ -90,6 +90,35 @@ function createEnumParser<const TValues extends readonly [string, ...string[]]>(
};
}
function createEnumArrayParser<
const TValues extends readonly [string, ...string[]],
>(values: TValues): (raw: string | undefined) => TValues[number][] | null {
const allowedValues = new Set<string>(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<string>();
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),

View File

@ -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: [],

View File

@ -155,6 +155,7 @@ export interface AppSettings {
gradcrackerMaxJobsPerTerm: Resolved<number>;
startupjobsMaxJobsPerTerm: Resolved<number>;
searchTerms: Resolved<string[]>;
workplaceTypes: Resolved<Array<"remote" | "hybrid" | "onsite">>;
blockedCompanyKeywords: Resolved<string[]>;
scoringInstructions: Resolved<string>;
searchCities: Resolved<string>;