Small bits and bobs, codebase quality (#129)

* initial change

* nav highlighting

* icon change

* deeeedoooop

* text

* show version number on all pages

* icon

* remove unused code

* add knip

* formatting

* remove unused code

* types fix

* remove notion completely from the codebase.

* update test for new url structure

* clean up the fucking shop boys

* make a "create job" factory and use that

* moar factories

* formatting
This commit is contained in:
Shaheer Sarfaraz 2026-02-10 20:01:58 +00:00 committed by GitHub
parent 2962e0c2ae
commit fe0aebe01a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 1614 additions and 3137 deletions

View File

@ -1,6 +1,6 @@
---
name: design-principles
description: Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.
description: Enforce a precise, minimal design system inspired by Linear and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.
---
# Design Principles
@ -24,7 +24,7 @@ Enterprise/SaaS UI has more range than you think. Consider these directions:
**Precision & Density** — Tight spacing, monochrome, information-forward. For power users who live in the tool. Think Linear, Raycast, terminal aesthetics.
**Warmth & Approachability** — Generous spacing, soft shadows, friendly colors. For products that want to feel human. Think Notion, Coda, collaborative tools.
**Warmth & Approachability** — Generous spacing, soft shadows, friendly colors. For products that want to feel human. Think Coda, collaborative tools.
**Sophistication & Trust** — Cool tones, layered depth, financial gravitas. For products handling money or sensitive data. Think Stripe, Mercury, enterprise B2B.
@ -234,4 +234,4 @@ Every interface should look designed by a team that obsesses over 1-pixel differ
Different products want different things. A developer tool wants precision and density. A collaborative product wants warmth and space. A financial product wants trust and sophistication. Let the product context guide the aesthetic.
The goal: intricate minimalism with appropriate personality. Same quality bar, context-driven execution.
The goal: intricate minimalism with appropriate personality. Same quality bar, context-driven execution.

View File

@ -7,7 +7,7 @@ This doc explains how the orchestrator thinks about job states, how the "Ready"
- `discovered`: The job was found by a crawler/import. It has not been processed into a tailored resume yet.
- `processing`: The system is currently generating tailoring data and/or the PDF.
- `ready`: A tailored PDF has been generated and the job is ready for you to apply.
- `applied`: You marked it as applied. If Notion is configured, a page is created and linked.
- `applied`: You marked it as applied.
- `skipped`: You explicitly skipped it (so it stays out of your active queue).
- `expired`: Deadline has passed. This is a terminal state used for cleanup/triage.
@ -31,7 +31,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane":
- View/download the PDF.
- Open the job listing.
- Mark Applied (moves to `applied` and syncs to Notion if configured).
- Mark Applied (moves to `applied`).
- Optional: edit tailoring, edit the JD, or regenerate the PDF.
## Generating PDFs (first time)

7
knip.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"tags": ["-lintignore"],
"workspaces": {
".": {}
}
}

View File

