modal to configure pipeline settings on pipeline runs (#99)

* feat(orchestrator): add unified run modal shell with Automatic/Manual tabs

* feat(orchestrator): implement Automatic tab presets, estimate, and save+run flow

* refactor(manual-import): reuse manual import flow inside unified run modal

* refactor(settings): move pipeline tuning out of settings page into run modal

* stage 5

* jobs per term simplified

* copy improvement

* pill input

* better UI

* style(orchestrator): align run settings inputs on one row

* style(orchestrator): remove hover and pointer affordance from term pills

* style(orchestrator): restore hover and pointer affordance for term pills

* style(orchestrator): make search term pill hover more prominent

* better hover

* refactor(orchestrator): use react-hook-form in automatic run panel

* formatting

* fix(orchestrator): resolve biome issues in automatic run modal

* better copy

* feat(orchestrator): auto-select custom preset on manual config changes

* remove badge

* feat(orchestrator): redesign automatic run panel with collapsible advanced settings

* refactor(orchestrator): move estimate summary to footer and dedupe sources

* style(orchestrator): separate search term input from term pills

* style(orchestrator): remove save preset action from automatic footer

* ux(orchestrator): make entire search term pill tap-to-remove

* remove badge

* remove badge

* fix(orchestrator): return zero estimate when search terms are empty
This commit is contained in:
Shaheer Sarfaraz 2026-02-07 21:48:44 +00:00 committed by GitHub
parent c1605065fd
commit 60788b0f6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1637 additions and 971 deletions

View File

@ -0,0 +1,563 @@
import * as api from "@client/api";
import type { ManualJobDraft } from "@shared/types.js";
import {
ArrowLeft,
ClipboardPaste,
Link,
Loader2,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
type ManualImportStep = "paste" | "loading" | "review";
type ManualJobDraftState = {
title: string;
employer: string;
jobUrl: string;
applicationLink: string;
location: string;
salary: string;
deadline: string;
jobDescription: string;
jobType: string;
jobLevel: string;
jobFunction: string;
disciplines: string;
degreeRequired: string;
starting: string;
};
const emptyDraft: ManualJobDraftState = {
title: "",
employer: "",
jobUrl: "",
applicationLink: "",
location: "",
salary: "",
deadline: "",
jobDescription: "",
jobType: "",
jobLevel: "",
jobFunction: "",
disciplines: "",
degreeRequired: "",
starting: "",
};
const normalizeDraft = (
draft?: ManualJobDraft | null,
jd?: string,
): ManualJobDraftState => ({
...emptyDraft,
title: draft?.title ?? "",
employer: draft?.employer ?? "",
jobUrl: draft?.jobUrl ?? "",
applicationLink: draft?.applicationLink ?? "",
location: draft?.location ?? "",
salary: draft?.salary ?? "",
deadline: draft?.deadline ?? "",
jobDescription: jd ?? draft?.jobDescription ?? "",
jobType: draft?.jobType ?? "",
jobLevel: draft?.jobLevel ?? "",
jobFunction: draft?.jobFunction ?? "",
disciplines: draft?.disciplines ?? "",
degreeRequired: draft?.degreeRequired ?? "",
starting: draft?.starting ?? "",
});
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
const clean = (value: string) => {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
return {
title: clean(draft.title),
employer: clean(draft.employer),
jobUrl: clean(draft.jobUrl),
applicationLink: clean(draft.applicationLink),
location: clean(draft.location),
salary: clean(draft.salary),
deadline: clean(draft.deadline),
jobDescription: clean(draft.jobDescription),
jobType: clean(draft.jobType),
jobLevel: clean(draft.jobLevel),
jobFunction: clean(draft.jobFunction),
disciplines: clean(draft.disciplines),
degreeRequired: clean(draft.degreeRequired),
starting: clean(draft.starting),
};
};
interface ManualImportFlowProps {
active: boolean;
onImported: (jobId: string) => void | Promise<void>;
onClose: () => void;
}
export const ManualImportFlow: React.FC<ManualImportFlowProps> = ({
active,
onImported,
onClose,
}) => {
const [step, setStep] = useState<ManualImportStep>("paste");
const [rawDescription, setRawDescription] = useState("");
const [fetchUrl, setFetchUrl] = useState("");
const [isFetching, setIsFetching] = useState(false);
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
if (active) return;
setStep("paste");
setRawDescription("");
setFetchUrl("");
setIsFetching(false);
setDraft(emptyDraft);
setWarning(null);
setError(null);
setIsImporting(false);
}, [active]);
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
const canFetch =
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
const canImport = useMemo(() => {
if (step !== "review") return false;
return (
draft.title.trim().length > 0 &&
draft.employer.trim().length > 0 &&
draft.jobDescription.trim().length > 0
);
}, [draft, step]);
const handleFetch = async () => {
if (!fetchUrl.trim()) return;
try {
setError(null);
setWarning(null);
setIsFetching(true);
const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() });
const fetchedContent = fetchResponse.content;
const fetchedUrl = fetchResponse.url;
setIsFetching(false);
setStep("loading");
const inferResponse = await api.inferManualJob({
jobDescription: fetchedContent,
});
const normalized = normalizeDraft(inferResponse.job);
if (!normalized.jobUrl) {
normalized.jobUrl = fetchedUrl;
}
setDraft(normalized);
setWarning(inferResponse.warning ?? null);
setStep("review");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch URL";
setError(message);
setIsFetching(false);
setStep("paste");
}
};
const handleAnalyze = async () => {
if (!rawDescription.trim()) {
setError("Paste a job description to continue.");
return;
}
try {
setError(null);
setWarning(null);
setStep("loading");
const response = await api.inferManualJob({
jobDescription: rawDescription,
});
const normalized = normalizeDraft(response.job, rawDescription.trim());
if (draft.jobUrl && !normalized.jobUrl) {
normalized.jobUrl = draft.jobUrl;
}
setDraft(normalized);
setWarning(response.warning ?? null);
setStep("review");
} catch (err) {
const message =
err instanceof Error
? err.message
: "Failed to analyze job description";
setError(message);
setStep("paste");
}
};
const handleImport = async () => {
if (!canImport) return;
try {
setIsImporting(true);
const payload = toPayload(draft);
const created = await api.importManualJob({ job: payload });
toast.success("Job imported", {
description: "The job is now in the discovered column.",
});
await onImported(created.id);
onClose();
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to import job";
toast.error(message);
} finally {
setIsImporting(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Step {stepIndex + 1} of 3</span>
<span>{stepLabel}</span>
</div>
<div className="h-1 rounded-full bg-muted/40">
<div
className="h-1 rounded-full bg-primary/60 transition-all"
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
/>
</div>
</div>
<Separator />
</div>
<div className="mt-4 flex-1 overflow-y-auto pr-1">
{step === "paste" && (
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="fetch-url"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job URL (optional)
</label>
<div className="flex gap-2">
<Input
id="fetch-url"
value={fetchUrl}
onChange={(event) => setFetchUrl(event.target.value)}
placeholder="https://example.com/job-posting"
className="flex-1"
onKeyDown={(event) => {
if (event.key === "Enter" && canFetch) {
event.preventDefault();
void handleFetch();
}
}}
/>
<Button
type="button"
variant="secondary"
disabled={isFetching}
className="gap-2 shrink-0"
onClick={async () => {
if (fetchUrl.trim()) {
await handleFetch();
} else {
try {
const text = await navigator.clipboard.readText();
if (text) setFetchUrl(text.trim());
} catch {
// Clipboard access denied
}
}
}}
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : fetchUrl.trim() ? (
<Link className="h-4 w-4" />
) : (
<ClipboardPaste className="h-4 w-4" />
)}
{isFetching
? "Fetching..."
: fetchUrl.trim()
? "Fetch"
: "Paste"}
</Button>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="raw-description"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job description
</label>
<Textarea
id="raw-description"
value={rawDescription}
onChange={(event) => setRawDescription(event.target.value)}
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
className="min-h-[200px] font-mono text-sm leading-relaxed"
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
<Button
onClick={
fetchUrl.trim()
? () => void handleFetch()
: () => void handleAnalyze()
}
disabled={isFetching || (!canFetch && !canAnalyze)}
className="w-full h-10 gap-2"
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isFetching ? "Fetching..." : "Analyze JD"}
</Button>
</div>
)}
{step === "loading" && (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="text-sm font-semibold">
Analyzing job description
</div>
<p className="text-xs text-muted-foreground max-w-xs">
Extracting title, company, location, and other details.
</p>
</div>
)}
{step === "review" && (
<div className="space-y-4 pb-4">
{warning && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{warning}
</div>
)}
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setStep("paste")}
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Edit JD
</Button>
<span className="text-[11px] text-muted-foreground">
Required: title, employer, description
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FieldInput
id="draft-title"
label="Title *"
value={draft.title}
onChange={(value) =>
setDraft((prev) => ({ ...prev, title: value }))
}
placeholder="e.g. Junior Backend Engineer"
/>
<FieldInput
id="draft-employer"
label="Employer *"
value={draft.employer}
onChange={(value) =>
setDraft((prev) => ({ ...prev, employer: value }))
}
placeholder="e.g. Acme Labs"
/>
<FieldInput
id="draft-location"
label="Location"
value={draft.location}
onChange={(value) =>
setDraft((prev) => ({ ...prev, location: value }))
}
placeholder="e.g. London, UK"
/>
<FieldInput
id="draft-salary"
label="Salary"
value={draft.salary}
onChange={(value) =>
setDraft((prev) => ({ ...prev, salary: value }))
}
placeholder="e.g. GBP 45k-55k"
/>
<FieldInput
id="draft-deadline"
label="Deadline"
value={draft.deadline}
onChange={(value) =>
setDraft((prev) => ({ ...prev, deadline: value }))
}
placeholder="e.g. 30 Sep 2025"
/>
<FieldInput
id="draft-jobType"
label="Job type"
value={draft.jobType}
onChange={(value) =>
setDraft((prev) => ({ ...prev, jobType: value }))
}
placeholder="e.g. Full-time"
/>
<FieldInput
id="draft-jobLevel"
label="Job level"
value={draft.jobLevel}
onChange={(value) =>
setDraft((prev) => ({ ...prev, jobLevel: value }))
}
placeholder="e.g. Graduate"
/>
<FieldInput
id="draft-jobFunction"
label="Job function"
value={draft.jobFunction}
onChange={(value) =>
setDraft((prev) => ({ ...prev, jobFunction: value }))
}
placeholder="e.g. Software Engineering"
/>
<FieldInput
id="draft-disciplines"
label="Disciplines"
value={draft.disciplines}
onChange={(value) =>
setDraft((prev) => ({ ...prev, disciplines: value }))
}
placeholder="e.g. Computer Science"
/>
<FieldInput
id="draft-degreeRequired"
label="Degree required"
value={draft.degreeRequired}
onChange={(value) =>
setDraft((prev) => ({ ...prev, degreeRequired: value }))
}
placeholder="e.g. BSc or MSc"
/>
<FieldInput
id="draft-starting"
label="Starting"
value={draft.starting}
onChange={(value) =>
setDraft((prev) => ({ ...prev, starting: value }))
}
placeholder="e.g. September 2026"
/>
<FieldInput
id="draft-jobUrl"
label="Job URL"
value={draft.jobUrl}
onChange={(value) =>
setDraft((prev) => ({ ...prev, jobUrl: value }))
}
placeholder="https://..."
/>
<FieldInput
id="draft-applicationLink"
label="Application URL"
value={draft.applicationLink}
onChange={(value) =>
setDraft((prev) => ({ ...prev, applicationLink: value }))
}
placeholder="https://..."
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-jobDescription"
className="text-xs font-medium text-muted-foreground"
>
Job description *
</label>
<Textarea
id="draft-jobDescription"
value={draft.jobDescription}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobDescription: event.target.value,
}))
}
placeholder="Paste the job description..."
className="min-h-[180px] font-mono text-sm leading-relaxed"
/>
</div>
<Button
onClick={() => void handleImport()}
disabled={!canImport || isImporting}
className="w-full h-10 gap-2"
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isImporting ? "Importing..." : "Import job"}
</Button>
</div>
)}
</div>
</div>
);
};
const FieldInput: React.FC<{
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}> = ({ id, label, value, onChange, placeholder }) => (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
</label>
<Input
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
);

