feat: add support for indicating workplaceTypes (#296)
This commit is contained in:
parent
8274ec4e14
commit
0b22c08d7d
@ -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:
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 : "",
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user