@ -40,7 +40,7 @@ orchestrator/
OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported.
Deprecated: `OPENROUTER_API_KEY` / `openrouterApiKey`. Use `LLM_API_KEY` / `llmApiKey` instead (legacy values are auto-migrated/copied for compatibility).
Use `LLM_API_KEY` / `llmApiKey` to configure providers that require an API key.
3. **Initialize database:**
```bash
@ -66,7 +66,7 @@ orchestrator/
| GET | `/api/jobs/:id` | Get single job |
| PATCH | `/api/jobs/:id` | Update job |
| POST | `/api/jobs/:id/process` | Generate resume for job |
| POST | `/api/jobs/:id/apply` | Mark as applied + sync to Notion |
| POST | `/api/jobs/:id/apply` | Mark as applied |
| POST | `/api/jobs/:id/skip` | Mark as skipped |
### Pipeline
@ -92,7 +92,7 @@ orchestrator/
- Use command bar search (`Cmd/Ctrl+K`) to quickly find and open jobs
- Click "View Job" to open application
- Download PDF and apply manually
- Click "Mark Applied" → syncs to Notion
- Click "Mark Applied" to mark application status
## n8n Setup

View File

@ -16,6 +16,20 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
/** Backwards-compatibility redirects: old URL paths -> new URL paths */
const REDIRECTS: Array<{ from: string; to: string }> = [
{ from: "/", to: "/jobs/ready" },
{ from: "/home", to: "/overview" },
{ from: "/ready", to: "/jobs/ready" },
{ from: "/ready/:jobId", to: "/jobs/ready/:jobId" },
{ from: "/discovered", to: "/jobs/discovered" },
{ from: "/discovered/:jobId", to: "/jobs/discovered/:jobId" },
{ from: "/applied", to: "/jobs/applied" },
{ from: "/applied/:jobId", to: "/jobs/applied/:jobId" },
{ from: "/all", to: "/jobs/all" },
{ from: "/all/:jobId", to: "/jobs/all/:jobId" },
];
export const App: React.FC = () => {
const location = useLocation();
const nodeRef = useRef<HTMLDivElement>(null);
@ -23,8 +37,8 @@ export const App: React.FC = () => {
// Determine a stable key for transitions to avoid unnecessary unmounts when switching sub-tabs
const pageKey = React.useMemo(() => {
const firstSegment = location.pathname.split("/")[1] || "ready";
if (["ready", "discovered", "applied", "all"].includes(firstSegment)) {
const firstSegment = location.pathname.split("/")[1] || "jobs";
if (firstSegment === "jobs") {
return "orchestrator";
}
return firstSegment;
@ -51,13 +65,25 @@ export const App: React.FC = () => {
>
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/home" element={<HomePage />} />
{/* Backwards-compatibility redirects */}
{REDIRECTS.map(({ from, to }) => (
<Route
key={from}
path={from}
element={<Navigate to={to} replace />}
/>
))}
{/* Application routes */}
<Route path="/overview" element={<HomePage />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route
path="/jobs/:tab/:jobId"
element={<OrchestratorPage />}
/>
</Routes>
</div>
</CSSTransition>

View File

@ -2,6 +2,7 @@
* API client for the orchestrator backend.
*/
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type {
ApiResponse,
ApplicationStage,
@ -24,7 +25,6 @@ import type {
ProfileStatusResponse,
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
StageEvent,
StageEventMetadata,
StageTransitionTarget,
@ -646,37 +646,9 @@ export async function validateResumeConfig(): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/resume");
}
export async function updateSettings(update: {
model?: string | null;
modelScorer?: string | null;
modelTailoring?: string | null;
modelProjectSelection?: string | null;
llmProvider?: string | null;
llmBaseUrl?: string | null;
llmApiKey?: string | null;
pipelineWebhookUrl?: string | null;
jobCompleteWebhookUrl?: string | null;
resumeProjects?: ResumeProjectsSettings | null;
ukvisajobsMaxJobs?: number | null;
gradcrackerMaxJobsPerTerm?: number | null;
searchTerms?: string[] | null;
jobspyLocation?: string | null;
jobspyResultsWanted?: number | null;
jobspyHoursOld?: number | null;
jobspyCountryIndeed?: string | null;
jobspySites?: string[] | null;
jobspyLinkedinFetchDescription?: boolean | null;
showSponsorInfo?: boolean | null;
openrouterApiKey?: string | null;
rxresumeEmail?: string | null;
rxresumePassword?: string | null;
basicAuthUser?: string | null;
basicAuthPassword?: string | null;
ukvisajobsEmail?: string | null;
ukvisajobsPassword?: string | null;
webhookSecret?: string | null;
rxresumeBaseResumeId?: string | null;
}): Promise<AppSettings> {
export async function updateSettings(
update: Partial<UpdateSettingsInput>,
): Promise<AppSettings> {
return fetchApi<AppSettings>("/settings", {
method: "PATCH",
body: JSON.stringify(update),

View File

@ -1,210 +0,0 @@
/**
* Header component with logo and pipeline trigger.
*/
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import type { JobSource } from "@shared/types.js";
import { ChevronDown, Loader2, Menu, Play, RefreshCcw } from "lucide-react";
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn, sourceLabel } from "@/lib/utils";
interface HeaderProps {
onRunPipeline: () => void;
onRefresh: () => void;
isPipelineRunning: boolean;
isLoading: boolean;
pipelineSources: JobSource[];
onPipelineSourcesChange: (sources: JobSource[]) => void;
}
export const Header: React.FC<HeaderProps> = ({
onRunPipeline,
onRefresh,
isPipelineRunning,
isLoading,
pipelineSources,
onPipelineSourcesChange,
}) => {
const location = useLocation();
const [sheetOpen, setSheetOpen] = React.useState(false);
const orderedSources: JobSource[] = [
"gradcracker",
"indeed",
"linkedin",
"ukvisajobs",
];
const toggleSource = (source: JobSource, checked: boolean) => {
const next = checked
? Array.from(new Set([...pipelineSources, source]))
: pipelineSources.filter((s) => s !== source);
if (next.length === 0) return;
onPipelineSourcesChange(next);
};
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">
<div className="flex items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
<Link
key={to}
to={to}
onClick={() => setSheetOpen(false)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
<Link
to="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm">
<img
src="/favicon.png"
alt="Job Ops Logo"
className="h-full w-full object-contain"
/>
</div>
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight">
Job Ops
</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div>
</Link>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
>
<RefreshCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refresh</span>
</Button>
<div>
<Button
size="sm"
onClick={onRunPipeline}
disabled={isPipelineRunning}
className="rounded-r-none"
>
{isPipelineRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
</>
) : (
<>
<Play className="h-4 w-4" />
Run Pipeline
</>
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
disabled={isPipelineRunning}
className="rounded-l-none border-l border-primary-foreground/20"
aria-label="Select pipeline sources"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
onCheckedChange={(checked) =>
toggleSource(source, Boolean(checked))
}
onSelect={(e) => e.preventDefault()}
>
{sourceLabel[source]}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onPipelineSourcesChange(orderedSources);
}}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onPipelineSourcesChange(["gradcracker"]);
}}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onPipelineSourcesChange(["indeed", "linkedin"]);
}}
>
Indeed + LinkedIn only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
};

View File

@ -1,3 +1,4 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
@ -35,20 +36,6 @@ vi.mock("sonner", () => ({
},
}));
const createJob = (overrides: Partial<Job> = {}): Job =>
({
id: "job-1",
title: "Backend Engineer",
employer: "Acme",
jobUrl: "https://example.com/job",
applicationLink: null,
location: "London",
salary: null,
deadline: null,
jobDescription: "Build APIs",
...overrides,
}) as Job;
describe("JobDetailsEditDrawer", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@ -1,4 +1,4 @@
import type { Job } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
@ -30,7 +30,7 @@ vi.mock("@/components/ui/tooltip", () => ({
),
}));
const mockJob: Job = {
const mockJob = createJob({
id: "job-1",
title: "Software Engineer",
employer: "Tech Corp",
@ -38,15 +38,10 @@ const mockJob: Job = {
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
outcome: null,
closedAt: null,
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
sponsorMatchScore: null,
sponsorMatchNames: null,
// Other fields...
} as Job;
});
describe("JobHeader", () => {
const renderWithRouter = (ui: React.ReactElement) =>

View File

@ -90,7 +90,6 @@ const settingsResponse = {
settings: {
llmProvider: "openrouter",
llmApiKeyHint: null,
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,

View File

@ -10,6 +10,7 @@ import {
LLM_PROVIDERS,
normalizeLlmProvider,
} from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type { ValidationResult } from "@shared/types.js";
import { Check } from "lucide-react";
import type React from "react";
@ -182,8 +183,7 @@ export const OnboardingGate: React.FC = () => {
requiresApiKey: requiresLlmKey,
} = providerConfig;
const llmKeyHint =
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null;
const llmKeyHint = settings?.llmApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
@ -351,11 +351,7 @@ export const OnboardingGate: React.FC = () => {
return false;
}
const update: {
llmProvider?: string;
llmBaseUrl?: string | null;
llmApiKey?: string;
} = {
const update: Partial<UpdateSettingsInput> = {
llmProvider: normalizedProvider,
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
};

View File

@ -1,3 +1,4 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
@ -90,69 +91,6 @@ vi.mock("sonner", () => ({
},
}));
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: "https://example.com/apply",
disciplines: null,
deadline: "2025-02-01",
salary: "GBP 50k",
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
suitabilityScore: 82,
suitabilityReason: "Strong fit",
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides,
});
describe("ReadyPanel", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@ -1,3 +1,4 @@
import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -19,7 +20,7 @@ vi.mock("sonner", () => ({
}));
const createJob = (overrides: Partial<Job> = {}): Job =>
({
createBaseJob({
id: "job-1",
tailoredSummary: "Saved summary",
tailoredHeadline: "Saved headline",
@ -29,7 +30,7 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
jobDescription: "Saved description",
selectedProjectIds: "p1",
...overrides,
}) as Job;
});
const ensureAccordionOpen = (name: string) => {
const trigger = screen.getByRole("button", { name });

View File

@ -3,7 +3,11 @@
* Tests real-world edge cases for conversion funnel and analytics
*/
import type { ApplicationStage, StageEvent } from "@shared/types.js";
import {
createJob as createBaseJob,
createStageEvent,
} from "@shared/testing/factories.js";
import type { ApplicationStage, Job, StageEvent } from "@shared/types.js";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -76,28 +80,27 @@ describe("ConversionAnalytics - Edge Cases", () => {
id: string,
appliedAt: string | null,
events: StageEvent[] = [],
) => ({
id,
datePosted: null,
discoveredAt: "2025-01-01T00:00:00Z",
appliedAt,
events,
});
) =>
createBaseJob({
id,
datePosted: null,
discoveredAt: "2025-01-01T00:00:00Z",
appliedAt,
...({ events } as any),
}) as Job & { events: StageEvent[] };
const createEvent = (
toStage: ApplicationStage,
occurredAt: number,
): StageEvent => ({
id: `event-${toStage}`,
applicationId: "job-1",
title: `Moved to ${toStage}`,
groupId: null,
fromStage: "applied",
toStage,
occurredAt,
metadata: null,
outcome: null,
});
): StageEvent =>
createStageEvent({
id: `event-${toStage}`,
applicationId: "job-1",
title: `Moved to ${toStage}`,
fromStage: "applied",
toStage,
occurredAt,
});
describe("Empty and Null Data", () => {
it("handles empty jobsWithEvents array - shows 0% conversion", () => {

View File

@ -1,3 +1,4 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
@ -83,69 +84,6 @@ vi.mock("sonner", () => ({
},
}));
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-2",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: "https://example.com/apply",
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "discovered",
suitabilityScore: 55,
suitabilityReason: "Ok fit",
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides,
});
describe("DiscoveredPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -153,7 +91,7 @@ describe("DiscoveredPanel", () => {
it("re-runs the fit assessment from the menu", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const job = createJob();
const job = createJob({ id: "job-2" });
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render(
@ -177,7 +115,7 @@ describe("DiscoveredPanel", () => {
it("opens edit details drawer from more actions", async () => {
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
const job = createJob();
const job = createJob({ id: "job-2" });
render(
<MemoryRouter>

View File

@ -1,3 +1,4 @@
import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -18,7 +19,7 @@ vi.mock("sonner", () => ({
}));
const createJob = (overrides: Partial<Job> = {}): Job =>
({
createBaseJob({
id: "job-1",
tailoredSummary: "Saved summary",
tailoredHeadline: "Saved headline",
@ -28,7 +29,7 @@ const createJob = (overrides: Partial<Job> = {}): Job =>
jobDescription: "Saved description",
selectedProjectIds: "p1",
...overrides,
}) as Job;
});
const ensureAccordionOpen = (name: string) => {
const trigger = screen.getByRole("button", { name });

View File

@ -1 +0,0 @@
export { DiscoveredPanel } from "./DiscoveredPanel";

View File

@ -1,6 +1,5 @@
export { DiscoveredPanel } from "./discovered-panel";
export { DiscoveredPanel } from "./discovered-panel/DiscoveredPanel";
export { FitAssessment } from "./FitAssessment";
export { Header } from "./Header";
export { JobHeader } from "./JobHeader";
export * from "./layout";
export { ManualImportSheet } from "./ManualImportSheet";

View File

@ -31,12 +31,15 @@ import { isNavActive, NAV_LINKS } from "./navigation";
// ============================================================================
interface PageHeaderProps {
icon: LucideIcon;
icon: LucideIcon | React.FC<{ className?: string }>;
title: string;
subtitle: string;
badge?: string;
statusIndicator?: React.ReactNode;
actions?: React.ReactNode;
showVersionFooter?: boolean;
navOpen?: boolean;
onNavOpenChange?: (open: boolean) => void;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
@ -46,10 +49,15 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
badge,
statusIndicator,
actions,
showVersionFooter = true,
navOpen: controlledNavOpen,
onNavOpenChange,
}) => {
const location = useLocation();
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
const [internalNavOpen, setInternalNavOpen] = useState(false);
const navOpen = controlledNavOpen ?? internalNavOpen;
const setNavOpen = onNavOpenChange ?? setInternalNavOpen;
const { version, updateAvailable } = useVersionCheck();
const handleNavClick = (to: string, activePaths?: string[]) => {
@ -94,28 +102,30 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
</button>
))}
</nav>
<div className="mt-auto pt-6 pb-2">
<TooltipProvider>
<a
href="https://github.com/DaKheera47/job-ops/releases"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span>Version {version}</span>
{updateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<span className="h-2 w-2 rounded-full bg-emerald-500 cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>Update available</p>
</TooltipContent>
</Tooltip>
)}
</a>
</TooltipProvider>
</div>
{showVersionFooter && (
<div className="mt-auto pt-6 pb-2">
<TooltipProvider>
<a
href="https://github.com/DaKheera47/job-ops/releases"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span>Version {version}</span>
{updateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<span className="h-2 w-2 rounded-full bg-emerald-500 cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>Update available</p>
</TooltipContent>
</Tooltip>
)}
</a>
</TooltipProvider>
</div>
)}
</SheetContent>
</Sheet>

View File

@ -8,12 +8,17 @@ export type NavLink = {
};
export const NAV_LINKS: NavLink[] = [
{ to: "/home", label: "Home", icon: Home },
{ to: "/overview", label: "Overview", icon: Home },
{
to: "/ready",
label: "Dashboard",
to: "/jobs/ready",
label: "Jobs",
icon: LayoutDashboard,
activePaths: ["/ready", "/discovered", "/applied", "/all"],
activePaths: [
"/jobs/ready",
"/jobs/discovered",
"/jobs/applied",
"/jobs/all",
],
},
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/settings", label: "Settings", icon: Settings },
@ -23,4 +28,10 @@ export const isNavActive = (
pathname: string,
to: string,
activePaths?: string[],
) => pathname === to || (activePaths ? activePaths.includes(pathname) : false);
) => {
if (pathname === to) return true;
if (!activePaths) return false;
return activePaths.some(
(path) => pathname === path || pathname.startsWith(`${path}/`),
);
};

View File

@ -5,22 +5,12 @@ import {
DurationSelector,
type DurationValue,
} from "@client/components/charts";
import { PageMain } from "@client/components/layout";
import { PageHeader, PageMain } from "@client/components/layout";
import type { StageEvent } from "@shared/types.js";
import { Home, Menu } from "lucide-react";
import { ChartColumn } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { isNavActive, NAV_LINKS } from "../components/navigation";
import { useSearchParams } from "react-router-dom";
type JobWithEvents = {
id: string;
@ -34,10 +24,7 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const;
const DEFAULT_DURATION = 30;
export const HomePage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [navOpen, setNavOpen] = useState(false);
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
const [isLoading, setIsLoading] = useState(true);
@ -139,74 +126,16 @@ export const HomePage: React.FC = () => {
[setSearchParams],
);
const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) {
setNavOpen(false);
return;
}
setNavOpen(false);
setTimeout(() => navigate(to), 150);
};
return (
<>
{/* Custom Header with Duration Selector */}
<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">
<div className="flex items-center gap-3">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{NAV_LINKS.map(
({ to, label, icon: NavIcon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => handleNavClick(to, activePaths)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}
>
<NavIcon className="h-4 w-4" />
{label}
</button>
),
)}
</nav>
</SheetContent>
</Sheet>
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Home className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">Home</div>
<div className="text-xs text-muted-foreground">
Applications over the last {duration} days
</div>
</div>
</div>
<div className="flex items-center gap-2">
<DurationSelector
value={duration}
onChange={handleDurationChange}
/>
</div>
</div>
</header>
<PageHeader
icon={ChartColumn}
title="Overview"
subtitle="Analytics & Insights"
actions={
<DurationSelector value={duration} onChange={handleDurationChange} />
}
/>
<PageMain>
<ApplicationsPerDayChart

View File

@ -1,4 +1,4 @@
import type { Job } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { toast } from "sonner";
@ -47,70 +47,35 @@ let mockAutomaticRunValues = {
country: "united kingdom",
};
const jobFixture: Job = {
const jobFixture = createJob({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
};
});
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
const processingJob: Job = { ...jobFixture, id: "job-3", status: "processing" };
const job2 = createJob({
id: "job-2",
source: "linkedin",
title: "Backend Engineer",
employer: "Acme",
location: "London",
jobDescription: "Build APIs",
status: "discovered",
});
const processingJob = createJob({
id: "job-3",
source: "linkedin",
title: "Backend Engineer",
employer: "Acme",
location: "London",
jobDescription: "Build APIs",
status: "processing",
});
const createMatchMedia = (matches: boolean) =>
vi.fn().mockImplementation((query: string) => ({
@ -155,7 +120,6 @@ vi.mock("./orchestrator/usePipelineSources", () => ({
vi.mock("../hooks/useSettings", () => ({
useSettings: () => ({
settings: {
jobspySites: ["indeed", "linkedin"],
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
},
@ -397,11 +361,11 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -417,10 +381,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -438,11 +402,11 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<MemoryRouter initialEntries={["/jobs/all"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -467,10 +431,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -497,13 +461,13 @@ describe("OrchestratorPage", () => {
render(
<MemoryRouter
initialEntries={[
"/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
"/jobs/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
]}
>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -536,11 +500,11 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?q=backend&sort=title-asc"]}>
<MemoryRouter initialEntries={["/jobs/ready?q=backend&sort=title-asc"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -558,11 +522,11 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -579,11 +543,11 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -630,10 +594,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -651,10 +615,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -668,10 +632,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
<MemoryRouter initialEntries={["/jobs/ready?source=ukvisajobs"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -692,10 +656,10 @@ describe("OrchestratorPage", () => {
.mockReturnValue(0 as unknown as NodeJS.Timeout);
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -733,10 +697,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -757,10 +721,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -781,10 +745,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -808,10 +772,10 @@ describe("OrchestratorPage", () => {
};
render(
<MemoryRouter initialEntries={["/ready"]}>
<MemoryRouter initialEntries={["/jobs/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
@ -830,10 +794,10 @@ describe("OrchestratorPage", () => {
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<MemoryRouter initialEntries={["/jobs/all"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
<Route path="/jobs/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);

View File

@ -1,7 +1,3 @@
/**
* Orchestrator layout with a split list/detail experience.
*/
import { useSettings } from "@client/hooks/useSettings";
import {
formatCountryLabel,
@ -32,15 +28,13 @@ import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters";
import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
import {
getEnabledSources,
getJobCounts,
getSourcesWithJobs,
} from "./orchestrator/utils";
const escapeCssAttributeValue = (value: string) =>
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
const navigate = useNavigate();
@ -71,8 +65,8 @@ export const OrchestratorPage: React.FC = () => {
const search = searchParams.toString();
const suffix = search ? `?${search}` : "";
const path = newJobId
? `/${newTab}/${newJobId}${suffix}`
: `/${newTab}${suffix}`;
? `/jobs/${newTab}/${newJobId}${suffix}`
: `/jobs/${newTab}${suffix}`;
navigate(path, { replace: isReplace });
},
[navigate, searchParams],
@ -93,9 +87,6 @@ export const OrchestratorPage: React.FC = () => {
const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [pendingCommandScrollJobId, setPendingCommandScrollJobId] = useState<
string | null
>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
@ -287,9 +278,16 @@ export const OrchestratorPage: React.FC = () => {
}
};
const { requestScrollToJob } = useScrollToJobItem({
activeJobs,
selectedJobId,
isDesktop,
onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true),
});
const handleCommandSelectJob = useCallback(
(targetTab: FilterTab, id: string) => {
setPendingCommandScrollJobId(id);
requestScrollToJob(id, { ensureSelected: true });
const nextParams = new URLSearchParams(searchParams);
for (const key of [
"source",
@ -302,36 +300,14 @@ export const OrchestratorPage: React.FC = () => {
nextParams.delete(key);
}
const query = nextParams.toString();
navigate(`/${targetTab}/${id}${query ? `?${query}` : ""}`);
navigate(`/jobs/${targetTab}/${id}${query ? `?${query}` : ""}`);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
},
[isDesktop, navigate, searchParams],
[isDesktop, navigate, requestScrollToJob, searchParams],
);
useEffect(() => {
if (!pendingCommandScrollJobId) return;
if (selectedJobId !== pendingCommandScrollJobId) return;
const hasPendingTargetInList = activeJobs.some(
(job) => job.id === pendingCommandScrollJobId,
);
if (!hasPendingTargetInList) return;
if (typeof document === "undefined") return;
const selector = `[data-job-id="${escapeCssAttributeValue(
pendingCommandScrollJobId,
)}"]`;
const target = document.querySelector<HTMLElement>(selector);
if (!target) return;
target.scrollIntoView({
behavior: isDesktop ? "smooth" : "auto",
block: "center",
});
setPendingCommandScrollJobId(null);
}, [activeJobs, isDesktop, pendingCommandScrollJobId, selectedJobId]);
useEffect(() => {
if (activeJobs.length === 0) {
if (selectedJobId) handleSelectJobId(null);

View File

@ -1,4 +1,4 @@
import type { AppSettings } from "@shared/types.js";
import { createAppSettings } from "@shared/testing/factories.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
@ -21,28 +21,16 @@ vi.mock("sonner", () => ({
},
}));
const baseSettings: AppSettings = {
const baseSettings = createAppSettings({
model: "google/gemini-3-flash-preview",
defaultModel: "google/gemini-3-flash-preview",
overrideModel: null,
modelScorer: "google/gemini-3-flash-preview",
overrideModelScorer: null,
modelTailoring: "google/gemini-3-flash-preview",
overrideModelTailoring: null,
modelProjectSelection: "google/gemini-3-flash-preview",
overrideModelProjectSelection: null,
llmProvider: "openrouter",
defaultLlmProvider: "openrouter",
overrideLlmProvider: null,
llmBaseUrl: "https://openrouter.ai",
defaultLlmBaseUrl: "https://openrouter.ai",
overrideLlmBaseUrl: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
overridePipelineWebhookUrl: null,
jobCompleteWebhookUrl: "",
defaultJobCompleteWebhookUrl: "",
overrideJobCompleteWebhookUrl: null,
profileProjects: [
{
id: "proj-1",
@ -69,72 +57,15 @@ const baseSettings: AppSettings = {
lockedProjectIds: [],
aiSelectableProjectIds: ["proj-1", "proj-2"],
},
overrideResumeProjects: null,
ukvisajobsMaxJobs: 50,
defaultUkvisajobsMaxJobs: 50,
overrideUkvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: 50,
defaultGradcrackerMaxJobsPerTerm: 50,
overrideGradcrackerMaxJobsPerTerm: null,
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
overrideSearchTerms: null,
jobspyLocation: "UK",
defaultJobspyLocation: "UK",
overrideJobspyLocation: null,
jobspyResultsWanted: 200,
defaultJobspyResultsWanted: 200,
overrideJobspyResultsWanted: null,
jobspyHoursOld: 72,
defaultJobspyHoursOld: 72,
overrideJobspyHoursOld: null,
jobspyCountryIndeed: "UK",
defaultJobspyCountryIndeed: "UK",
overrideJobspyCountryIndeed: null,
jobspySites: ["indeed", "linkedin"],
defaultJobspySites: ["indeed", "linkedin"],
overrideJobspySites: null,
jobspyLinkedinFetchDescription: true,
defaultJobspyLinkedinFetchDescription: true,
overrideJobspyLinkedinFetchDescription: null,
jobspyIsRemote: false,
defaultJobspyIsRemote: false,
overrideJobspyIsRemote: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
llmApiKeyHint: null,
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
basicAuthUser: "",
basicAuthPasswordHint: null,
ukvisajobsEmail: "",
ukvisajobsPasswordHint: null,
webhookSecretHint: null,
basicAuthActive: false,
rxresumeBaseResumeId: null,
// Backup settings
backupEnabled: false,
defaultBackupEnabled: false,
overrideBackupEnabled: null,
backupHour: 2,
defaultBackupHour: 2,
overrideBackupHour: null,
backupMaxCount: 5,
defaultBackupMaxCount: 5,
overrideBackupMaxCount: null,
// Scoring settings
penalizeMissingSalary: false,
defaultPenalizeMissingSalary: false,
overridePenalizeMissingSalary: null,
missingSalaryPenalty: 10,
defaultMissingSalaryPenalty: 10,
overrideMissingSalaryPenalty: null,
autoSkipScoreThreshold: null,
defaultAutoSkipScoreThreshold: null,
overrideAutoSkipScoreThreshold: null,
};
jobspyLocation: "UK",
defaultJobspyLocation: "UK",
searchTerms: ["engineer"],
defaultSearchTerms: ["engineer"],
});
const renderPage = () => {
return render(

View File

@ -32,7 +32,6 @@ import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { arraysEqual } from "@/lib/utils";
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "",
@ -46,18 +45,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
jobCompleteWebhookUrl: "",
resumeProjects: null,
rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
jobspyIsRemote: null,
showSponsorInfo: null,
openrouterApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
basicAuthUser: "",
@ -92,18 +80,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
jobCompleteWebhookUrl: null,
resumeProjects: null,
rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
jobspyIsRemote: null,
showSponsorInfo: null,
openrouterApiKey: null,
rxresumeEmail: null,
rxresumePassword: null,
basicAuthUser: null,
@ -132,18 +109,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
searchTerms: data.overrideSearchTerms,
jobspyLocation: data.overrideJobspyLocation,
jobspyResultsWanted: data.overrideJobspyResultsWanted,
jobspyHoursOld: data.overrideJobspyHoursOld,
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
jobspySites: data.overrideJobspySites,
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
jobspyIsRemote: data.overrideJobspyIsRemote,
showSponsorInfo: data.overrideShowSponsorInfo,
openrouterApiKey: "",
rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "",
basicAuthUser: data.basicAuthUser ?? "",
@ -171,32 +137,9 @@ const normalizePrivateInput = (value: string | null | undefined) => {
return trimmed || undefined;
};
const isSameStringList = (
left: string[] | null | undefined,
right: string[] | null | undefined,
) => {
if (!left && !right) return true;
if (!left || !right) return false;
return arraysEqual(left, right);
};
const isSameSortedStringList = (
left: string[] | null | undefined,
right: string[] | null | undefined,
) => {
if (!left && !right) return true;
if (!left || !right) return false;
return arraysEqual(left.slice().sort(), right.slice().sort());
};
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
value === defaultValue ? null : (value ?? null);
const nullIfSameList = (
value: string[] | null | undefined,
defaultValue: string[],
) => (isSameStringList(value, defaultValue) ? null : (value ?? null));
const normalizeResumeProjectsForCatalog = (
catalog: ResumeProjectCatalogItem[],
current: ResumeProjectsSettings | null,
@ -231,19 +174,6 @@ const normalizeResumeProjectsForCatalog = (
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
};
const nullIfSameSortedList = (
value: string[] | null | undefined,
defaultValue: string[],
) => (isSameSortedStringList(value, defaultValue) ? null : (value ?? null));
const withAlwaysOnGlassdoor = (
sites: string[] | null | undefined,
): string[] => {
const unique = new Set((sites ?? []).filter(Boolean));
unique.add("glassdoor");
return Array.from(unique);
};
const getDerivedSettings = (settings: AppSettings | null) => {
const profileProjects = settings?.profileProjects ?? [];
@ -256,8 +186,7 @@ const getDerivedSettings = (settings: AppSettings | null) => {
projectSelection: settings?.modelProjectSelection ?? "",
llmProvider: settings?.llmProvider ?? "",
llmBaseUrl: settings?.llmBaseUrl ?? "",
llmApiKeyHint:
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null,
llmApiKeyHint: settings?.llmApiKeyHint ?? null,
},
pipelineWebhook: {
effective: settings?.pipelineWebhookUrl ?? "",
@ -267,52 +196,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.jobCompleteWebhookUrl ?? "",
default: settings?.defaultJobCompleteWebhookUrl ?? "",
},
ukvisajobs: {
effective: settings?.ukvisajobsMaxJobs ?? 50,
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
},
gradcracker: {
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
},
searchTerms: {
effective: settings?.searchTerms ?? [],
default: settings?.defaultSearchTerms ?? [],
},
jobspy: {
location: {
effective: settings?.jobspyLocation ?? "",
default: settings?.defaultJobspyLocation ?? "",
},
resultsWanted: {
effective: settings?.jobspyResultsWanted ?? 200,
default: settings?.defaultJobspyResultsWanted ?? 200,
},
hoursOld: {
effective: settings?.jobspyHoursOld ?? 72,
default: settings?.defaultJobspyHoursOld ?? 72,
},
countryIndeed: {
effective: settings?.jobspyCountryIndeed ?? "",
default: settings?.defaultJobspyCountryIndeed ?? "",
},
sites: {
effective: withAlwaysOnGlassdoor(
settings?.jobspySites ?? ["indeed", "linkedin", "glassdoor"],
),
default: withAlwaysOnGlassdoor(
settings?.defaultJobspySites ?? ["indeed", "linkedin", "glassdoor"],
),
},
linkedinFetchDescription: {
effective: settings?.jobspyLinkedinFetchDescription ?? true,
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
},
isRemote: {
effective: settings?.jobspyIsRemote ?? false,
default: settings?.defaultJobspyIsRemote ?? false,
},
},
display: {
effective: settings?.showSponsorInfo ?? true,
default: settings?.defaultShowSponsorInfo ?? true,
@ -324,7 +207,6 @@ const getDerivedSettings = (settings: AppSettings | null) => {
basicAuthUser: settings?.basicAuthUser ?? "",
},
private: {
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
@ -503,10 +385,6 @@ export const SettingsPage: React.FC = () => {
model,
pipelineWebhook,
jobCompleteWebhook,
ukvisajobs,
gradcracker,
searchTerms,
jobspy,
display,
envSettings,
defaultResumeProjects,
@ -635,11 +513,6 @@ export const SettingsPage: React.FC = () => {
}
}
if (dirtyFields.openrouterApiKey) {
const value = normalizePrivateInput(data.openrouterApiKey);
if (value !== undefined) envPayload.openrouterApiKey = value;
}
if (dirtyFields.llmProvider) {
envPayload.llmProvider = data.llmProvider ?? null;
}
@ -677,43 +550,6 @@ export const SettingsPage: React.FC = () => {
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
resumeProjects: resumeProjectsOverride,
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
ukvisajobsMaxJobs: nullIfSame(
data.ukvisajobsMaxJobs,
ukvisajobs.default,
),
gradcrackerMaxJobsPerTerm: nullIfSame(
data.gradcrackerMaxJobsPerTerm,
gradcracker.default,
),
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
jobspyLocation: nullIfSame(
data.jobspyLocation,
jobspy.location.default,
),
jobspyResultsWanted: nullIfSame(
data.jobspyResultsWanted,
jobspy.resultsWanted.default,
),
jobspyHoursOld: nullIfSame(
data.jobspyHoursOld,
jobspy.hoursOld.default,
),
jobspyCountryIndeed: nullIfSame(
data.jobspyCountryIndeed,
jobspy.countryIndeed.default,
),
jobspySites: nullIfSameSortedList(
withAlwaysOnGlassdoor(data.jobspySites),
jobspy.sites.default,
),
jobspyLinkedinFetchDescription: nullIfSame(
data.jobspyLinkedinFetchDescription,
jobspy.linkedinFetchDescription.default,
),
jobspyIsRemote: nullIfSame(
data.jobspyIsRemote,
jobspy.isRemote.default,
),
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
backupEnabled: nullIfSame(
data.backupEnabled,

View File

@ -1,4 +1,4 @@
import type { AppSettings } from "@shared/types";
import { createAppSettings } from "@shared/testing/factories.js";
import { render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
@ -22,12 +22,11 @@ describe("AutomaticRunTab", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us",
} as AppSettings
}
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "us",
jobspyLocation: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
@ -48,12 +47,11 @@ describe("AutomaticRunTab", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
} as AppSettings
}
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
onToggleSource={vi.fn()}
@ -71,16 +69,15 @@ describe("AutomaticRunTab", () => {
expect(screen.getByRole("button", { name: "UK Visa Jobs" })).toBeDisabled();
});
it("shows disabled source guidance copy for UK-only source", () => {
it("shows disabled source guidance copy for UK-only source", async () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
} as AppSettings
}
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united states",
jobspyLocation: "",
})}
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
pipelineSources={["linkedin"]}
onToggleSource={vi.fn()}
@ -103,12 +100,11 @@ describe("AutomaticRunTab", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "japan",
} as AppSettings
}
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "japan",
jobspyLocation: "",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
onToggleSource={vi.fn()}
@ -135,13 +131,11 @@ describe("AutomaticRunTab", () => {
render(
<AutomaticRunTab
open
settings={
{
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
} as AppSettings
}
settings={createAppSettings({
searchTerms: ["backend engineer"],
jobspyCountryIndeed: "united kingdom",
jobspyLocation: "United Kingdom",
})}
enabledSources={["linkedin", "glassdoor"]}
pipelineSources={["linkedin", "glassdoor"]}
onToggleSource={vi.fn()}

View File

@ -1,3 +1,4 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
@ -19,69 +20,6 @@ afterAll(() => {
});
});
const createJob = (overrides: Partial<Job>): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme Labs",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "California",
degreeRequired: null,
starting: null,
jobDescription: null,
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
describe("JobCommandBar", () => {
const openWithKeyboard = () => {
fireEvent.keyDown(window, { key: "k", ctrlKey: true });

View File

@ -1,73 +1,10 @@
import type { Job } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import { describe, expect, it } from "vitest";
import {
computeJobMatchScore,
groupJobsForCommandBar,
} from "./JobCommandBar.utils";
const createJob = (overrides: Partial<Job>): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme Labs",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "California",
degreeRequired: null,
starting: null,
jobDescription: null,
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
describe("JobCommandBar score helpers", () => {
it("returns zero when no title, employer, or location matches", () => {
const score = computeJobMatchScore(

View File

@ -1,3 +1,4 @@
import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js";
import {
act,
@ -134,69 +135,6 @@ vi.mock("sonner", () => ({
},
}));
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: "https://example.com/apply",
disciplines: null,
deadline: "2025-02-01",
salary: "GBP 50k",
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 82,
suitabilityReason: "Strong fit",
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
});
const renderJobDetailPanel = async (
props: React.ComponentProps<typeof JobDetailPanel>,
) => {

View File

@ -1,71 +1,8 @@
import type { Job } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { JobListPanel } from "./JobListPanel";
const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 72,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
...overrides,
});
describe("JobListPanel", () => {
it("shows a loading state when fetching jobs", () => {
render(

View File

@ -1,17 +1,8 @@
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import { PageHeader, StatusIndicator } from "@client/components/layout";
import type { JobSource } from "@shared/types.js";
import { Loader2, Menu, Play, Sparkles, Square } from "lucide-react";
import { Loader2, Play, Square } from "lucide-react";
import type React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
interface OrchestratorHeaderProps {
navOpen: boolean;
@ -32,99 +23,45 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
onOpenAutomaticRun,
onCancelPipeline,
}) => {
const location = useLocation();
const navigate = useNavigate();
const actions = isPipelineRunning ? (
<Button
size="sm"
onClick={onCancelPipeline}
disabled={isCancelling}
variant="destructive"
className="gap-2"
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Square className="h-4 w-4" />
)}
<span className="hidden sm:inline">
{isCancelling ? `Cancelling (${pipelineSources.length})` : `Cancel run`}
</span>
</Button>
) : (
<Button size="sm" onClick={onOpenAutomaticRun} className="gap-2">
<Play className="h-4 w-4" />
<span className="hidden sm:inline">Run pipeline</span>
</Button>
);
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">
<div className="flex items-center gap-3">
<Sheet open={navOpen} onOpenChange={onNavOpenChange}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => {
if (isNavActive(location.pathname, to, activePaths)) {
onNavOpenChange(false);
return;
}
onNavOpenChange(false);
setTimeout(() => navigate(to), 150);
}}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</nav>
</SheetContent>
</Sheet>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">
Job Ops
</div>
<div className="text-xs text-muted-foreground">Orchestrator</div>
</div>
</div>
{isPipelineRunning && (
<span className="hidden sm:inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
Pipeline running
</span>
)}
</div>
<div className="flex items-center gap-2">
{isPipelineRunning ? (
<Button
size="sm"
onClick={onCancelPipeline}
disabled={isCancelling}
variant="destructive"
className="gap-2"
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Square className="h-4 w-4" />
)}
<span className="hidden sm:inline">
{isCancelling
? `Cancelling (${pipelineSources.length})`
: `Cancel run`}
</span>
</Button>
) : (
<Button size="sm" onClick={onOpenAutomaticRun} className="gap-2">
<Play className="h-4 w-4" />
<span className="hidden sm:inline">Run pipeline</span>
</Button>
)}
</div>
</div>
</header>
<PageHeader
icon={() => (
<img src="/favicon.png" alt="" className="size-8 rounded-lg" />
)}
title="Job Ops"
subtitle="Orchestrator"
navOpen={navOpen}
onNavOpenChange={onNavOpenChange}
statusIndicator={
isPipelineRunning ? (
<StatusIndicator label="Pipeline running" variant="amber" />
) : undefined
}
actions={actions}
/>
);
};

View File

@ -1,4 +1,5 @@
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import type { BulkJobActionResponse } from "@shared/types.js";
import { describe, expect, it } from "vitest";
import {
canBulkMoveToReady,
@ -7,96 +8,42 @@ import {
getFailedJobIds,
} from "./bulkActions";
function createJob(id: string, status: JobStatus): Job {
return {
id,
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Role",
employer: "Acme",
employerUrl: null,
jobUrl: `https://example.com/${id}`,
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: null,
status,
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
};
}
describe("bulkActions", () => {
it("computes eligibility for skip, move-to-ready, and rescore", () => {
expect(
canBulkSkip([createJob("1", "discovered"), createJob("2", "ready")]),
canBulkSkip([
createJob({ id: "1", status: "discovered" }),
createJob({ id: "2", status: "ready" }),
]),
).toBe(true);
expect(canBulkSkip([createJob("1", "applied")])).toBe(false);
expect(canBulkSkip([createJob({ id: "1", status: "applied" })])).toBe(
false,
);
expect(
canBulkMoveToReady([
createJob("1", "discovered"),
createJob("2", "discovered"),
createJob({ id: "1", status: "discovered" }),
createJob({ id: "2", status: "discovered" }),
]),
).toBe(true);
expect(canBulkMoveToReady([createJob("1", "ready")])).toBe(false);
expect(canBulkMoveToReady([createJob({ id: "1", status: "ready" })])).toBe(
false,
);
expect(
canBulkRescore([
createJob("1", "discovered"),
createJob("2", "ready"),
createJob("3", "applied"),
createJob("4", "skipped"),
createJob("5", "expired"),
createJob({ id: "1", status: "discovered" }),
createJob({ id: "2", status: "ready" }),
createJob({ id: "3", status: "applied" }),
createJob({ id: "4", status: "skipped" }),
createJob({ id: "5", status: "expired" }),
]),
).toBe(true);
expect(
canBulkRescore([createJob("1", "ready"), createJob("2", "processing")]),
canBulkRescore([
createJob({ id: "1", status: "ready" }),
createJob({ id: "2", status: "processing" }),
]),
).toBe(false);
});
@ -107,7 +54,11 @@ describe("bulkActions", () => {
succeeded: 1,
failed: 2,
results: [
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
{
jobId: "job-1",
ok: true,
job: createJob({ id: "job-1", status: "skipped" }),
},
{
jobId: "job-2",
ok: false,

View File

@ -1,4 +1,5 @@
import type { BulkJobActionResponse, Job, JobStatus } from "@shared/types.js";
import { createJob } from "@shared/testing/factories.js";
import type { BulkJobActionResponse } from "@shared/types.js";
import { act, renderHook, waitFor } from "@testing-library/react";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -16,70 +17,6 @@ vi.mock("sonner", () => ({
},
}));
function createJob(id: string, status: JobStatus): Job {
return {
id,
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: `Role ${id}`,
employer: "Acme",
employerUrl: null,
jobUrl: `https://example.com/${id}`,
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: null,
status,
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
};
}
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
@ -100,7 +37,7 @@ describe("useBulkJobSelection", () => {
it("caps select-all to the API max", () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
createJob(`job-${index + 1}`, "discovered"),
createJob({ id: `job-${index + 1}`, status: "discovered" }),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
@ -120,7 +57,7 @@ describe("useBulkJobSelection", () => {
it("does not send bulk requests above the max selection size", async () => {
const activeJobs = Array.from({ length: 101 }, (_, index) =>
createJob(`job-${index + 1}`, "discovered"),
createJob({ id: `job-${index + 1}`, status: "discovered" }),
);
const loadJobs = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
@ -146,9 +83,9 @@ describe("useBulkJobSelection", () => {
it("reconciles failures with selection changes made during in-flight action", async () => {
const activeJobs = [
createJob("job-1", "discovered"),
createJob("job-2", "discovered"),
createJob("job-3", "discovered"),
createJob({ id: "job-1", status: "discovered" }),
createJob({ id: "job-2", status: "discovered" }),
createJob({ id: "job-3", status: "discovered" }),
];
const loadJobs = vi.fn().mockResolvedValue(undefined);
const pending = deferred<BulkJobActionResponse>();
@ -184,7 +121,11 @@ describe("useBulkJobSelection", () => {
succeeded: 1,
failed: 1,
results: [
{ jobId: "job-1", ok: true, job: createJob("job-1", "skipped") },
{
jobId: "job-1",
ok: true,
job: createJob({ id: "job-1", status: "skipped" }),
},
{
jobId: "job-2",
ok: false,
@ -202,8 +143,8 @@ describe("useBulkJobSelection", () => {
it("runs bulk rescore and reports success copy", async () => {
const activeJobs = [
createJob("job-1", "ready"),
createJob("job-2", "ready"),
createJob({ id: "job-1", status: "ready" }),
createJob({ id: "job-2", status: "ready" }),
];
const loadJobs = vi.fn().mockResolvedValue(undefined);
vi.mocked(api.bulkJobAction).mockResolvedValue({
@ -212,8 +153,16 @@ describe("useBulkJobSelection", () => {
succeeded: 2,
failed: 0,
results: [
{ jobId: "job-1", ok: true, job: createJob("job-1", "ready") },
{ jobId: "job-2", ok: true, job: createJob("job-2", "ready") },
{
jobId: "job-1",
ok: true,
job: createJob({ id: "job-1", status: "ready" }),
},
{
jobId: "job-2",
ok: true,
job: createJob({ id: "job-2", status: "ready" }),
},
],
});

View File

@ -1,69 +1,18 @@
import { createJob } from "@shared/testing/factories";
import type { Job } from "@shared/types";
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useFilteredJobs } from "./useFilteredJobs";
const baseJob: Job = {
const baseJob = createJob({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Desc",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
};
});
describe("useFilteredJobs", () => {
it("filters by sponsor status categories", () => {

View File

@ -0,0 +1,78 @@
import type { JobListItem } from "@shared/types.js";
import { useCallback, useEffect, useState } from "react";
const escapeCssAttributeValue = (value: string) =>
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
type PendingScrollTarget = {
jobId: string;
ensureSelected: boolean;
selectionRequested: boolean;
};
type UseScrollToJobItemParams = {
activeJobs: JobListItem[];
selectedJobId: string | null;
isDesktop: boolean;
onEnsureJobSelected: (jobId: string) => void;
};
export const useScrollToJobItem = ({
activeJobs,
selectedJobId,
isDesktop,
onEnsureJobSelected,
}: UseScrollToJobItemParams) => {
const [pendingTarget, setPendingTarget] =
useState<PendingScrollTarget | null>(null);
const requestScrollToJob = useCallback(
(jobId: string, options?: { ensureSelected?: boolean }) => {
setPendingTarget({
jobId,
ensureSelected: options?.ensureSelected ?? false,
selectionRequested: false,
});
},
[],
);
useEffect(() => {
if (!pendingTarget) return;
if (!activeJobs.some((job) => job.id === pendingTarget.jobId)) return;
if (selectedJobId !== pendingTarget.jobId) {
if (!pendingTarget.ensureSelected || pendingTarget.selectionRequested)
return;
onEnsureJobSelected(pendingTarget.jobId);
setPendingTarget((current) =>
current
? {
...current,
selectionRequested: true,
}
: null,
);
return;
}
if (typeof document === "undefined") return;
const selector = `[data-job-id="${escapeCssAttributeValue(pendingTarget.jobId)}"]`;
const target = document.querySelector<HTMLElement>(selector);
if (!target) return;
target.scrollIntoView({
behavior: isDesktop ? "smooth" : "auto",
block: "center",
});
setPendingTarget(null);
}, [
activeJobs,
isDesktop,
onEnsureJobSelected,
pendingTarget,
selectedJobId,
]);
return { requestScrollToJob };
};

View File

@ -166,7 +166,6 @@ export const getEnabledSources = (
if (!settings) return [...DEFAULT_PIPELINE_SOURCES, "glassdoor"];
const enabled: JobSource[] = [];
const jobspySites = settings.jobspySites ?? [];
const hasUkVisaJobsAuth = Boolean(
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint,
);
@ -185,9 +184,7 @@ export const getEnabledSources = (
source === "linkedin" ||
source === "glassdoor"
) {
if (source === "glassdoor" || jobspySites.includes(source)) {
enabled.push(source);
}
enabled.push(source);
}
}

View File

@ -10,7 +10,6 @@ const EnvironmentSettingsHarness = () => {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
openrouterApiKey: "",
rxresumePassword: "",
ukvisajobsPassword: "",
basicAuthPassword: "",
@ -30,7 +29,6 @@ const EnvironmentSettingsHarness = () => {
basicAuthUser: "admin",
},
private: {
openrouterApiKeyHint: "sk-1",
rxresumePasswordHint: null,
ukvisajobsPasswordHint: "pass",
basicAuthPasswordHint: "abcd",

View File

@ -1,30 +0,0 @@
import type { NumericSettingValues } from "@client/pages/settings/types";
import type React from "react";
import { NumericSettingSection } from "./NumericSettingSection";
type GradcrackerSectionProps = {
values: NumericSettingValues;
isLoading: boolean;
isSaving: boolean;
};
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
return (
<NumericSettingSection
accordionValue="gradcracker"
title="Gradcracker Extractor"
fieldName="gradcrackerMaxJobsPerTerm"
label="Max jobs per search term"
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${values.default}. Range: 1-1000.`}
values={values}
min={1}
max={1000}
isLoading={isLoading}
isSaving={isSaving}
/>
);
};

View File

@ -1,77 +0,0 @@
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import { fireEvent, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { JobspySection } from "./JobspySection";
const JobspyHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
jobspySites: ["indeed", "linkedin", "glassdoor"],
jobspyLocation: "UK",
jobspyResultsWanted: 200,
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
jobspyIsRemote: false,
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
values={{
sites: {
default: ["indeed", "linkedin", "glassdoor"],
effective: ["indeed", "linkedin", "glassdoor"],
},
location: { default: "UK", effective: "UK" },
resultsWanted: { default: 200, effective: 200 },
hoursOld: { default: 72, effective: 72 },
countryIndeed: { default: "UK", effective: "UK" },
linkedinFetchDescription: { default: true, effective: true },
isRemote: { default: false, effective: false },
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
);
};
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />);
const indeedCheckbox = screen.getByLabelText("Indeed");
const linkedinCheckbox = screen.getByLabelText("LinkedIn");
expect(indeedCheckbox).toBeChecked();
expect(linkedinCheckbox).toBeChecked();
expect(screen.queryByLabelText(/glassdoor/i)).not.toBeInTheDocument();
fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).not.toBeChecked();
expect(linkedinCheckbox).toBeChecked();
fireEvent.click(indeedCheckbox);
expect(indeedCheckbox).toBeChecked();
});
it("clamps numeric inputs to allowed ranges", () => {
render(<JobspyHarness />);
const numericInputs = screen.getAllByRole("spinbutton");
const resultsWantedInput = numericInputs[0];
const hoursOldInput = numericInputs[1];
fireEvent.change(resultsWantedInput, { target: { value: "1001" } });
expect(resultsWantedInput).toHaveValue(1000);
fireEvent.change(hoursOldInput, { target: { value: "0" } });
expect(hoursOldInput).toHaveValue(1);
});
});

View File

@ -1,360 +0,0 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { JobspyValues } from "@client/pages/settings/types";
import {
formatCountryLabel,
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "@shared/location-support.js";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
type JobspySectionProps = {
values: JobspyValues;
isLoading: boolean;
isSaving: boolean;
};
export const JobspySection: React.FC<JobspySectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const {
sites,
location,
resultsWanted,
hoursOld,
countryIndeed,
linkedinFetchDescription,
isRemote,
} = values;
const configurableDefaultSites = sites.default.filter(
(site) => site !== "glassdoor",
);
const configurableEffectiveSites = sites.effective.filter(
(site) => site !== "glassdoor",
);
const {
control,
register,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">JobSpy Scraper</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-3">
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-indeed"
checked={
field.value?.includes("indeed") ??
sites.default.includes("indeed")
}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default;
let next = [...current];
if (checked) {
if (!next.includes("indeed")) next.push("indeed");
} else {
next = next.filter((s) => s !== "indeed");
}
field.onChange(next);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label
htmlFor="site-indeed"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Indeed
</label>
</div>
<div className="flex items-center space-x-2">
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-linkedin"
checked={
field.value?.includes("linkedin") ??
sites.default.includes("linkedin")
}
onCheckedChange={(checked) => {
const current = field.value ?? sites.default;
let next = [...current];
if (checked) {
if (!next.includes("linkedin")) next.push("linkedin");
} else {
next = next.filter((s) => s !== "linkedin");
}
field.onChange(next);
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label
htmlFor="site-linkedin"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
LinkedIn
</label>
</div>
</div>
{errors.jobspySites && (
<p className="text-xs text-destructive">
{errors.jobspySites.message}
</p>
)}
<div className="text-xs text-muted-foreground">
Select configurable sites JobSpy should scrape.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>
Effective: {configurableEffectiveSites.join(", ") || "None"}
</span>
<span>Default: {configurableDefaultSites.join(", ")}</span>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<SettingsInput
label="Location"
inputProps={register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyLocation?.message as string | undefined}
helper={
'Location to search for jobs (e.g. "UK", "London", "Remote").'
}
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
/>
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<SettingsInput
label="Results Wanted"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? resultsWanted.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null);
} else {
field.onChange(Math.min(1000, Math.max(1, value)));
}
},
}}
disabled={isLoading || isSaving}
error={
errors.jobspyResultsWanted?.message as string | undefined
}
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
/>
)}
/>
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<SettingsInput
label="Hours Old"
type="number"
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 720,
value: field.value ?? hoursOld.default,
onChange: (event) => {
const value = parseInt(event.target.value, 10);
if (Number.isNaN(value)) {
field.onChange(null);
} else {
field.onChange(Math.min(720, Math.max(1, value)));
}
},
}}
disabled={isLoading || isSaving}
error={errors.jobspyHoursOld?.message as string | undefined}
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
/>
)}
/>
<Controller
name="jobspyCountryIndeed"
control={control}
render={({ field }) => {
const currentValue = (
field.value ??
countryIndeed.default ??
""
).toLowerCase();
const normalizedValue = normalizeCountryKey(currentValue);
const displayValue = SUPPORTED_COUNTRY_KEYS.includes(
normalizedValue,
)
? normalizedValue
: "__default__";
return (
<div className="space-y-2">
<label
htmlFor="jobspyCountryIndeed"
className="text-sm font-medium"
>
Indeed Country
</label>
<Select
value={displayValue}
onValueChange={(value) => {
if (value === "__default__") {
field.onChange(null);
} else {
field.onChange(value);
}
}}
disabled={isLoading || isSaving}
>
<SelectTrigger id="jobspyCountryIndeed">
<SelectValue placeholder="Select a country..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{`Use default (${countryIndeed.default || "UK"})`}
</SelectItem>
{SUPPORTED_COUNTRY_KEYS.map((country) => (
<SelectItem key={country} value={country}>
{formatCountryLabel(country)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.jobspyCountryIndeed && (
<p className="text-xs text-destructive">
{errors.jobspyCountryIndeed.message}
</p>
)}
<div className="text-xs text-muted-foreground">
Select one of JobSpy's supported Indeed country values.
</div>
<div className="text-xs text-muted-foreground">
{`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
</div>
</div>
);
}}
/>
</div>
<Separator />
<div className="flex items-center space-x-2">
<Controller
name="jobspyLinkedinFetchDescription"
control={control}
render={({ field }) => (
<Checkbox
id="linkedin-desc"
checked={field.value ?? linkedinFetchDescription.default}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="linkedin-desc"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Fetch LinkedIn Description
</label>
<p className="text-xs text-muted-foreground">
If enabled, JobSpy will make extra requests to fetch full
descriptions. Slower but better data.
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>
Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}
</span>
<span>
Default: {linkedinFetchDescription.default ? "Yes" : "No"}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Controller
name="jobspyIsRemote"
control={control}
render={({ field }) => (
<Checkbox
id="jobspy-remote"
checked={field.value ?? isRemote.default}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="jobspy-remote"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Remote Jobs?
</label>
<p className="text-xs text-muted-foreground">
Only search for remote job listings
</p>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {isRemote.effective ? "Yes" : "No"}</span>
<span>Default: {isRemote.default ? "Yes" : "No"}</span>
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -1,49 +0,0 @@
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import { fireEvent, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { NumericSettingSection } from "./NumericSettingSection";
const Harness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
ukvisajobsMaxJobs: 50,
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["ukvisajobs"]}>
<NumericSettingSection
accordionValue="ukvisajobs"
title="UKVisaJobs Extractor"
fieldName="ukvisajobsMaxJobs"
label="Max jobs to fetch"
helper="Maximum jobs per run."
values={{ default: 50, effective: 50 }}
min={1}
max={1000}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
);
};
describe("NumericSettingSection", () => {
it("clamps out-of-range values and clears invalid number input", () => {
render(<Harness />);
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "1001" } });
expect(input).toHaveValue(1000);
fireEvent.change(input, { target: { value: "0" } });
expect(input).toHaveValue(1);
fireEvent.change(input, { target: { value: "" } });
expect(input).toHaveValue(50);
});
});

View File

@ -1,91 +0,0 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { NumericSettingValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
type NumericFieldName =
| "ukvisajobsMaxJobs"
| "gradcrackerMaxJobsPerTerm"
| "jobspyResultsWanted"
| "jobspyHoursOld"
| "backupHour"
| "backupMaxCount";
type NumericSettingSectionProps = {
accordionValue: string;
title: string;
fieldName: NumericFieldName;
label: string;
helper: string;
values: NumericSettingValues;
min: number;
max: number;
isLoading: boolean;
isSaving: boolean;
};
export const NumericSettingSection: React.FC<NumericSettingSectionProps> = ({
accordionValue,
title,
fieldName,
label,
helper,
values,
min,
max,
isLoading,
isSaving,
}) => {
const { effective, default: defaultValue } = values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value={accordionValue} className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">{title}</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<Controller
name={fieldName}
control={control}
render={({ field }) => (
<SettingsInput
label={label}
type="number"
inputProps={{
...field,
inputMode: "numeric",
min,
max,
value: field.value ?? defaultValue,
onChange: (event) => {
const parsed = parseInt(event.target.value, 10);
if (Number.isNaN(parsed)) {
field.onChange(null);
return;
}
field.onChange(Math.min(max, Math.max(min, parsed)));
},
}}
disabled={isLoading || isSaving}
error={errors[fieldName]?.message as string | undefined}
helper={helper}
current={String(effective)}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -1,99 +0,0 @@
import type { SearchTermsValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
type SearchTermsSectionProps = {
values: SearchTermsValues;
isLoading: boolean;
isSaving: boolean;
};
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { default: defaultSearchTerms, effective: effectiveSearchTerms } =
values;
const {
control,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Search Terms</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<Controller
name="searchTerms"
control={control}
render={({ field }) => (
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
field.value
? field.value.join("\n")
: (defaultSearchTerms ?? []).join("\n")
}
onChange={(event) => {
const text = event.target.value;
const terms = text.split("\n");
field.onChange(terms);
}}
onBlur={() => {
if (field.value) {
field.onChange(
field.value.map((t) => t.trim()).filter(Boolean),
);
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
)}
/>
{errors.searchTerms && (
<p className="text-xs text-destructive">
{errors.searchTerms.message}
</p>
)}
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported
extractors.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">
{(effectiveSearchTerms || []).join(", ") || "—"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">
{(defaultSearchTerms || []).join(", ") || "—"}
</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -1,30 +0,0 @@
import type { NumericSettingValues } from "@client/pages/settings/types";
import type React from "react";
import { NumericSettingSection } from "./NumericSettingSection";
type UkvisajobsSectionProps = {
values: NumericSettingValues;
isLoading: boolean;
isSaving: boolean;
};
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
return (
<NumericSettingSection
accordionValue="ukvisajobs"
title="UKVisaJobs Extractor"
fieldName="ukvisajobsMaxJobs"
label="Max jobs to fetch"
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${values.default}. Range: 1-1000.`}
values={values}
min={1}
max={1000}
isLoading={isLoading}
isSaving={isSaving}
/>
);
};

View File

@ -13,20 +13,8 @@ export type ModelValues = EffectiveDefault<string> & {
};
export type WebhookValues = EffectiveDefault<string>;
export type NumericSettingValues = EffectiveDefault<number>;
export type SearchTermsValues = EffectiveDefault<string[]>;
export type DisplayValues = EffectiveDefault<boolean>;
export type JobspyValues = {
sites: EffectiveDefault<string[]>;
location: EffectiveDefault<string>;
resultsWanted: EffectiveDefault<number>;
hoursOld: EffectiveDefault<number>;
countryIndeed: EffectiveDefault<string>;
linkedinFetchDescription: EffectiveDefault<boolean>;
isRemote: EffectiveDefault<boolean>;
};
export type EnvSettingsValues = {
readable: {
rxresumeEmail: string;
@ -34,7 +22,6 @@ export type EnvSettingsValues = {
basicAuthUser: string;
};
private: {
openrouterApiKeyHint: string | null;
rxresumePasswordHint: string | null;
ukvisajobsPasswordHint: string | null;
basicAuthPasswordHint: string | null;

View File

@ -93,7 +93,7 @@ describe.sequential("Demo mode API behavior", () => {
}
});
it("simulates apply and does not call Notion in demo mode", async () => {
it("simulates apply in demo mode", async () => {
const { server, baseUrl, closeDb, tempDir } = await startServer({
env: { DEMO_MODE: "true" },
});
@ -124,7 +124,6 @@ describe.sequential("Demo mode API behavior", () => {
expect(body.ok).toBe(true);
expect(body.meta?.simulated).toBe(true);
expect(body.data.status).toBe("applied");
expect(String(body.data.notionPageId)).toMatch(/^demo-notion-/);
} finally {
await stopServer({ server, closeDb, tempDir });
}

View File

@ -451,13 +451,7 @@ describe.sequential("Jobs API routes", () => {
expect(body.meta.requestId).toBeTruthy();
});
it("applies a job and syncs to Notion", async () => {
const { createNotionEntry } = await import("../../services/notion");
vi.mocked(createNotionEntry).mockResolvedValue({
success: true,
pageId: "page-123",
});
it("applies a job", async () => {
const { createJob } = await import("../../repositories/jobs");
const job = await createJob({
source: "manual",
@ -473,15 +467,7 @@ describe.sequential("Jobs API routes", () => {
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.data.status).toBe("applied");
expect(body.data.notionPageId).toBe("page-123");
expect(body.data.appliedAt).toBeTruthy();
expect(createNotionEntry).toHaveBeenCalledWith(
expect.objectContaining({
id: job.id,
title: job.title,
employer: job.employer,
}),
);
});
it("rescoring a job updates the suitability fields", async () => {

View File

@ -39,7 +39,6 @@ import {
simulateRescoreJob,
simulateSummarizeJob,
} from "../../services/demo-simulator";
import { createNotionEntry } from "../../services/notion";
import { getProfile } from "../../services/profile";
import { scoreJobSuitability } from "../../services/scorer";
import * as visaSponsors from "../../services/visa-sponsors/index";
@ -906,7 +905,7 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
});
/**
* POST /api/jobs/:id/apply - Mark a job as applied and sync to Notion
* POST /api/jobs/:id/apply - Mark a job as applied
*/
jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
try {
@ -924,19 +923,6 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
const appliedAtDate = new Date();
const appliedAt = appliedAtDate.toISOString();
// Sync to Notion
const notionResult = await createNotionEntry({
id: job.id,
title: job.title,
employer: job.employer,
applicationLink: job.applicationLink,
deadline: job.deadline,
salary: job.salary,
location: job.location,
pdfPath: job.pdfPath,
appliedAt,
});
transitionStage(
job.id,
"applied",
@ -948,11 +934,9 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
null,
);
// Update job status + Notion metadata
const updatedJob = await jobsRepo.updateJob(job.id, {
status: "applied",
appliedAt,
notionPageId: notionResult.pageId,
});
if (updatedJob) {

View File

@ -11,7 +11,7 @@ describe.sequential("Settings API routes", () => {
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: {
OPENROUTER_API_KEY: "secret-key",
LLM_API_KEY: "secret-key",
RXRESUME_EMAIL: "resume@example.com",
},
}));
@ -29,7 +29,6 @@ describe.sequential("Settings API routes", () => {
expect(Array.isArray(body.data.searchTerms)).toBe(true);
expect(body.data.rxresumeEmail).toBe("resume@example.com");
expect(body.data.llmApiKeyHint).toBe("secr");
expect(body.data.openrouterApiKeyHint).toBe("secr");
expect(body.data.basicAuthActive).toBe(false);
});
@ -47,7 +46,7 @@ describe.sequential("Settings API routes", () => {
body: JSON.stringify({
searchTerms: ["engineer"],
rxresumeEmail: "updated@example.com",
openrouterApiKey: "updated-secret",
llmApiKey: "updated-secret",
}),
});
const patchBody = await patchRes.json();
@ -56,7 +55,6 @@ describe.sequential("Settings API routes", () => {
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
expect(patchBody.data.llmApiKeyHint).toBe("upda");
expect(patchBody.data.openrouterApiKeyHint).toBe("upda");
});
it("validates basic auth requirements", async () => {

View File

@ -51,10 +51,6 @@ vi.mock("../../pipeline/index", () => {
};
});
vi.mock("../../services/notion", () => ({
createNotionEntry: vi.fn(),
}));
vi.mock("../../services/manualJob", () => ({
inferManualJobDetails: vi.fn(),
}));

View File

@ -26,11 +26,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
backupMaxCount: "5",
jobspyLocation: "United States",
jobspyResultsWanted: "25",
jobspyHoursOld: "72",
jobspyCountryIndeed: "US",
jobspySites: JSON.stringify(["linkedin", "indeed", "glassdoor"]),
jobspyLinkedinFetchDescription: "1",
jobspyIsRemote: "0",
resumeProjects: JSON.stringify({
maxProjects: 3,
lockedProjectIds: ["demo-project-1"],
@ -279,7 +275,6 @@ export interface DemoDefaultJob {
tailoredSkills?: string[];
selectedProjectIds?: string;
pdfPath?: string;
notionPageId?: string;
appliedOffsetMinutes?: number;
}
@ -452,7 +447,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
tailoredSkills: ["TypeScript", "Architecture", "Mentorship", "SRE"],
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-1.pdf",
notionPageId: "demo-notion-applied-1",
},
{
id: "demo-job-applied-2",
@ -478,7 +472,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
tailoredSkills: ["Data Pipelines", "TypeScript", "SQL", "Observability"],
selectedProjectIds: "demo-project-1,demo-project-2,demo-project-4",
pdfPath: "/pdfs/demo-job-applied-2.pdf",
notionPageId: "demo-notion-applied-2",
},
{
id: "demo-job-applied-3",
@ -504,7 +497,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
tailoredSkills: ["System Design", "Team Leadership", "TypeScript", "APIs"],
selectedProjectIds: "demo-project-2,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-3.pdf",
notionPageId: "demo-notion-applied-3",
},
{
id: "demo-job-applied-4",
@ -530,7 +522,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
tailoredSkills: ["APIs", "Testing", "TypeScript", "CI/CD"],
selectedProjectIds: "demo-project-2,demo-project-3,demo-project-4",
pdfPath: "/pdfs/demo-job-applied-4.pdf",
notionPageId: "demo-notion-applied-4",
},
{
id: "demo-job-applied-5",
@ -556,7 +547,6 @@ export const DEMO_BASE_JOBS: DemoDefaultJob[] = [
tailoredSkills: ["Reliability", "Node.js", "TypeScript", "Operations"],
selectedProjectIds: "demo-project-1,demo-project-4,demo-project-5",
pdfPath: "/pdfs/demo-job-applied-5.pdf",
notionPageId: "demo-notion-applied-5",
},
{
id: "demo-job-skipped-1",

View File

@ -141,7 +141,6 @@ function buildGeneratedJob(
| "tailoredSkills"
| "selectedProjectIds"
| "pdfPath"
| "notionPageId"
>;
if (status === "applied") {
@ -163,7 +162,6 @@ function buildGeneratedJob(
tailoredSkills: ["TypeScript", "Node.js", "APIs", "Observability"],
selectedProjectIds: PROJECT_ID_SETS[idx % PROJECT_ID_SETS.length],
pdfPath: `/pdfs/demo-job-applied-auto-${n}.pdf`,
notionPageId: `demo-notion-applied-auto-${n}`,
};
}

View File

@ -68,7 +68,6 @@ const migrations = [
suitability_reason TEXT,
tailored_summary TEXT,
pdf_path TEXT,
notion_page_id TEXT,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT,
applied_at TEXT,
@ -131,6 +130,15 @@ const migrations = [
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
`DELETE FROM settings WHERE key = 'webhookUrl'`,
// Drop legacy settings keys that are no longer read by the app.
`DELETE FROM settings
WHERE key IN (
'jobspyHoursOld',
'jobspySites',
'jobspyLinkedinFetchDescription',
'jobspyIsRemote',
'openrouterApiKey'
)`,
// Add source column for existing databases (safe to skip if already present)
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,

View File

@ -92,7 +92,6 @@ export const jobs = sqliteTable("jobs", {
tailoredSkills: text("tailored_skills"),
selectedProjectIds: text("selected_project_ids"),
pdfPath: text("pdf_path"),
notionPageId: text("notion_page_id"),
sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"),

View File

@ -5,6 +5,7 @@
* correctly calculates and stores sponsor match scores and names.
*/
import { createJob as createBaseJob } from "@shared/testing/factories";
import type { Job } from "@shared/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -53,69 +54,22 @@ vi.mock("../services/ukvisajobs", () => ({
const now = new Date().toISOString();
// Mock job template
const createMockJob = (overrides: Partial<Job> = {}): Job => ({
id: "test-job-1",
source: "gradcracker",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Software Engineer",
employer: "Acme Corporation Ltd",
employerUrl: null,
jobUrl: "http://test.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Looking for a TypeScript developer.",
status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: now,
processedAt: null,
appliedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
});
const createJob = (overrides: Partial<Job> = {}): Job =>
createBaseJob({
id: "test-job-1",
source: "gradcracker",
title: "Software Engineer",
employer: "Acme Corporation Ltd",
location: "London",
jobDescription: "Looking for a TypeScript developer.",
status: "discovered",
suitabilityScore: null,
suitabilityReason: null,
discoveredAt: now,
createdAt: now,
updatedAt: now,
...overrides,
});
describe("Sponsor Match Calculation", () => {
let searchSponsors: ReturnType<typeof vi.fn>;
@ -171,7 +125,7 @@ describe("Sponsor Match Calculation", () => {
describe("searchSponsors integration", () => {
it("should calculate sponsor match score when employer matches a sponsor", async () => {
const mockJob = createMockJob({ employer: "Acme Corporation Ltd" });
const mockJob = createJob({ employer: "Acme Corporation Ltd" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
// Mock sponsor search returning a match
@ -206,7 +160,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should handle 100% perfect matches correctly", async () => {
const mockJob = createMockJob({ employer: "Microsoft UK" });
const mockJob = createJob({ employer: "Microsoft UK" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
// Mock sponsor search returning perfect matches
@ -245,7 +199,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should report single top match when no perfect matches exist", async () => {
const mockJob = createMockJob({ employer: "Tech Corp" });
const mockJob = createJob({ employer: "Tech Corp" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
// Mock sponsor search returning partial matches only
@ -276,7 +230,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should not set sponsor match when no matches found", async () => {
const mockJob = createMockJob({ employer: "Unknown Company XYZ" });
const mockJob = createJob({ employer: "Unknown Company XYZ" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
// Mock sponsor search returning no matches
@ -302,7 +256,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should skip sponsor matching when job has no employer", async () => {
const mockJob = createMockJob({ employer: null as unknown as string });
const mockJob = createJob({ employer: null as unknown as string });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
const { runPipeline } = await import("./orchestrator");
@ -322,7 +276,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should skip sponsor matching when job has empty employer string", async () => {
const mockJob = createMockJob({ employer: "" });
const mockJob = createJob({ employer: "" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
const { runPipeline } = await import("./orchestrator");
@ -335,7 +289,7 @@ describe("Sponsor Match Calculation", () => {
describe("sponsor match edge cases", () => {
it("should use correct limit and minScore options", async () => {
const mockJob = createMockJob({ employer: "Test Company" });
const mockJob = createJob({ employer: "Test Company" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
searchSponsors.mockReturnValue([]);
@ -349,7 +303,7 @@ describe("Sponsor Match Calculation", () => {
});
it("should handle single 100% match correctly", async () => {
const mockJob = createMockJob({ employer: "Google UK" });
const mockJob = createJob({ employer: "Google UK" });
getUnscoredDiscoveredJobs.mockResolvedValue([mockJob]);
searchSponsors.mockReturnValue([
@ -374,11 +328,11 @@ describe("Sponsor Match Calculation", () => {
});
it("should process multiple jobs with different sponsor matches", async () => {
const mockJob1 = createMockJob({
const mockJob1 = createJob({
id: "job-1",
employer: "Amazon UK",
});
const mockJob2 = createMockJob({
const mockJob2 = createJob({
id: "job-2",
employer: "Meta Platforms",
});

View File

@ -40,14 +40,13 @@ describe("discoverJobsStep", () => {
resetProgress();
});
it("applies jobspySites setting and aggregates source errors", async () => {
it("aggregates source errors for enabled sources", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
const ukVisa = await import("../../services/ukvisajobs");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspySites: JSON.stringify(["linkedin"]),
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
@ -72,7 +71,7 @@ describe("discoverJobsStep", () => {
expect(result.discoveredJobs).toHaveLength(1);
expect(result.sourceErrors).toEqual(["ukvisajobs: login failed"]);
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
expect.objectContaining({ sites: ["linkedin"] }),
expect.objectContaining({ sites: ["indeed", "linkedin"] }),
);
});
@ -82,7 +81,6 @@ describe("discoverJobsStep", () => {
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspySites: JSON.stringify(["glassdoor"]),
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
@ -110,32 +108,6 @@ describe("discoverJobsStep", () => {
);
});
it("keeps glassdoor enabled even when jobspySites override omits it", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]),
jobspySites: JSON.stringify(["linkedin"]),
} as any);
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
success: true,
jobs: [],
} as any);
await discoverJobsStep({
mergedConfig: {
...config,
sources: ["glassdoor", "linkedin"],
},
});
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
expect.objectContaining({ sites: ["glassdoor", "linkedin"] }),
);
});
it("filters out glassdoor for unsupported countries", async () => {
const settingsRepo = await import("../../repositories/settings");
const jobSpy = await import("../../services/jobspy");
@ -231,7 +203,6 @@ describe("discoverJobsStep", () => {
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer", "frontend"]),
jobspySites: JSON.stringify(["linkedin"]),
} as any);
vi.mocked(jobSpy.runJobSpy).mockImplementation(async (options: any) => {

View File

@ -66,25 +66,11 @@ export async function discoverJobsStep(args: {
);
}
let jobSpySites = compatibleSources.filter(
const jobSpySites = compatibleSources.filter(
(source): source is "indeed" | "linkedin" | "glassdoor" =>
source === "indeed" || source === "linkedin" || source === "glassdoor",
);
const jobspySitesSettingRaw = settings.jobspySites;
if (jobspySitesSettingRaw) {
try {
const allowed = JSON.parse(jobspySitesSettingRaw);
if (Array.isArray(allowed)) {
jobSpySites = jobSpySites.filter(
(site) => site === "glassdoor" || allowed.includes(site),
);
}
} catch {
// ignore JSON parse error
}
}
const shouldRunJobSpy = jobSpySites.length > 0;
const shouldRunGradcracker = compatibleSources.includes("gradcracker");
const shouldRunUkVisaJobs = compatibleSources.includes("ukvisajobs");
@ -119,20 +105,7 @@ export async function discoverJobsStep(args: {
resultsWanted: settings.jobspyResultsWanted
? parseInt(settings.jobspyResultsWanted, 10)
: undefined,
hoursOld: settings.jobspyHoursOld
? parseInt(settings.jobspyHoursOld, 10)
: undefined,
countryIndeed: settings.jobspyCountryIndeed ?? undefined,
linkedinFetchDescription:
settings.jobspyLinkedinFetchDescription !== null &&
settings.jobspyLinkedinFetchDescription !== undefined
? settings.jobspyLinkedinFetchDescription === "1"
: undefined,
isRemote:
settings.jobspyIsRemote !== null &&
settings.jobspyIsRemote !== undefined
? settings.jobspyIsRemote === "1"
: undefined,
onProgress: (event) => {
if (event.type === "term_start") {
progressHelpers.crawlingUpdate({

View File

@ -1,4 +1,4 @@
import type { Job } from "@shared/types";
import { createJob } from "@shared/testing/factories";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { scoreJobsStep } from "./score-jobs";
@ -36,18 +36,6 @@ vi.mock("../progress", () => ({
},
}));
function createMockJob(overrides: Partial<Job> = {}): Job {
return {
id: "job-1",
title: "Software Engineer",
employer: "Acme Corp",
status: "discovered",
suitabilityScore: null,
suitabilityReason: null,
...overrides,
} as Job;
}
describe("scoreJobsStep auto-skip behavior", () => {
beforeEach(async () => {
vi.clearAllMocks();
@ -58,7 +46,13 @@ describe("scoreJobsStep auto-skip behavior", () => {
const visaSponsors = await import("../../services/visa-sponsors/index");
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
createMockJob(),
createJob({
title: "Software Engineer",
employer: "Acme Corp",
status: "discovered",
suitabilityScore: null,
suitabilityReason: null,
}),
]);
vi.mocked(jobsRepo.updateJob).mockResolvedValue(null);
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
@ -164,7 +158,14 @@ describe("scoreJobsStep auto-skip behavior", () => {
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
createMockJob({ id: "job-applied", status: "applied" }),
createJob({
id: "job-applied",
status: "applied",
title: "Software Engineer",
employer: "Acme Corp",
suitabilityScore: null,
suitabilityReason: null,
}),
]);
await scoreJobsStep({ profile: {} });

View File

@ -1,2 +0,0 @@
export * from "./jobs";
export * from "./pipeline";

View File

@ -373,7 +373,6 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
tailoredSkills: row.tailoredSkills ?? null,
selectedProjectIds: row.selectedProjectIds ?? null,
pdfPath: row.pdfPath,
notionPageId: row.notionPageId,
sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null,
jobType: row.jobType ?? null,

View File

@ -24,13 +24,8 @@ export type SettingKey =
| "searchTerms"
| "jobspyLocation"
| "jobspyResultsWanted"
| "jobspyHoursOld"
| "jobspyCountryIndeed"
| "jobspySites"
| "jobspyLinkedinFetchDescription"
| "jobspyIsRemote"
| "showSponsorInfo"
| "openrouterApiKey"
| "rxresumeEmail"
| "rxresumePassword"
| "basicAuthUser"

View File

@ -1,4 +1,4 @@
import type { Job } from "@shared/types";
import { createJob } from "@shared/testing/factories";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pickProjectIdsForJob } from "./projectSelection";
import { scoreJobSuitability } from "./scorer";
@ -12,70 +12,16 @@ vi.mock("../repositories/settings", () => ({
// We need to mock 'fetch' globally for these tests
const globalFetch = global.fetch;
const now = new Date().toISOString();
// A simple mock job
const mockJob: Job = {
const mockJob = createJob({
id: "test-job",
source: "gradcracker",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Senior Engineer",
employer: "Test Corp",
employerUrl: null,
jobUrl: "http://test.com",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: "Looking for a TypeScript and React expert.",
status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: now,
processedAt: null,
appliedAt: null,
createdAt: now,
updatedAt: now,
};
});
const mockProfile = { name: "Test User" };

View File

@ -117,7 +117,6 @@ describe.sequential("demo seed baseline", () => {
source: schema.jobs.source,
title: schema.jobs.title,
employer: schema.jobs.employer,
notionPageId: schema.jobs.notionPageId,
})
.from(schema.jobs),
db

View File

@ -66,7 +66,6 @@ export function buildDemoBaseline(now: Date): BuiltDemoBaseline {
: null,
selectedProjectIds: job.selectedProjectIds ?? null,
pdfPath: job.pdfPath ?? null,
notionPageId: job.notionPageId ?? null,
discoveredAt: toIsoFromOffset(now, job.discoveredOffsetMinutes),
appliedAt:
job.status === "applied" && typeof job.appliedOffsetMinutes === "number"

View File

@ -151,7 +151,6 @@ export async function simulateApplyJob(jobId: string): Promise<Job> {
const updated = await jobsRepo.updateJob(job.id, {
status: "applied",
appliedAt: appliedAtDate.toISOString(),
notionPageId: `demo-notion-${job.id.slice(0, 8)}`,
});
if (!updated) throw new Error("Job not found");
return updated;

View File

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const originalEnv = { ...process.env };
describe.sequential("envSettings migration", () => {
describe.sequential("envSettings overrides", () => {
let tempDir: string;
let closeDb: (() => void) | null = null;
@ -17,6 +17,7 @@ describe.sequential("envSettings migration", () => {
DATA_DIR: tempDir,
NODE_ENV: "test",
MODEL: "test-model",
LLM_API_KEY: "sk-env-default",
};
await import("../db/migrate");
@ -30,34 +31,26 @@ describe.sequential("envSettings migration", () => {
process.env = { ...originalEnv };
});
it("migrates stored openrouterApiKey -> llmApiKey for openrouter provider", async () => {
it("applies stored llmApiKey override to process env", async () => {
const settingsRepo = await import("../repositories/settings");
const { applyStoredEnvOverrides } = await import("./envSettings");
await settingsRepo.setSetting("llmProvider", "openrouter");
await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy");
await settingsRepo.setSetting("llmApiKey", null);
await settingsRepo.setSetting("llmApiKey", "sk-db-override");
await applyStoredEnvOverrides();
expect(await settingsRepo.getSetting("llmApiKey")).toBe("sk-or-legacy");
expect(await settingsRepo.getSetting("openrouterApiKey")).toBe(null);
expect(process.env.LLM_API_KEY).toBe("sk-or-legacy");
expect(await settingsRepo.getSetting("llmApiKey")).toBe("sk-db-override");
expect(process.env.LLM_API_KEY).toBe("sk-db-override");
});
it("does not migrate openrouterApiKey when provider is not openrouter", async () => {
it("restores default env value when override is explicitly cleared", async () => {
const settingsRepo = await import("../repositories/settings");
const { applyStoredEnvOverrides } = await import("./envSettings");
await settingsRepo.setSetting("llmProvider", "openai");
await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy");
await settingsRepo.setSetting("llmApiKey", null);
await settingsRepo.setSetting("llmApiKey", "");
await applyStoredEnvOverrides();
expect(await settingsRepo.getSetting("llmApiKey")).toBe(null);
expect(await settingsRepo.getSetting("openrouterApiKey")).toBe(
"sk-or-legacy",
);
expect(process.env.LLM_API_KEY).toBe("sk-env-default");
});
});

View File

@ -27,11 +27,6 @@ const privateStringConfig: {
envKey: "LLM_API_KEY",
hintKey: "llmApiKeyHint",
},
{
settingKey: "openrouterApiKey",
envKey: "OPENROUTER_API_KEY",
hintKey: "openrouterApiKeyHint",
},
{
settingKey: "rxresumePassword",
envKey: "RXRESUME_PASSWORD",
@ -103,56 +98,6 @@ export async function applyStoredEnvOverrides(): Promise<void> {
}
};
const safeSetSetting = async (key: SettingKey, value: string | null) => {
try {
await settingsRepo.setSetting(key, value);
} catch (error) {
const msg = String((error as Error)?.message ?? error);
if (msg.includes("no such table") && msg.includes("settings")) return;
throw error;
}
};
// Migration: move legacy OpenRouter key to the unified LLM key.
//
// Users only see their API keys once. If we simply switch to LLM_API_KEY without
// copying, they may be unable to recover their existing key.
const providerOverride = await safeGetSetting("llmProvider");
const legacyOpenrouterKey = normalizeEnvInput(
await safeGetSetting("openrouterApiKey"),
);
const unifiedKey = normalizeEnvInput(await safeGetSetting("llmApiKey"));
const effectiveProvider = (providerOverride ?? process.env.LLM_PROVIDER)
?.trim()
.toLowerCase();
if (
(effectiveProvider ?? "openrouter") === "openrouter" &&
legacyOpenrouterKey &&
!unifiedKey
) {
console.warn(
"[DEPRECATED] Detected stored OpenRouter API key. Migrating to LLM_API_KEY and clearing legacy storage.",
);
await safeSetSetting("llmApiKey", legacyOpenrouterKey);
await safeSetSetting("openrouterApiKey", null);
}
// Migration helper for env-based users: copy OPENROUTER_API_KEY -> LLM_API_KEY
// at runtime so the app keeps working after removing fallback logic.
if (
(effectiveProvider ?? "openrouter") === "openrouter" &&
!normalizeEnvInput(process.env.LLM_API_KEY) &&
normalizeEnvInput(process.env.OPENROUTER_API_KEY)
) {
console.warn(
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY for compatibility.",
);
const normalizedKey = normalizeEnvInput(process.env.OPENROUTER_API_KEY);
if (normalizedKey) process.env.LLM_API_KEY = normalizedKey;
}
await Promise.all([
...readableStringConfig.map(async ({ settingKey, envKey }) => {
const override = await safeGetSetting(settingKey);
@ -207,12 +152,6 @@ export async function getEnvSettingsData(
privateValues[hintKey] = rawValue.slice(0, hintLength);
}
// Backwards-compat: old clients still expect openrouterApiKeyHint.
// Always prefer the unified LLM key hint when present.
if (privateValues.llmApiKeyHint) {
privateValues.openrouterApiKeyHint = privateValues.llmApiKeyHint;
}
const basicAuthUser =
activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER;
const basicAuthPassword =

View File

@ -1,7 +0,0 @@
export * from "./crawler";
export * from "./jobspy";
export * from "./notion";
export * from "./pdf";
export * from "./profile";
export * from "./scorer";
export * from "./summary";

View File

@ -1,97 +0,0 @@
/**
* Service for syncing with Notion.
*/
export interface NotionSyncResult {
success: boolean;
pageId?: string;
error?: string;
}
/**
* Create a job entry in Notion.
*
* This is a placeholder - implement based on your Notion setup.
*/
export async function createNotionEntry(job: {
id: string;
title: string;
employer: string;
applicationLink: string | null;
deadline: string | null;
salary: string | null;
location: string | null;
pdfPath: string | null;
appliedAt: string;
}): Promise<NotionSyncResult> {
const notionApiKey = process.env.NOTION_API_KEY;
const notionDatabaseId = process.env.NOTION_DATABASE_ID;
if (!notionApiKey || !notionDatabaseId) {
console.log(" Notion API not configured, skipping sync");
return { success: true, pageId: undefined };
}
try {
const response = await fetch("https://api.notion.com/v1/pages", {
method: "POST",
headers: {
Authorization: `Bearer ${notionApiKey}`,
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
},
body: JSON.stringify({
parent: { database_id: notionDatabaseId },
properties: {
// Customize these based on your Notion database schema
Name: {
title: [{ text: { content: `${job.title} @ ${job.employer}` } }],
},
Company: {
rich_text: [{ text: { content: job.employer } }],
},
"Application Link": job.applicationLink
? {
url: job.applicationLink,
}
: undefined,
Deadline: job.deadline
? {
date: { start: job.deadline },
}
: undefined,
Salary: job.salary
? {
rich_text: [{ text: { content: job.salary } }],
}
: undefined,
Location: job.location
? {
rich_text: [{ text: { content: job.location } }],
}
: undefined,
"Applied Date": {
date: { start: job.appliedAt },
},
Status: {
status: { name: "Applied" },
},
},
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Notion API error: ${response.status} - ${error}`);
}
const data = await response.json();
console.log(`✅ Created Notion entry: ${data.id}`);
return { success: true, pageId: data.id };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(`❌ Notion sync failed: ${message}`);
return { success: false, error: message };
}
}

View File

@ -2,7 +2,7 @@
* Tests for scorer.ts - focusing on robust JSON parsing from AI responses
*/
import type { Job } from "@shared/types";
import { createJob } from "@shared/testing/factories";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseJsonFromContent } from "./scorer";
@ -254,72 +254,6 @@ This score reflects the candidate's technical capabilities while accounting for
});
});
// Helper to create minimal test job
function createTestJob(overrides: Partial<Job> = {}): Job {
return {
id: "test-job-1",
source: "gradcracker",
sourceJobId: "ext-1",
jobUrlDirect: null,
datePosted: null,
title: "Software Engineer",
employer: "Test Company",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: null,
degreeRequired: null,
starting: null,
jobDescription: "A test job",
status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: new Date().toISOString(),
processedAt: null,
appliedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("salary penalty", () => {
let getEffectiveSettingsMock: ReturnType<typeof vi.fn>;
let getSettingMock: ReturnType<typeof vi.fn>;
@ -365,7 +299,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70); // 80 - 10
@ -388,7 +326,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "" });
const job = createJob({
id: "test-job-1",
salary: "",
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70);
@ -409,7 +351,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: " " });
const job = createJob({
id: "test-job-1",
salary: " ",
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(70);
@ -430,7 +376,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "Competitive" });
const job = createJob({
id: "test-job-1",
salary: "Competitive",
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty
@ -451,7 +401,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: "£40,000 - £50,000" });
const job = createJob({
id: "test-job-1",
salary: "£40,000 - £50,000",
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty
@ -474,7 +428,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No penalty when disabled
@ -495,7 +453,11 @@ describe("salary penalty", () => {
data: { score: 50, reason: "Average match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(0); // Clamped, not negative
@ -516,7 +478,11 @@ describe("salary penalty", () => {
data: { score: 5, reason: "Weak match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(0); // 5 - 10 = -5, clamped to 0
@ -537,7 +503,11 @@ describe("salary penalty", () => {
data: { score: 80, reason: "Good match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(80); // No change with 0 penalty
@ -558,7 +528,11 @@ describe("salary penalty", () => {
data: { score: 90, reason: "Excellent match" },
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.score).toBe(65); // 90 - 25
@ -584,7 +558,11 @@ describe("salary penalty", () => {
error: "API key not configured",
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
// Mock score base is 50, with keyword bonuses from "Software Engineer"
@ -607,7 +585,11 @@ describe("salary penalty", () => {
error: "API key not configured",
});
const job = createTestJob({ salary: null });
const job = createJob({
id: "test-job-1",
salary: null,
title: "Software Engineer",
});
const result = await scoreJobSuitability(job, {});
expect(result.reason).not.toContain("missing salary");

View File

@ -26,13 +26,13 @@ describe("settings-conversion", () => {
});
it("round-trips boolean bit settings", () => {
expect(serializeSettingValue("jobspyIsRemote", true)).toBe("1");
expect(serializeSettingValue("jobspyIsRemote", false)).toBe("0");
expect(serializeSettingValue("showSponsorInfo", true)).toBe("1");
expect(serializeSettingValue("showSponsorInfo", false)).toBe("0");
expect(resolveSettingValue("jobspyIsRemote", "1").value).toBe(true);
expect(resolveSettingValue("jobspyIsRemote", "0").value).toBe(false);
expect(resolveSettingValue("jobspyIsRemote", "true").value).toBe(true);
expect(resolveSettingValue("jobspyIsRemote", "false").value).toBe(false);
expect(resolveSettingValue("showSponsorInfo", "1").value).toBe(true);
expect(resolveSettingValue("showSponsorInfo", "0").value).toBe(false);
expect(resolveSettingValue("showSponsorInfo", "true").value).toBe(true);
expect(resolveSettingValue("showSponsorInfo", "false").value).toBe(false);
});
it("round-trips JSON array settings", () => {
@ -79,26 +79,6 @@ describe("settings-conversion", () => {
expect(malformedOverride.value).toEqual(["web developer"]);
});
it("always includes glassdoor in resolved jobspySites", () => {
delete process.env.JOBSPY_SITES;
expect(resolveSettingValue("jobspySites", undefined).value).toEqual([
"indeed",
"linkedin",
"glassdoor",
]);
process.env.JOBSPY_SITES = "indeed,linkedin";
expect(resolveSettingValue("jobspySites", undefined).value).toEqual([
"indeed",
"linkedin",
"glassdoor",
]);
expect(
resolveSettingValue("jobspySites", JSON.stringify(["linkedin"])).value,
).toEqual(["linkedin", "glassdoor"]);
});
it("round-trips penalizeMissingSalary boolean setting", () => {
expect(serializeSettingValue("penalizeMissingSalary", true)).toBe("1");
expect(serializeSettingValue("penalizeMissingSalary", false)).toBe("0");

View File

@ -11,11 +11,7 @@ type SettingsConversionValueMap = {
searchTerms: string[];
jobspyLocation: string;
jobspyResultsWanted: number;
jobspyHoursOld: number;
jobspyCountryIndeed: string;
jobspySites: string[];
jobspyLinkedinFetchDescription: boolean;
jobspyIsRemote: boolean;
showSponsorInfo: boolean;
backupEnabled: boolean;
backupHour: number;
@ -57,24 +53,6 @@ function parseJsonArrayOrNull(raw: string | undefined): string[] | null {
}
}
function normalizeJobspySites(value: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const site of value) {
const trimmed = site.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
normalized.push(trimmed);
}
if (!seen.has("glassdoor")) {
normalized.push("glassdoor");
}
return normalized;
}
function parseBitBoolOrNull(raw: string | undefined): boolean | null {
if (!raw) return null;
return raw === "true" || raw === "1";
@ -147,41 +125,12 @@ export const settingsConversionMetadata: SettingsConversionMetadata = {
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
jobspyHoursOld: {
defaultValue: () => parseInt(process.env.JOBSPY_HOURS_OLD || "72", 10),
parseOverride: parseIntOrNull,
serialize: serializeNullableNumber,
resolve: resolveWithNullishFallback,
},
jobspyCountryIndeed: {
defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK",
parseOverride: (raw) => raw ?? null,
serialize: (value) => value ?? null,
resolve: resolveWithEmptyStringFallback,
},
jobspySites: {
defaultValue: () =>
normalizeJobspySites(
(process.env.JOBSPY_SITES || "indeed,linkedin,glassdoor").split(","),
),
parseOverride: parseJsonArrayOrNull,
serialize: serializeNullableJsonArray,
resolve: ({ defaultValue, overrideValue }) =>
normalizeJobspySites(overrideValue ?? defaultValue),
},
jobspyLinkedinFetchDescription: {
defaultValue: () =>
(process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1",
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
jobspyIsRemote: {
defaultValue: () => (process.env.JOBSPY_IS_REMOTE || "0") === "1",
parseOverride: parseBitBoolOrNull,
serialize: serializeBitBool,
resolve: resolveWithNullishFallback,
},
showSponsorInfo: {
defaultValue: () => true,
parseOverride: parseBitBoolOrNull,

View File

@ -36,17 +36,15 @@ describe("applySettingsUpdates", () => {
model: "gpt-4o-mini",
ukvisajobsMaxJobs: 42,
searchTerms: ["backend", "platform"],
jobspyIsRemote: true,
llmProvider: "openai",
});
expect(settingsRepo.setSetting).toHaveBeenCalledTimes(5);
expect(settingsRepo.setSetting).toHaveBeenCalledTimes(4);
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
expect.arrayContaining([
["model", "gpt-4o-mini"],
["ukvisajobsMaxJobs", "42"],
["searchTerms", '["backend","platform"]'],
["jobspyIsRemote", "1"],
["llmProvider", "openai"],
]),
);
@ -57,33 +55,6 @@ describe("applySettingsUpdates", () => {
expect(plan.shouldRefreshBackupScheduler).toBe(false);
});
it("handles deprecated openrouterApiKey migration path", async () => {
const settingsRepo = await import("@server/repositories/settings");
const envSettings = await import("@server/services/envSettings");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await applySettingsUpdates({
openrouterApiKey: " legacy-key ",
});
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
expect.arrayContaining([
["llmApiKey", "legacy-key"],
["openrouterApiKey", null],
]),
);
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
"LLM_API_KEY",
"legacy-key",
);
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
"OPENROUTER_API_KEY",
null,
);
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
});
it("marks backup scheduler refresh when backup settings are changed", async () => {
const settingsRepo = await import("@server/repositories/settings");

View File

@ -9,7 +9,6 @@ export type {
} from "./registry";
export {
settingsUpdateRegistry,
toBitBoolOrNull,
toJsonOrNull,
toNormalizedStringOrNull,
toNumberStringOrNull,

View File

@ -55,12 +55,6 @@ export function toJsonOrNull<T>(value: T | null | undefined): string | null {
return value !== null && value !== undefined ? JSON.stringify(value) : null;
}
export function toBitBoolOrNull(
value: boolean | null | undefined,
): string | null {
return serializeSettingValue("jobspyIsRemote", value);
}
function result(
args: {
actions?: SettingsUpdateAction[];
@ -186,48 +180,14 @@ export const settingsUpdateRegistry: Partial<{
actions: [metadataPersistAction("jobspyResultsWanted", value)],
}),
),
jobspyHoursOld: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("jobspyHoursOld", value)],
}),
),
jobspyCountryIndeed: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspyCountryIndeed", value)] }),
),
jobspySites: singleAction(({ value }) =>
result({ actions: [metadataPersistAction("jobspySites", value)] }),
),
jobspyLinkedinFetchDescription: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("jobspyLinkedinFetchDescription", value)],
}),
),
jobspyIsRemote: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("jobspyIsRemote", value)],
}),
),
showSponsorInfo: singleAction(({ value }) =>
result({
actions: [metadataPersistAction("showSponsorInfo", value)],
}),
),
openrouterApiKey: singleAction(({ value }) => {
console.warn(
"[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.",
);
const normalized = toNormalizedStringOrNull(value);
return result({
actions: [
persistAction("llmApiKey", normalized, () => {
applyEnvValue("LLM_API_KEY", normalized);
}),
persistAction("openrouterApiKey", null, () => {
applyEnvValue("OPENROUTER_API_KEY", null);
}),
],
});
}),
llmApiKey: singleAction(({ value }) => {
const normalized = toNormalizedStringOrNull(value);
return result({

View File

@ -130,14 +130,6 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideJobspyResultsWanted = jobspyResultsWantedSetting.overrideValue;
const jobspyResultsWanted = jobspyResultsWantedSetting.value;
const jobspyHoursOldSetting = resolveSettingValue(
"jobspyHoursOld",
overrides.jobspyHoursOld,
);
const defaultJobspyHoursOld = jobspyHoursOldSetting.defaultValue;
const overrideJobspyHoursOld = jobspyHoursOldSetting.overrideValue;
const jobspyHoursOld = jobspyHoursOldSetting.value;
const jobspyCountryIndeedSetting = resolveSettingValue(
"jobspyCountryIndeed",
overrides.jobspyCountryIndeed,
@ -146,33 +138,6 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideJobspyCountryIndeed = jobspyCountryIndeedSetting.overrideValue;
const jobspyCountryIndeed = jobspyCountryIndeedSetting.value;
const jobspySitesSetting = resolveSettingValue(
"jobspySites",
overrides.jobspySites,
);
const defaultJobspySites = jobspySitesSetting.defaultValue;
const overrideJobspySites = jobspySitesSetting.overrideValue;
const jobspySites = jobspySitesSetting.value;
const jobspyLinkedinFetchDescriptionSetting = resolveSettingValue(
"jobspyLinkedinFetchDescription",
overrides.jobspyLinkedinFetchDescription,
);
const defaultJobspyLinkedinFetchDescription =
jobspyLinkedinFetchDescriptionSetting.defaultValue;
const overrideJobspyLinkedinFetchDescription =
jobspyLinkedinFetchDescriptionSetting.overrideValue;
const jobspyLinkedinFetchDescription =
jobspyLinkedinFetchDescriptionSetting.value;
const jobspyIsRemoteSetting = resolveSettingValue(
"jobspyIsRemote",
overrides.jobspyIsRemote,
);
const defaultJobspyIsRemote = jobspyIsRemoteSetting.defaultValue;
const overrideJobspyIsRemote = jobspyIsRemoteSetting.overrideValue;
const jobspyIsRemote = jobspyIsRemoteSetting.value;
const showSponsorInfoSetting = resolveSettingValue(
"showSponsorInfo",
overrides.showSponsorInfo,
@ -274,21 +239,9 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
jobspyResultsWanted,
defaultJobspyResultsWanted,
overrideJobspyResultsWanted,
jobspyHoursOld,
defaultJobspyHoursOld,
overrideJobspyHoursOld,
jobspyCountryIndeed,
defaultJobspyCountryIndeed,
overrideJobspyCountryIndeed,
jobspySites,
defaultJobspySites,
overrideJobspySites,
jobspyLinkedinFetchDescription,
defaultJobspyLinkedinFetchDescription,
overrideJobspyLinkedinFetchDescription,
jobspyIsRemote,
defaultJobspyIsRemote,
overrideJobspyIsRemote,
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,

899
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,13 @@
"check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types",
"check:all": "./orchestrator/node_modules/.bin/biome ci .",
"format:all": "./orchestrator/node_modules/.bin/biome format . --write",
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types"
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types",
"knip": "knip"
},
"devDependencies": {
"tsx": "^4.19.2"
"@types/node": "^25.2.3",
"knip": "^5.83.1",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
}
}

View File

@ -52,18 +52,8 @@ export const updateSettingsSchema = z
.max(1000)
.nullable()
.optional(),
jobspyHoursOld: z.number().int().min(1).max(720).nullable().optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
jobspySites: z
.array(z.string().trim().min(1).max(50))
.max(20)
.nullable()
.optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
jobspyIsRemote: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
/** @deprecated Use llmApiKey instead. */
openrouterApiKey: z.string().trim().max(2000).nullable().optional(),
rxresumeEmail: z.string().trim().max(200).nullable().optional(),
rxresumePassword: z.string().trim().max(2000).nullable().optional(),
basicAuthUser: z.string().trim().max(200).nullable().optional(),

View File

@ -0,0 +1,210 @@
import type {
ApplicationTask,
AppSettings,
Job,
PipelineRun,
ResumeProjectCatalogItem,
StageEvent,
} from "../types";
export const createJob = (overrides: Partial<Job> = {}): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme Labs",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: "https://example.com/apply",
disciplines: null,
deadline: null,
salary: null,
location: "California",
degreeRequired: null,
starting: null,
jobDescription: "Job description content",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: "Strong fit",
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
export const createStageEvent = (
overrides: Partial<StageEvent> = {},
): StageEvent => ({
id: "event-1",
applicationId: "job-1",
title: "Moved to applied",
groupId: null,
fromStage: null,
toStage: "applied",
occurredAt: Date.now(),
metadata: null,
outcome: null,
...overrides,
});
export const createApplicationTask = (
overrides: Partial<ApplicationTask> = {},
): ApplicationTask => ({
id: "task-1",
applicationId: "job-1",
type: "todo",
title: "Follow up",
dueDate: null,
isCompleted: false,
notes: null,
...overrides,
});
export const createPipelineRun = (
overrides: Partial<PipelineRun> = {},
): PipelineRun => ({
id: "run-1",
startedAt: "2025-01-01T00:00:00Z",
completedAt: null,
status: "running",
jobsDiscovered: 0,
jobsProcessed: 0,
errorMessage: null,
...overrides,
});
export const createResumeProjectCatalogItem = (
overrides: Partial<ResumeProjectCatalogItem> = {},
): ResumeProjectCatalogItem => ({
id: "p1",
name: "Project 1",
description: "Description 1",
date: "2024",
isVisibleInBase: true,
...overrides,
});
export const createAppSettings = (
overrides: Partial<AppSettings> = {},
): AppSettings => ({
model: "gpt-4o",
defaultModel: "gpt-4o",
overrideModel: null,
modelScorer: "gpt-4o",
overrideModelScorer: null,
modelTailoring: "gpt-4o",
overrideModelTailoring: null,
modelProjectSelection: "gpt-4o",
overrideModelProjectSelection: null,
llmProvider: "openai",
defaultLlmProvider: "openai",
overrideLlmProvider: null,
llmBaseUrl: "https://api.openai.com/v1",
defaultLlmBaseUrl: "https://api.openai.com/v1",
overrideLlmBaseUrl: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
overridePipelineWebhookUrl: null,
jobCompleteWebhookUrl: "",
defaultJobCompleteWebhookUrl: "",
overrideJobCompleteWebhookUrl: null,
profileProjects: [],
resumeProjects: {
maxProjects: 3,
lockedProjectIds: [],
aiSelectableProjectIds: [],
},
defaultResumeProjects: {
maxProjects: 3,
lockedProjectIds: [],
aiSelectableProjectIds: [],
},
overrideResumeProjects: null,
rxresumeBaseResumeId: null,
ukvisajobsMaxJobs: 50,
defaultUkvisajobsMaxJobs: 50,
overrideUkvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: 50,
defaultGradcrackerMaxJobsPerTerm: 50,
overrideGradcrackerMaxJobsPerTerm: null,
searchTerms: ["Software Engineer"],
defaultSearchTerms: ["Software Engineer"],
overrideSearchTerms: null,
jobspyLocation: "United Kingdom",
defaultJobspyLocation: "United Kingdom",
overrideJobspyLocation: null,
jobspyResultsWanted: 20,
defaultJobspyResultsWanted: 20,
overrideJobspyResultsWanted: null,
jobspyCountryIndeed: "united kingdom",
defaultJobspyCountryIndeed: "united kingdom",
overrideJobspyCountryIndeed: null,
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
llmApiKeyHint: null,
rxresumeEmail: null,
rxresumePasswordHint: null,
basicAuthUser: null,
basicAuthPasswordHint: null,
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
webhookSecretHint: null,
basicAuthActive: false,
backupEnabled: false,
defaultBackupEnabled: false,
overrideBackupEnabled: null,
backupHour: 3,
defaultBackupHour: 3,
overrideBackupHour: null,
backupMaxCount: 7,
defaultBackupMaxCount: 7,
overrideBackupMaxCount: null,
penalizeMissingSalary: false,
defaultPenalizeMissingSalary: false,
overridePenalizeMissingSalary: null,
missingSalaryPenalty: 10,
defaultMissingSalaryPenalty: 10,
overrideMissingSalaryPenalty: null,
autoSkipScoreThreshold: null,
defaultAutoSkipScoreThreshold: null,
overrideAutoSkipScoreThreshold: null,
...overrides,
});

View File

@ -6,7 +6,7 @@ export type JobStatus =
| "discovered" // Crawled but not processed
| "processing" // Currently generating resume
| "ready" // PDF generated, waiting for user to apply
| "applied" // User marked as applied (added to Notion)
| "applied" // User marked as applied
| "skipped" // User skipped this job
| "expired"; // Deadline passed
@ -160,7 +160,6 @@ export interface Job {
tailoredSkills: string | null; // Generated resume skills (JSON)
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF
notionPageId: string | null; // Notion page ID if synced
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
@ -314,7 +313,6 @@ export interface UpdateJobInput {
tailoredSkills?: string;
selectedProjectIds?: string;
pdfPath?: string;
notionPageId?: string;
appliedAt?: string;
sponsorMatchScore?: number;
sponsorMatchNames?: string;
@ -601,27 +599,13 @@ export interface AppSettings {
jobspyResultsWanted: number;
defaultJobspyResultsWanted: number;
overrideJobspyResultsWanted: number | null;
jobspyHoursOld: number;
defaultJobspyHoursOld: number;
overrideJobspyHoursOld: number | null;
jobspyCountryIndeed: string;
defaultJobspyCountryIndeed: string;
overrideJobspyCountryIndeed: string | null;
jobspySites: string[];
defaultJobspySites: string[];
overrideJobspySites: string[] | null;
jobspyLinkedinFetchDescription: boolean;
defaultJobspyLinkedinFetchDescription: boolean;
overrideJobspyLinkedinFetchDescription: boolean | null;
jobspyIsRemote: boolean;
defaultJobspyIsRemote: boolean;
overrideJobspyIsRemote: boolean | null;
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;
llmApiKeyHint: string | null;
/** @deprecated Use llmApiKeyHint instead. */
openrouterApiKeyHint: string | null;
rxresumeEmail: string | null;
rxresumePasswordHint: string | null;
basicAuthUser: string | null;