View File

@ -2,21 +2,8 @@
* Manual job import flow (paste JD -> infer -> review -> import).
*/
import type { ManualJobDraft } from "@shared/types.js";
import {
ArrowLeft,
ClipboardPaste,
FileText,
Link,
Loader2,
Sparkles,
} from "lucide-react";
import { FileText } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
@ -24,90 +11,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import * as api from "../api";
type ManualImportStep = "paste" | "loading" | "review";
type ManualJobDraftState = {
title: string;
employer: string;
jobUrl: string;
applicationLink: string;
location: string;
salary: string;
deadline: string;
jobDescription: string;
jobType: string;
jobLevel: string;
jobFunction: string;
disciplines: string;
degreeRequired: string;
starting: string;
};
const emptyDraft: ManualJobDraftState = {
title: "",
employer: "",
jobUrl: "",
applicationLink: "",
location: "",
salary: "",
deadline: "",
jobDescription: "",
jobType: "",
jobLevel: "",
jobFunction: "",
disciplines: "",
degreeRequired: "",
starting: "",
};
const normalizeDraft = (
draft?: ManualJobDraft | null,
jd?: string,
): ManualJobDraftState => ({
...emptyDraft,
title: draft?.title ?? "",
employer: draft?.employer ?? "",
jobUrl: draft?.jobUrl ?? "",
applicationLink: draft?.applicationLink ?? "",
location: draft?.location ?? "",
salary: draft?.salary ?? "",
deadline: draft?.deadline ?? "",
jobDescription: jd ?? draft?.jobDescription ?? "",
jobType: draft?.jobType ?? "",
jobLevel: draft?.jobLevel ?? "",
jobFunction: draft?.jobFunction ?? "",
disciplines: draft?.disciplines ?? "",
degreeRequired: draft?.degreeRequired ?? "",
starting: draft?.starting ?? "",
});
const toPayload = (draft: ManualJobDraftState): ManualJobDraft => {
const clean = (value: string) => {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
return {
title: clean(draft.title),
employer: clean(draft.employer),
jobUrl: clean(draft.jobUrl),
applicationLink: clean(draft.applicationLink),
location: clean(draft.location),
salary: clean(draft.salary),
deadline: clean(draft.deadline),
jobDescription: clean(draft.jobDescription),
jobType: clean(draft.jobType),
jobLevel: clean(draft.jobLevel),
jobFunction: clean(draft.jobFunction),
disciplines: clean(draft.disciplines),
degreeRequired: clean(draft.degreeRequired),
starting: clean(draft.starting),
};
};
import { ManualImportFlow } from "./ManualImportFlow";
interface ManualImportSheetProps {
open: boolean;
@ -120,135 +24,6 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
onOpenChange,
onImported,
}) => {
const [step, setStep] = useState<ManualImportStep>("paste");
const [rawDescription, setRawDescription] = useState("");
const [fetchUrl, setFetchUrl] = useState("");
const [isFetching, setIsFetching] = useState(false);
const [draft, setDraft] = useState<ManualJobDraftState>(emptyDraft);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
if (!open) {
setStep("paste");
setRawDescription("");
setFetchUrl("");
setIsFetching(false);
setDraft(emptyDraft);
setWarning(null);
setError(null);
setIsImporting(false);
}
}, [open]);
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
const canFetch =
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
const canImport = useMemo(() => {
if (step !== "review") return false;
return (
draft.title.trim().length > 0 &&
draft.employer.trim().length > 0 &&
draft.jobDescription.trim().length > 0
);
}, [draft, step]);
const handleFetch = async () => {
if (!fetchUrl.trim()) return;
try {
setError(null);
setWarning(null);
setIsFetching(true);
// Fetch the URL content
const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() });
const fetchedContent = fetchResponse.content;
const fetchedUrl = fetchResponse.url;
setIsFetching(false);
// Automatically proceed to analysis
setStep("loading");
const inferResponse = await api.inferManualJob({
jobDescription: fetchedContent,
});
// Don't pass raw HTML as job description - let user fill it in or use inferred data
const normalized = normalizeDraft(inferResponse.job);
// Preserve the fetched URL
if (!normalized.jobUrl) {
normalized.jobUrl = fetchedUrl;
}
setDraft(normalized);
setWarning(inferResponse.warning ?? null);
setStep("review");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch URL";
setError(message);
setIsFetching(false);
setStep("paste");
}
};
const handleAnalyze = async () => {
if (!rawDescription.trim()) {
setError("Paste a job description to continue.");
return;
}
try {
setError(null);
setWarning(null);
setStep("loading");
const response = await api.inferManualJob({
jobDescription: rawDescription,
});
const normalized = normalizeDraft(response.job, rawDescription.trim());
// Preserve the fetched URL if we fetched from a URL
if (draft.jobUrl && !normalized.jobUrl) {
normalized.jobUrl = draft.jobUrl;
}
setDraft(normalized);
setWarning(response.warning ?? null);
setStep("review");
} catch (err) {
const message =
err instanceof Error
? err.message
: "Failed to analyze job description";
setError(message);
setStep("paste");
}
};
const handleImport = async () => {
if (!canImport) return;
try {
setIsImporting(true);
const payload = toPayload(draft);
const created = await api.importManualJob({ job: payload });
toast.success("Job imported", {
description: "The job is now in the discovered column.",
});
await onImported(created.id);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to import job";
toast.error(message);
} finally {
setIsImporting(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl overflow-hidden">
@ -266,444 +41,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</SheetDescription>
</SheetHeader>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Step {stepIndex + 1} of 3</span>
<span>{stepLabel}</span>
</div>
<div className="h-1 rounded-full bg-muted/40">
<div
className="h-1 rounded-full bg-primary/60 transition-all"
style={{ width: `${((stepIndex + 1) / 3) * 100}%` }}
/>
</div>
</div>
<Separator />
</div>
<div className="mt-4 flex-1 overflow-y-auto pr-1">
{step === "paste" && (
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor="fetch-url"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job URL (optional)
</label>
<div className="flex gap-2">
<Input
id="fetch-url"
value={fetchUrl}
onChange={(event) => setFetchUrl(event.target.value)}
placeholder="https://example.com/job-posting"
className="flex-1"
onKeyDown={(event) => {
if (event.key === "Enter" && canFetch) {
event.preventDefault();
handleFetch();
}
}}
/>
<Button
type="button"
variant="secondary"
disabled={isFetching}
className="gap-2 shrink-0"
onClick={async () => {
if (fetchUrl.trim()) {
handleFetch();
} else {
try {
const text = await navigator.clipboard.readText();
if (text) setFetchUrl(text.trim());
} catch {
// Clipboard access denied
}
}
}}
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : fetchUrl.trim() ? (
<Link className="h-4 w-4" />
) : (
<ClipboardPaste className="h-4 w-4" />
)}
{isFetching
? "Fetching..."
: fetchUrl.trim()
? "Fetch"
: "Paste"}
</Button>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="raw-description"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job description
</label>
<Textarea
id="raw-description"
value={rawDescription}
onChange={(event) => setRawDescription(event.target.value)}
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
className="min-h-[200px] font-mono text-sm leading-relaxed"
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
<Button
onClick={fetchUrl.trim() ? handleFetch : handleAnalyze}
disabled={isFetching || (!canFetch && !canAnalyze)}
className="w-full h-10 gap-2"
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isFetching ? "Fetching..." : "Analyze JD"}
</Button>
</div>
)}
{step === "loading" && (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="text-sm font-semibold">
Analyzing job description
</div>
<p className="text-xs text-muted-foreground max-w-xs">
Extracting title, company, location, and other details.
</p>
</div>
)}
{step === "review" && (
<div className="space-y-4 pb-4">
{warning && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{warning}
</div>
)}
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setStep("paste")}
className="gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Edit JD
</Button>
<span className="text-[11px] text-muted-foreground">
Required: title, employer, description
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label
htmlFor="draft-title"
className="text-xs font-medium text-muted-foreground"
>
Title *
</label>
<Input
id="draft-title"
value={draft.title}
onChange={(event) =>
setDraft((prev) => ({
...prev,
title: event.target.value,
}))
}
placeholder="e.g. Junior Backend Engineer"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-employer"
className="text-xs font-medium text-muted-foreground"
>
Employer *
</label>
<Input
id="draft-employer"
value={draft.employer}
onChange={(event) =>
setDraft((prev) => ({
...prev,
employer: event.target.value,
}))
}
placeholder="e.g. Acme Labs"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-location"
className="text-xs font-medium text-muted-foreground"
>
Location
</label>
<Input
id="draft-location"
value={draft.location}
onChange={(event) =>
setDraft((prev) => ({
...prev,
location: event.target.value,
}))
}
placeholder="e.g. London, UK"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-salary"
className="text-xs font-medium text-muted-foreground"
>
Salary
</label>
<Input
id="draft-salary"
value={draft.salary}
onChange={(event) =>
setDraft((prev) => ({
...prev,
salary: event.target.value,
}))
}
placeholder="e.g. GBP 45k-55k"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-deadline"
className="text-xs font-medium text-muted-foreground"
>
Deadline
</label>
<Input
id="draft-deadline"
value={draft.deadline}
onChange={(event) =>
setDraft((prev) => ({
...prev,
deadline: event.target.value,
}))
}
placeholder="e.g. 30 Sep 2025"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-jobType"
className="text-xs font-medium text-muted-foreground"
>
Job type
</label>
<Input
id="draft-jobType"
value={draft.jobType}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobType: event.target.value,
}))
}
placeholder="e.g. Full-time"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-jobLevel"
className="text-xs font-medium text-muted-foreground"
>
Job level
</label>
<Input
id="draft-jobLevel"
value={draft.jobLevel}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobLevel: event.target.value,
}))
}
placeholder="e.g. Graduate"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-jobFunction"
className="text-xs font-medium text-muted-foreground"
>
Job function
</label>
<Input
id="draft-jobFunction"
value={draft.jobFunction}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobFunction: event.target.value,
}))
}
placeholder="e.g. Software Engineering"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-disciplines"
className="text-xs font-medium text-muted-foreground"
>
Disciplines
</label>
<Input
id="draft-disciplines"
value={draft.disciplines}
onChange={(event) =>
setDraft((prev) => ({
...prev,
disciplines: event.target.value,
}))
}
placeholder="e.g. Computer Science"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-degreeRequired"
className="text-xs font-medium text-muted-foreground"
>
Degree required
</label>
<Input
id="draft-degreeRequired"
value={draft.degreeRequired}
onChange={(event) =>
setDraft((prev) => ({
...prev,
degreeRequired: event.target.value,
}))
}
placeholder="e.g. BSc or MSc"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-starting"
className="text-xs font-medium text-muted-foreground"
>
Starting
</label>
<Input
id="draft-starting"
value={draft.starting}
onChange={(event) =>
setDraft((prev) => ({
...prev,
starting: event.target.value,
}))
}
placeholder="e.g. Summer 2026"
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-jobUrl"
className="text-xs font-medium text-muted-foreground"
>
Job URL
</label>
<Input
id="draft-jobUrl"
value={draft.jobUrl}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="space-y-1">
<label
htmlFor="draft-applicationLink"
className="text-xs font-medium text-muted-foreground"
>
Application link
</label>
<Input
id="draft-applicationLink"
value={draft.applicationLink}
onChange={(event) =>
setDraft((prev) => ({
...prev,
applicationLink: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="draft-jobDescription"
className="text-xs font-medium text-muted-foreground"
>
Job description *
</label>
<Textarea
id="draft-jobDescription"
value={draft.jobDescription}
onChange={(event) =>
setDraft((prev) => ({
...prev,
jobDescription: event.target.value,
}))
}
className="min-h-[200px] font-mono text-sm leading-relaxed"
placeholder="Paste the job description..."
/>
</div>
<div className="space-y-3 pt-2">
<Separator />
<Button
onClick={handleImport}
disabled={!canImport || isImporting}
className={cn(
"w-full h-10 gap-2",
!canImport && "opacity-70",
)}
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
{isImporting ? "Importing..." : "Import job"}
</Button>
</div>
</div>
)}
<div className="mt-4 min-h-0 flex-1">
<ManualImportFlow
active={open}
onImported={onImported}
onClose={() => onOpenChange(false)}
/>
</div>
</div>
</SheetContent>

View File

@ -4,6 +4,14 @@ const GITHUB_REPO = "DaKheera47/job-ops";
const STORAGE_KEY = "jobops_version_check";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
function canUseStorage(): boolean {
return (
typeof localStorage !== "undefined" &&
typeof localStorage.getItem === "function" &&
typeof localStorage.setItem === "function"
);
}
export interface VersionCheckResult {
currentVersion: string;
latestVersion: string | null;
@ -44,7 +52,7 @@ export async function checkForUpdate(): Promise<VersionCheckResult> {
const currentVersion = parseVersion(currentRaw);
// Check cached result
const cached = localStorage.getItem(STORAGE_KEY);
const cached = canUseStorage() ? localStorage.getItem(STORAGE_KEY) : null;
if (cached) {
try {
const parsed: VersionCheckResult = JSON.parse(cached);
@ -85,7 +93,9 @@ export async function checkForUpdate(): Promise<VersionCheckResult> {
lastChecked: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(result));
if (canUseStorage()) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(result));
}
return result;
} catch {
// On error, return current version with no update info

View File

@ -2,9 +2,20 @@ import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants";
vi.mock("../api", () => ({
updateSettings: vi.fn().mockResolvedValue({}),
runPipeline: vi.fn().mockResolvedValue({ message: "ok" }),
getPipelineStatus: vi.fn().mockResolvedValue({
isRunning: false,
lastRun: null,
nextScheduledRun: null,
}),
}));
const jobFixture: Job = {
id: "job-1",
source: "linkedin",
@ -113,6 +124,7 @@ vi.mock("../hooks/useSettings", () => ({
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
},
refreshSettings: vi.fn(),
}),
}));
@ -186,6 +198,34 @@ vi.mock("./orchestrator/JobListPanel", () => ({
),
}));
vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({
onSaveAndRunAutomatic,
}: {
onSaveAndRunAutomatic: (values: {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
}) => Promise<void>;
}) => (
<button
type="button"
data-testid="run-automatic"
onClick={() =>
void onSaveAndRunAutomatic({
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
})
}
>
Run automatic
</button>
),
}));
vi.mock("../components", () => ({
ManualImportSheet: () => <div data-testid="manual-import" />,
}));
@ -350,4 +390,35 @@ describe("OrchestratorPage", () => {
);
});
});
it("saves automatic settings from modal", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
const setIntervalSpy = vi
.spyOn(globalThis, "setInterval")
.mockReturnValue(0 as unknown as NodeJS.Timeout);
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith({
searchTerms: ["backend"],
jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150,
});
});
setIntervalSpy.mockRestore();
});
});

View File

@ -11,7 +11,8 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
import * as api from "../api";
import { ManualImportSheet } from "../components";
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
import type { FilterTab, JobSort } from "./orchestrator/constants";
import { DEFAULT_SORT } from "./orchestrator/constants";
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
@ -20,6 +21,8 @@ import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
import { OrchestratorHeader } from "./orchestrator/OrchestratorHeader";
import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { RunModeModal } from "./orchestrator/RunModeModal";
import type { RunMode } from "./orchestrator/run-mode";
import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
@ -131,7 +134,8 @@ export const OrchestratorPage: React.FC = () => {
}, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
@ -153,7 +157,7 @@ export const OrchestratorPage: React.FC = () => {
[navigateWithContext, activeTab],
);
const { settings } = useSettings();
const { settings, refreshSettings } = useSettings();
const {
jobs,
stats,
@ -167,8 +171,7 @@ export const OrchestratorPage: React.FC = () => {
() => getEnabledSources(settings ?? null),
[settings],
);
const { pipelineSources, setPipelineSources, toggleSource } =
usePipelineSources(enabledSources);
const { pipelineSources, toggleSource } = usePipelineSources(enabledSources);
const activeJobs = useFilteredJobs(
jobs,
@ -208,43 +211,78 @@ export const OrchestratorPage: React.FC = () => {
}
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
const openRunMode = useCallback((mode: RunMode) => {
setRunMode(mode);
setIsRunModeModalOpen(true);
}, []);
const handleManualImported = useCallback(
async (importedJobId: string) => {
// Refresh jobs and navigate to the new job in discovered tab
await loadJobs();
navigateWithContext("discovered", importedJobId);
},
[loadJobs, navigateWithContext],
);
const handleRunPipeline = async () => {
try {
setIsPipelineRunning(true);
await api.runPipeline({ sources: pipelineSources });
toast.message("Pipeline started", {
description: `Sources: ${pipelineSources.join(", ")}. This may take a few minutes.`,
});
const startPipelineRun = useCallback(
async (config: {
topN: number;
minSuitabilityScore: number;
sources: JobSource[];
}) => {
try {
setIsPipelineRunning(true);
await api.runPipeline(config);
toast.message("Pipeline started", {
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
});
const pollInterval = setInterval(async () => {
try {
const status = await api.getPipelineStatus();
if (!status.isRunning) {
clearInterval(pollInterval);
setIsPipelineRunning(false);
await loadJobs();
toast.success("Pipeline completed");
const pollInterval = setInterval(async () => {
try {
const status = await api.getPipelineStatus();
if (!status.isRunning) {
clearInterval(pollInterval);
setIsPipelineRunning(false);
await loadJobs();
toast.success("Pipeline completed");
}
} catch {
// Ignore errors
}
} catch {
// Ignore errors
}
}, 5000);
} catch (error) {
setIsPipelineRunning(false);
const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message);
}
};
}, 5000);
} catch (error) {
setIsPipelineRunning(false);
const message =
error instanceof Error ? error.message : "Failed to start pipeline";
toast.error(message);
}
},
[loadJobs, setIsPipelineRunning],
);
const handleSaveAndRunAutomatic = useCallback(
async (values: AutomaticRunValues) => {
const limits = deriveExtractorLimits({
budget: values.runBudget,
searchTerms: values.searchTerms,
sources: pipelineSources,
});
await api.updateSettings({
searchTerms: values.searchTerms,
jobspyResultsWanted: limits.jobspyResultsWanted,
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
});
await refreshSettings();
await startPipelineRun({
topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore,
sources: pipelineSources,
});
setIsRunModeModalOpen(false);
},
[pipelineSources, refreshSettings, startPipelineRun],
);
const handleSelectJob = (id: string) => {
handleSelectJobId(id);
@ -315,11 +353,7 @@ export const OrchestratorPage: React.FC = () => {
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
enabledSources={enabledSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
onOpenAutomaticRun={() => openRunMode("automatic")}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
@ -386,10 +420,18 @@ export const OrchestratorPage: React.FC = () => {
onClear={clearSelection}
/>
<ManualImportSheet
open={isManualImportOpen}
onOpenChange={setIsManualImportOpen}
onImported={handleManualImported}
<RunModeModal
open={isRunModeModalOpen}
mode={runMode}
settings={settings ?? null}
enabledSources={enabledSources}
pipelineSources={pipelineSources}
onToggleSource={toggleSource}
isPipelineRunning={isPipelineRunning}
onOpenChange={setIsRunModeModalOpen}
onModeChange={setRunMode}
onSaveAndRunAutomatic={handleSaveAndRunAutomatic}
onManualImported={handleManualImported}
/>
{!isDesktop && (

View File

@ -252,18 +252,23 @@ describe("SettingsPage", () => {
expect(saveButton).toBeEnabled();
});
it("enables save button when numeric setting is changed", async () => {
it("hides pipeline tuning sections that moved to run modal", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage();
const saveButton = screen.getByRole("button", { name: /^save$/i });
const visaTrigger = await screen.findByRole("button", {
name: /ukvisajobs extractor/i,
});
fireEvent.click(visaTrigger);
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i);
fireEvent.change(maxJobsInput, { target: { value: "100" } });
expect(saveButton).toBeEnabled();
await screen.findByRole("button", { name: /model/i });
expect(
screen.queryByRole("button", { name: /ukvisajobs extractor/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /gradcracker extractor/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /search terms/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /jobspy scraper/i }),
).not.toBeInTheDocument();
});
it("enables save button when display setting is changed", async () => {

View File

@ -4,13 +4,9 @@ import { BackupSettingsSection } from "@client/pages/settings/components/BackupS
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection";
import { JobspySection } from "@client/pages/settings/components/JobspySection";
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
import {
type LlmProviderId,
@ -867,26 +863,6 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
values={ukvisajobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
values={gradcracker}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
values={searchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
values={jobspy}
isLoading={isLoading}
isSaving={isSaving}
/>
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={(value) => {

View File

@ -0,0 +1,421 @@
import type { AppSettings, JobSource } from "@shared/types";
import { Loader2, Sparkles, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { sourceLabel } from "@/lib/utils";
import {
AUTOMATIC_PRESETS,
type AutomaticPresetId,
type AutomaticRunValues,
calculateAutomaticEstimate,
loadAutomaticRunMemory,
parseSearchTermsInput,
saveAutomaticRunMemory,
} from "./automatic-run";
interface AutomaticRunTabProps {
open: boolean;
settings: AppSettings | null;
enabledSources: JobSource[];
pipelineSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void;
isPipelineRunning: boolean;
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
}
const DEFAULT_VALUES: AutomaticRunValues = {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["web developer"],
runBudget: 200,
};
interface AutomaticRunFormValues {
topN: string;
minSuitabilityScore: string;
runBudget: string;
searchTerms: string[];
searchTermDraft: string;
}
type AutomaticPresetSelection = AutomaticPresetId | "custom";
function toNumber(input: string, min: number, max: number, fallback: number) {
const parsed = Number.parseInt(input, 10);
if (Number.isNaN(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function getPresetSelection(values: {
topN: number;
minSuitabilityScore: number;
runBudget: number;
}): AutomaticPresetSelection {
if (
values.topN === AUTOMATIC_PRESETS.fast.topN &&
values.minSuitabilityScore === AUTOMATIC_PRESETS.fast.minSuitabilityScore &&
values.runBudget === AUTOMATIC_PRESETS.fast.runBudget
) {
return "fast";
}
if (
values.topN === AUTOMATIC_PRESETS.balanced.topN &&
values.minSuitabilityScore ===
AUTOMATIC_PRESETS.balanced.minSuitabilityScore &&
values.runBudget === AUTOMATIC_PRESETS.balanced.runBudget
) {
return "balanced";
}
if (
values.topN === AUTOMATIC_PRESETS.detailed.topN &&
values.minSuitabilityScore ===
AUTOMATIC_PRESETS.detailed.minSuitabilityScore &&
values.runBudget === AUTOMATIC_PRESETS.detailed.runBudget
) {
return "detailed";
}
return "custom";
}
export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
open,
settings,
enabledSources,
pipelineSources,
onToggleSource,
isPipelineRunning,
onSaveAndRun,
}) => {
const [isSaving, setIsSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
{
defaultValues: {
topN: String(DEFAULT_VALUES.topN),
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
runBudget: String(DEFAULT_VALUES.runBudget),
searchTerms: DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
},
},
);
const topNInput = watch("topN");
const minScoreInput = watch("minSuitabilityScore");
const runBudgetInput = watch("runBudget");
const searchTerms = watch("searchTerms");
const searchTermDraft = watch("searchTermDraft");
useEffect(() => {
if (!open) return;
const memory = loadAutomaticRunMemory();
const topN = memory?.topN ?? DEFAULT_VALUES.topN;
const minSuitabilityScore =
memory?.minSuitabilityScore ?? DEFAULT_VALUES.minSuitabilityScore;
const rememberedRunBudget =
settings?.jobspyResultsWanted ??
settings?.gradcrackerMaxJobsPerTerm ??
settings?.ukvisajobsMaxJobs ??
DEFAULT_VALUES.runBudget;
reset({
topN: String(topN),
minSuitabilityScore: String(minSuitabilityScore),
runBudget: String(rememberedRunBudget),
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
searchTermDraft: "",
});
setAdvancedOpen(false);
}, [open, settings, reset]);
const addSearchTerms = (input: string) => {
const parsed = parseSearchTermsInput(input);
if (parsed.length === 0) return;
const current = getValues("searchTerms");
const next = [...current];
for (const term of parsed) {
if (!next.includes(term)) next.push(term);
}
setValue("searchTerms", next, { shouldDirty: true });
};
const values = useMemo<AutomaticRunValues>(() => {
return {
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
minSuitabilityScore: toNumber(
minScoreInput,
0,
100,
DEFAULT_VALUES.minSuitabilityScore,
),
searchTerms,
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
};
}, [topNInput, minScoreInput, searchTerms, runBudgetInput]);
const estimate = useMemo(
() => calculateAutomaticEstimate({ values, sources: pipelineSources }),
[values, pipelineSources],
);
const activePreset = useMemo<AutomaticPresetSelection>(
() => getPresetSelection(values),
[values],
);
const runDisabled =
isPipelineRunning ||
isSaving ||
pipelineSources.length === 0 ||
values.searchTerms.length === 0;
const applyPreset = (presetId: AutomaticPresetId) => {
const preset = AUTOMATIC_PRESETS[presetId];
setValue("topN", String(preset.topN), { shouldDirty: true });
setValue("minSuitabilityScore", String(preset.minSuitabilityScore), {
shouldDirty: true,
});
setValue("runBudget", String(preset.runBudget), { shouldDirty: true });
};
const handleSaveAndRun = async () => {
setIsSaving(true);
try {
saveAutomaticRunMemory({
topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore,
});
await onSaveAndRun(values);
} finally {
setIsSaving(false);
}
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
<Card>
<CardContent className="space-y-4 pt-6">
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
<Label className="text-base font-semibold">Preset</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={activePreset === "fast" ? "default" : "outline"}
onClick={() => applyPreset("fast")}
>
Fast
</Button>
<Button
type="button"
size="sm"
variant={activePreset === "balanced" ? "default" : "outline"}
onClick={() => applyPreset("balanced")}
>
Balanced
</Button>
<Button
type="button"
size="sm"
variant={activePreset === "detailed" ? "default" : "outline"}
onClick={() => applyPreset("detailed")}
>
Detailed
</Button>
<Button
type="button"
size="sm"
variant={activePreset === "custom" ? "secondary" : "outline"}
>
Custom
</Button>
</div>
</div>
<Separator />
<Accordion
type="single"
collapsible
value={advancedOpen ? "advanced" : undefined}
onValueChange={(value) => setAdvancedOpen(value === "advanced")}
>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-0 text-base font-semibold hover:no-underline">
Advanced settings
</AccordionTrigger>
<AccordionContent className="pt-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="top-n">Resumes tailored</Label>
<Input
id="top-n"
type="number"
min={1}
max={50}
value={topNInput}
onChange={(event) =>
setValue("topN", event.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="min-score">Min suitability score</Label>
<Input
id="min-score"
type="number"
min={0}
max={100}
value={minScoreInput}
onChange={(event) =>
setValue("minSuitabilityScore", event.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobs-per-term">Max jobs discovered</Label>
<Input
id="jobs-per-term"
type="number"
min={1}
max={1000}
value={runBudgetInput}
onChange={(event) =>
setValue("runBudget", event.target.value)
}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Search terms</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input
id="search-terms-input"
value={searchTermDraft}
onChange={(event) =>
setValue("searchTermDraft", event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
return;
}
if (
event.key === "Backspace" &&
searchTermDraft.length === 0 &&
searchTerms.length > 0
) {
setValue("searchTerms", searchTerms.slice(0, -1), {
shouldDirty: true,
});
}
}}
onBlur={() => {
addSearchTerms(searchTermDraft);
setValue("searchTermDraft", "");
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData("text");
const parsed = parseSearchTermsInput(pasted);
if (parsed.length > 1) {
event.preventDefault();
addSearchTerms(pasted);
}
}}
placeholder="Type and press Enter"
/>
<p className="text-xs text-muted-foreground">
Add multiple terms by separating with commas or pressing Enter.
</p>
<div className="flex flex-wrap gap-2">
{searchTerms.map((term) => (
<button
type="button"
key={term}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-3 py-1 text-sm transition-all duration-150 hover:border-primary/50 hover:bg-primary/40 hover:text-primary-foreground hover:shadow-sm"
aria-label={`Remove ${term}`}
onClick={() =>
setValue(
"searchTerms",
searchTerms.filter((value) => value !== term),
{ shouldDirty: true },
)
}
>
{term}
<X className="h-3 w-3" />
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
Sources ({pipelineSources.length}/{enabledSources.length})
</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{enabledSources.map((source) => (
<Button
key={source}
type="button"
size="sm"
variant={
pipelineSources.includes(source) ? "default" : "outline"
}
onClick={() =>
onToggleSource(source, !pipelineSources.includes(source))
}
>
{sourceLabel[source]}
</Button>
))}
</CardContent>
</Card>
</div>
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-border/60 bg-background pt-3">
<div className="hidden text-sm text-muted-foreground md:block">
Est: {estimate.discovered.min}-{estimate.discovered.max} jobs, ~
{values.topN} resumes
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
className="gap-2"
disabled={runDisabled}
onClick={() => void handleSaveAndRun()}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
Start run now
</Button>
</div>
</div>
</div>
);
};

View File

@ -5,61 +5,6 @@ import { describe, expect, it, vi } from "vitest";
import { OrchestratorHeader } from "./OrchestratorHeader";
vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react");
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div role="menu">{children}</div>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect?: (event: Event) => void;
}) => (
<button
type="button"
role="menuitem"
onClick={() =>
onSelect?.({ preventDefault: () => {} } as unknown as Event)
}
>
{children}
</button>
),
DropdownMenuCheckboxItem: ({
children,
onCheckedChange,
checked,
}: {
children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
checked?: boolean;
}) => (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
>
{children}
</button>
),
};
});
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: React.ReactNode }) => (
@ -84,11 +29,7 @@ const renderHeader = (
onNavOpenChange: vi.fn(),
isPipelineRunning: false,
pipelineSources: ["gradcracker"],
enabledSources: ["gradcracker"],
onToggleSource: vi.fn(),
onSetPipelineSources: vi.fn(),
onRunPipeline: vi.fn(),
onOpenManualImport: vi.fn(),
onOpenAutomaticRun: vi.fn(),
...overrides,
};
@ -103,46 +44,16 @@ const renderHeader = (
};
describe("OrchestratorHeader", () => {
it("renders only enabled sources", () => {
renderHeader({
enabledSources: ["gradcracker", "linkedin"],
pipelineSources: ["linkedin"],
});
expect(
screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i }),
).not.toBeInTheDocument();
it("opens automatic run from the navbar button", () => {
const { props } = renderHeader();
fireEvent.click(screen.getByRole("button", { name: /run pipeline/i }));
expect(props.onOpenAutomaticRun).toHaveBeenCalled();
});
it("uses enabled sources for the all sources action", () => {
const { props } = renderHeader({
enabledSources: ["gradcracker", "linkedin"],
});
fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }),
);
expect(props.onSetPipelineSources).toHaveBeenCalledWith([
"gradcracker",
"linkedin",
]);
});
it("does not show source presets", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
it("does not render manual import button", () => {
renderHeader();
expect(
screen.queryByRole("menuitem", { name: /Gradcracker only/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i }),
screen.queryByRole("button", { name: /manual import/i }),
).not.toBeInTheDocument();
});
});

View File

@ -1,24 +1,9 @@
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import type { JobSource } from "@shared/types.js";
import {
ChevronDown,
FileText,
Loader2,
Menu,
Play,
Sparkles,
} from "lucide-react";
import { Loader2, Menu, Play, Sparkles } from "lucide-react";
import type React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
@ -26,19 +11,14 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn, sourceLabel } from "@/lib/utils";
import { orderedSources } from "./constants";
import { cn } from "@/lib/utils";
interface OrchestratorHeaderProps {
navOpen: boolean;
onNavOpenChange: (open: boolean) => void;
isPipelineRunning: boolean;
pipelineSources: JobSource[];
enabledSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void;
onRunPipeline: () => void;
onOpenManualImport: () => void;
onOpenAutomaticRun: () => void;
}
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
@ -46,20 +26,10 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
onNavOpenChange,
isPipelineRunning,
pipelineSources,
enabledSources,
onToggleSource,
onSetPipelineSources,
onRunPipeline,
onOpenManualImport,
onOpenAutomaticRun,
}) => {
const location = useLocation();
const navigate = useNavigate();
const visibleSources = orderedSources.filter((source) =>
enabledSources.includes(source),
);
const allSourcesSelected =
visibleSources.length > 0 &&
visibleSources.every((source) => pipelineSources.includes(source));
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
@ -126,74 +96,21 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={onOpenManualImport}
onClick={onOpenAutomaticRun}
disabled={isPipelineRunning}
className="gap-2"
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">Manual import</span>
{isPipelineRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="hidden sm:inline">
{isPipelineRunning
? `Running (${pipelineSources.length})`
: `Run pipeline`}
</span>
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={onRunPipeline}
disabled={isPipelineRunning}
className="gap-2"
>
{isPipelineRunning ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="hidden sm:inline">
{isPipelineRunning
? `Running (${pipelineSources.length})`
: `Run pipeline (${pipelineSources.length})`}
</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
disabled={isPipelineRunning}
aria-label="Select pipeline sources"
className="shrink-0"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{visibleSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) =>
onToggleSource(source, Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={allSourcesSelected}
onCheckedChange={(checked) => {
onSetPipelineSources(
checked ? visibleSources : visibleSources.slice(0, 1),
);
}}
onSelect={(event) => event.preventDefault()}
>
Select all sources
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>

View File

@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RunModeModal } from "./RunModeModal";
vi.mock("@client/components/ManualImportFlow", () => ({
ManualImportFlow: () => <div data-testid="manual-flow">Manual flow</div>,
}));
vi.mock("./AutomaticRunTab", () => ({
AutomaticRunTab: () => (
<div data-testid="automatic-tab">Automatic run tab</div>
),
}));
describe("RunModeModal", () => {
it("switches between Automatic and Manual tabs", () => {
render(
<RunModeModal
open
mode="automatic"
settings={null}
enabledSources={["linkedin"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
isPipelineRunning={false}
onOpenChange={vi.fn()}
onModeChange={vi.fn()}
onSaveAndRunAutomatic={vi.fn().mockResolvedValue(undefined)}
onManualImported={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.getByTestId("automatic-tab")).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /manual/i })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,93 @@
import { ManualImportFlow } from "@client/components/ManualImportFlow";
import type { AppSettings, JobSource } from "@shared/types";
import type React from "react";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AutomaticRunTab } from "./AutomaticRunTab";
import type { AutomaticRunValues } from "./automatic-run";
import type { RunMode } from "./run-mode";
interface RunModeModalProps {
open: boolean;
mode: RunMode;
settings: AppSettings | null;
enabledSources: JobSource[];
pipelineSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void;
isPipelineRunning: boolean;
onOpenChange: (open: boolean) => void;
onModeChange: (mode: RunMode) => void;
onSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise<void>;
onManualImported: (jobId: string) => Promise<void>;
}
export const RunModeModal: React.FC<RunModeModalProps> = ({
open,
mode,
settings,
enabledSources,
pipelineSources,
onToggleSource,
isPipelineRunning,
onOpenChange,
onModeChange,
onSaveAndRunAutomatic,
onManualImported,
}) => {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl">
<div className="flex h-full flex-col">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
Run jobs
</SheetTitle>
<SheetDescription>
Choose Automatic pipeline run or Manual import.
</SheetDescription>
</SheetHeader>
<Separator className="my-4" />
<Tabs
value={mode}
onValueChange={(value) => onModeChange(value as RunMode)}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="automatic">Automatic</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="automatic" className="min-h-0 flex-1">
<AutomaticRunTab
open={open}
settings={settings}
enabledSources={enabledSources}
pipelineSources={pipelineSources}
onToggleSource={onToggleSource}
isPipelineRunning={isPipelineRunning}
onSaveAndRun={onSaveAndRunAutomatic}
/>
</TabsContent>
<TabsContent value="manual" className="min-h-0 flex-1">
<ManualImportFlow
active={open && mode === "manual"}
onImported={onManualImported}
onClose={() => onOpenChange(false)}
/>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
};

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
AUTOMATIC_PRESETS,
calculateAutomaticEstimate,
deriveExtractorLimits,
parseSearchTermsInput,
} from "./automatic-run";
describe("automatic-run utilities", () => {
it("exposes the expected preset values", () => {
expect(AUTOMATIC_PRESETS.fast).toEqual({
topN: 5,
minSuitabilityScore: 75,
runBudget: 300,
});
expect(AUTOMATIC_PRESETS.detailed.topN).toBeGreaterThan(
AUTOMATIC_PRESETS.fast.topN,
);
});
it("calculates estimate range with source caps and topN clipping", () => {
const estimate = calculateAutomaticEstimate({
values: {
topN: 10,
minSuitabilityScore: 50,
searchTerms: ["backend", "platform"],
runBudget: 100,
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
expect(estimate.discovered.cap).toBe(100);
expect(estimate.discovered.min).toBe(35);
expect(estimate.discovered.max).toBe(75);
expect(estimate.processed.min).toBe(10);
expect(estimate.processed.max).toBe(10);
});
it("keeps discovered cap under budget regardless of search-term count", () => {
const limits = deriveExtractorLimits({
budget: 750,
searchTerms: ["a", "b", "c"],
sources: ["indeed", "linkedin", "gradcracker"],
});
const cap =
2 * limits.jobspyResultsWanted * 3 + limits.gradcrackerMaxJobsPerTerm * 3;
expect(cap).toBeLessThanOrEqual(750);
});
it("returns zero estimate when no search terms are provided", () => {
const estimate = calculateAutomaticEstimate({
values: {
topN: 10,
minSuitabilityScore: 50,
searchTerms: [],
runBudget: 750,
},
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
});
expect(estimate).toEqual({
discovered: { min: 0, max: 0, cap: 0 },
processed: { min: 0, max: 0 },
});
});
it("parses comma and newline separated search terms", () => {
expect(parseSearchTermsInput("backend, platform\napi\n\n")).toEqual([
"backend",
"platform",
"api",
]);
});
});

View File

@ -0,0 +1,196 @@
import type { JobSource } from "@shared/types";
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
export interface AutomaticRunValues {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
}
export interface AutomaticPresetValues {
topN: number;
minSuitabilityScore: number;
runBudget: number;
}
export interface AutomaticEstimate {
discovered: {
min: number;
max: number;
cap: number;
};
processed: {
min: number;
max: number;
};
}
export const AUTOMATIC_PRESETS: Record<
AutomaticPresetId,
AutomaticPresetValues
> = {
fast: {
topN: 5,
minSuitabilityScore: 75,
runBudget: 300,
},
balanced: {
topN: 10,
minSuitabilityScore: 50,
runBudget: 500,
},
detailed: {
topN: 20,
minSuitabilityScore: 35,
runBudget: 750,
},
};
export const RUN_MEMORY_STORAGE_KEY = "jobops.pipeline.run-memory.v1";
export interface AutomaticRunMemory {
topN: number;
minSuitabilityScore: number;
}
export interface ExtractorLimits {
jobspyResultsWanted: number;
gradcrackerMaxJobsPerTerm: number;
ukvisajobsMaxJobs: number;
}
export function deriveExtractorLimits(args: {
budget: number;
searchTerms: string[];
sources: JobSource[];
}): ExtractorLimits {
const budget = Math.max(1, Math.round(args.budget));
const termCount = Math.max(1, args.searchTerms.length);
const includesIndeed = args.sources.includes("indeed");
const includesLinkedIn = args.sources.includes("linkedin");
const includesGradcracker = args.sources.includes("gradcracker");
const includesUkVisaJobs = args.sources.includes("ukvisajobs");
const weightedContributors =
(includesIndeed ? termCount : 0) +
(includesLinkedIn ? termCount : 0) +
(includesGradcracker ? termCount : 0) +
(includesUkVisaJobs ? 1 : 0);
if (weightedContributors <= 0) {
return {
jobspyResultsWanted: budget,
gradcrackerMaxJobsPerTerm: budget,
ukvisajobsMaxJobs: budget,
};
}
const perUnit = Math.max(1, Math.floor(budget / weightedContributors));
const remainder = Math.max(0, budget - perUnit * weightedContributors);
return {
jobspyResultsWanted: perUnit,
gradcrackerMaxJobsPerTerm: perUnit,
ukvisajobsMaxJobs: Math.min(budget, perUnit + remainder),
};
}
export function parseSearchTermsInput(input: string): string[] {
return input
.split(/[\n,]/g)
.map((value) => value.trim())
.filter(Boolean);
}
export function stringifySearchTerms(terms: string[]): string {
return terms.join("\n");
}
export function calculateAutomaticEstimate(args: {
values: AutomaticRunValues;
sources: JobSource[];
}): AutomaticEstimate {
const { values, sources } = args;
if (values.searchTerms.length === 0) {
return {
discovered: {
min: 0,
max: 0,
cap: 0,
},
processed: {
min: 0,
max: 0,
},
};
}
const termCount = values.searchTerms.length;
const hasGradcracker = sources.includes("gradcracker");
const hasUkVisaJobs = sources.includes("ukvisajobs");
const hasIndeed = sources.includes("indeed");
const hasLinkedIn = sources.includes("linkedin");
const limits = deriveExtractorLimits({
budget: values.runBudget,
searchTerms: values.searchTerms,
sources,
});
const jobspySitesCount = [hasIndeed, hasLinkedIn].filter(Boolean).length;
const jobspyCap = jobspySitesCount * limits.jobspyResultsWanted * termCount;
const gradcrackerCap = hasGradcracker
? limits.gradcrackerMaxJobsPerTerm * termCount
: 0;
const ukvisaCap = hasUkVisaJobs ? limits.ukvisajobsMaxJobs : 0;
const discoveredCap = jobspyCap + gradcrackerCap + ukvisaCap;
const discoveredMin = Math.round(discoveredCap * 0.35);
const discoveredMax = Math.round(discoveredCap * 0.75);
const processedMin = Math.min(values.topN, discoveredMin);
const processedMax = Math.min(values.topN, discoveredMax);
return {
discovered: {
min: discoveredMin,
max: discoveredMax,
cap: discoveredCap,
},
processed: {
min: processedMin,
max: processedMax,
},
};
}
export function loadAutomaticRunMemory(): AutomaticRunMemory | null {
try {
const raw = localStorage.getItem(RUN_MEMORY_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<AutomaticRunMemory>;
if (
typeof parsed.topN !== "number" ||
typeof parsed.minSuitabilityScore !== "number"
) {
return null;
}
return {
topN: Math.min(50, Math.max(1, Math.round(parsed.topN))),
minSuitabilityScore: Math.min(
100,
Math.max(0, Math.round(parsed.minSuitabilityScore)),
),
};
} catch {
return null;
}
}
export function saveAutomaticRunMemory(memory: AutomaticRunMemory): void {
try {
localStorage.setItem(RUN_MEMORY_STORAGE_KEY, JSON.stringify(memory));
} catch {
// Ignore localStorage failures
}
}

View File

@ -0,0 +1 @@
export type RunMode = "automatic" | "manual";

View File

@ -1,7 +1,8 @@
import { okWithMeta } from "@infra/http";
import { AppError, badRequest, requestTimeout } from "@infra/errors";
import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import type { ApiResponse, PipelineStatusResponse } from "@shared/types";
import type { PipelineStatusResponse } from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { isDemoMode } from "../../config/demo";
@ -22,22 +23,21 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
try {
const { isRunning } = getPipelineStatus();
const lastRun = await pipelineRepo.getLatestPipelineRun();
const response: ApiResponse<PipelineStatusResponse> = {
ok: true,
data: {
isRunning,
lastRun,
nextScheduledRun: null, // Would come from n8n
},
const data: PipelineStatusResponse = {
isRunning,
lastRun,
nextScheduledRun: null,
};
res.json(response);
ok(res, data);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
fail(
res,
new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unknown error",
}),
);
}
});
@ -77,12 +77,16 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => {
pipelineRouter.get("/runs", async (_req: Request, res: Response) => {
try {
const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ ok: true, data: runs });
ok(res, runs);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
fail(
res,
new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unknown error",
}),
);
}
});
@ -113,21 +117,21 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
logger.error("Background pipeline run failed", error);
});
});
res.json({
ok: true,
data: { message: "Pipeline started" },
});
ok(res, { message: "Pipeline started" });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
ok: false,
error: { code: "INVALID_REQUEST", message: error.message },
});
return fail(res, badRequest(error.message, error.flatten()));
}
const message = error instanceof Error ? error.message : "Unknown error";
res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
if (error instanceof Error && error.name === "AbortError") {
return fail(res, requestTimeout("Request timed out"));
}
fail(
res,
new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "Unknown error",
}),
);
}
});