diff --git a/.codex/skills/design-principles/SKILL.md b/.codex/skills/design-principles/SKILL.md index 70ddb8b..baea126 100644 --- a/.codex/skills/design-principles/SKILL.md +++ b/.codex/skills/design-principles/SKILL.md @@ -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. \ No newline at end of file +The goal: intricate minimalism with appropriate personality. Same quality bar, context-driven execution. diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index e4cfc5e..43601ad 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -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) diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..6295ecb --- /dev/null +++ b/knip.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "tags": ["-lintignore"], + "workspaces": { + ".": {} + } +} diff --git a/orchestrator/README.md b/orchestrator/README.md index 9ad0cda..d501d04 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -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 diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 62b35ed..5209f52 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -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(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 = () => { >
- } /> - } /> + {/* Backwards-compatibility redirects */} + {REDIRECTS.map(({ from, to }) => ( + } + /> + ))} + + {/* Application routes */} + } /> } /> } /> } /> - } /> - } /> + } /> + } + />
diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index e916651..5d39c25 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { return fetchApi("/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 { +export async function updateSettings( + update: Partial, +): Promise { return fetchApi("/settings", { method: "PATCH", body: JSON.stringify(update), diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx deleted file mode 100644 index 1ab32db..0000000 --- a/orchestrator/src/client/components/Header.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
-
-
- - - - - - - JobOps - - - - - - -
- Job Ops Logo -
-
-
- Job Ops -
-
Orchestrator
-
- -
- -
- - -
- - - - - - - - Sources - - {orderedSources.map((source) => ( - - toggleSource(source, Boolean(checked)) - } - onSelect={(e) => e.preventDefault()} - > - {sourceLabel[source]} - - ))} - - { - e.preventDefault(); - onPipelineSourcesChange(orderedSources); - }} - > - All sources - - { - e.preventDefault(); - onPipelineSourcesChange(["gradcracker"]); - }} - > - Gradcracker only - - { - e.preventDefault(); - onPipelineSourcesChange(["indeed", "linkedin"]); - }} - > - Indeed + LinkedIn only - - - -
-
-
-
- ); -}; diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx index a24cac0..899f7b4 100644 --- a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx @@ -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 => - ({ - 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(); diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx index 18435f3..efb9db4 100644 --- a/orchestrator/src/client/components/JobHeader.test.tsx +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -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) => diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index bcc3eff..32e8b13 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -90,7 +90,6 @@ const settingsResponse = { settings: { llmProvider: "openrouter", llmApiKeyHint: null, - openrouterApiKeyHint: null, rxresumeEmail: "", rxresumePasswordHint: null, rxresumeBaseResumeId: null, diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 999a2fe..a40066d 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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 = { llmProvider: normalizedProvider, llmBaseUrl: showBaseUrl ? baseUrlValue || null : null, }; diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx index 513eea2..64dc99e 100644 --- a/orchestrator/src/client/components/ReadyPanel.test.tsx +++ b/orchestrator/src/client/components/ReadyPanel.test.tsx @@ -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 => ({ - 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(); diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index a43d68d..9ed54b8 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -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 => - ({ + createBaseJob({ id: "job-1", tailoredSummary: "Saved summary", tailoredHeadline: "Saved headline", @@ -29,7 +30,7 @@ const createJob = (overrides: Partial = {}): Job => jobDescription: "Saved description", selectedProjectIds: "p1", ...overrides, - }) as Job; + }); const ensureAccordionOpen = (name: string) => { const trigger = screen.getByRole("button", { name }); diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx index 725bc61..613dcd1 100644 --- a/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx +++ b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx @@ -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", () => { diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx index b9fa6b6..ee94cd9 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx @@ -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 => ({ - 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( diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index d2ac723..a2cc16e 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -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 => - ({ + createBaseJob({ id: "job-1", tailoredSummary: "Saved summary", tailoredHeadline: "Saved headline", @@ -28,7 +29,7 @@ const createJob = (overrides: Partial = {}): Job => jobDescription: "Saved description", selectedProjectIds: "p1", ...overrides, - }) as Job; + }); const ensureAccordionOpen = (name: string) => { const trigger = screen.getByRole("button", { name }); diff --git a/orchestrator/src/client/components/discovered-panel/index.ts b/orchestrator/src/client/components/discovered-panel/index.ts deleted file mode 100644 index 3c809c0..0000000 --- a/orchestrator/src/client/components/discovered-panel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DiscoveredPanel } from "./DiscoveredPanel"; diff --git a/orchestrator/src/client/components/index.ts b/orchestrator/src/client/components/index.ts index 9c51cf3..dbacefa 100644 --- a/orchestrator/src/client/components/index.ts +++ b/orchestrator/src/client/components/index.ts @@ -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"; diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx index 30a9d61..e693862 100644 --- a/orchestrator/src/client/components/layout.tsx +++ b/orchestrator/src/client/components/layout.tsx @@ -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 = ({ @@ -46,10 +49,15 @@ export const PageHeader: React.FC = ({ 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 = ({ ))} - + {showVersionFooter && ( + + )} diff --git a/orchestrator/src/client/components/navigation.ts b/orchestrator/src/client/components/navigation.ts index 0f3f2cb..0507e6e 100644 --- a/orchestrator/src/client/components/navigation.ts +++ b/orchestrator/src/client/components/navigation.ts @@ -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}/`), + ); +}; diff --git a/orchestrator/src/client/pages/HomePage.tsx b/orchestrator/src/client/pages/HomePage.tsx index 207be3e..79b1cd7 100644 --- a/orchestrator/src/client/pages/HomePage.tsx +++ b/orchestrator/src/client/pages/HomePage.tsx @@ -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([]); const [appliedDates, setAppliedDates] = useState>([]); 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 */} -
-
-
- - - - - - - JobOps - - - - - -
- -
-
-
Home
-
- Applications over the last {duration} days -
-
-
- -
- -
-
-
+ + } + /> 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( - + - } /> - } /> + } /> + } /> , ); @@ -417,10 +381,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -438,11 +402,11 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -467,10 +431,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -497,13 +461,13 @@ describe("OrchestratorPage", () => { render( - } /> - } /> + } /> + } /> , ); @@ -536,11 +500,11 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -558,11 +522,11 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -579,11 +543,11 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -630,10 +594,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -651,10 +615,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -668,10 +632,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> + } /> , ); @@ -692,10 +656,10 @@ describe("OrchestratorPage", () => { .mockReturnValue(0 as unknown as NodeJS.Timeout); render( - + - } /> - } /> + } /> + } /> , ); @@ -733,10 +697,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -757,10 +721,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -781,10 +745,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); @@ -808,10 +772,10 @@ describe("OrchestratorPage", () => { }; render( - + - } /> - } /> + } /> + } /> , ); @@ -830,10 +794,10 @@ describe("OrchestratorPage", () => { ) as unknown as typeof window.matchMedia; render( - + - } /> - } /> + } /> + } /> , ); diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 507cb30..132a596 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -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("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(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); diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 1385252..322e04c 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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( diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index e355f9d..938a18e 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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 = (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, diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx index c50141c..bd28f73 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx @@ -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( { render( { 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( { render( { render( { }); }); -const createJob = (overrides: Partial): 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 }); diff --git a/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.test.ts b/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.test.ts index eb2b84b..0e064d4 100644 --- a/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.test.ts +++ b/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.test.ts @@ -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 => ({ - 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( diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 50c4e0b..7ed2748 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -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 => ({ - 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, ) => { diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx index 4bbc8b1..42c31f5 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx @@ -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 => ({ - 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( diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx index e11e852..c5e876e 100644 --- a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx +++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx @@ -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 = ({ onOpenAutomaticRun, onCancelPipeline, }) => { - const location = useLocation(); - const navigate = useNavigate(); + const actions = isPipelineRunning ? ( + + ) : ( + + ); + return ( -
-
-
- - - - - - - JobOps - - - - - -
-
- -
-
-
- Job Ops -
-
Orchestrator
-
-
- - {isPipelineRunning && ( - - - Pipeline running - - )} -
- -
- {isPipelineRunning ? ( - - ) : ( - - )} -
-
-
+ ( + + )} + title="Job Ops" + subtitle="Orchestrator" + navOpen={navOpen} + onNavOpenChange={onNavOpenChange} + statusIndicator={ + isPipelineRunning ? ( + + ) : undefined + } + actions={actions} + /> ); }; diff --git a/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts b/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts index 0e7696b..2f13c87 100644 --- a/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts +++ b/orchestrator/src/client/pages/orchestrator/bulkActions.test.ts @@ -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, diff --git a/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts index 7f1544c..ec43a22 100644 --- a/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useBulkJobSelection.test.ts @@ -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 = { promise: Promise; 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(); @@ -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" }), + }, ], }); diff --git a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts index a01acca..a7b1d18 100644 --- a/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts +++ b/orchestrator/src/client/pages/orchestrator/useFilteredJobs.test.ts @@ -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", () => { diff --git a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts new file mode 100644 index 0000000..7e2e6bb --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts @@ -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(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(selector); + if (!target) return; + + target.scrollIntoView({ + behavior: isDesktop ? "smooth" : "auto", + block: "center", + }); + setPendingTarget(null); + }, [ + activeJobs, + isDesktop, + onEnsureJobSelected, + pendingTarget, + selectedJobId, + ]); + + return { requestScrollToJob }; +}; diff --git a/orchestrator/src/client/pages/orchestrator/utils.ts b/orchestrator/src/client/pages/orchestrator/utils.ts index 7ac7157..d79be01 100644 --- a/orchestrator/src/client/pages/orchestrator/utils.ts +++ b/orchestrator/src/client/pages/orchestrator/utils.ts @@ -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); } } diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index 84ee8f8..dd522fb 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -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", diff --git a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx deleted file mode 100644 index 24bd104..0000000 --- a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx +++ /dev/null @@ -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 = ({ - values, - isLoading, - isSaving, -}) => { - return ( - - ); -}; diff --git a/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx b/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx deleted file mode 100644 index 32246fa..0000000 --- a/orchestrator/src/client/pages/settings/components/JobspySection.test.tsx +++ /dev/null @@ -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({ - defaultValues: { - jobspySites: ["indeed", "linkedin", "glassdoor"], - jobspyLocation: "UK", - jobspyResultsWanted: 200, - jobspyHoursOld: 72, - jobspyCountryIndeed: "UK", - jobspyLinkedinFetchDescription: true, - jobspyIsRemote: false, - }, - }); - - return ( - - - - - - ); -}; - -describe("JobspySection", () => { - it("toggles scraped sites and keeps checkboxes in sync", () => { - render(); - - 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(); - - 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); - }); -}); diff --git a/orchestrator/src/client/pages/settings/components/JobspySection.tsx b/orchestrator/src/client/pages/settings/components/JobspySection.tsx deleted file mode 100644 index 77c1e62..0000000 --- a/orchestrator/src/client/pages/settings/components/JobspySection.tsx +++ /dev/null @@ -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 = ({ - 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(); - - return ( - - - JobSpy Scraper - - -
-
-
Scraped Sites
-
-
- ( - { - 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} - /> - )} - /> - -
-
- ( - { - 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} - /> - )} - /> - -
-
- {errors.jobspySites && ( -

- {errors.jobspySites.message} -

- )} -
- Select configurable sites JobSpy should scrape. -
-
- - Effective: {configurableEffectiveSites.join(", ") || "None"} - - Default: {configurableDefaultSites.join(", ")} -
-
- -
- - - ( - { - 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}`} - /> - )} - /> - - ( - { - 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`} - /> - )} - /> - - { - const currentValue = ( - field.value ?? - countryIndeed.default ?? - "" - ).toLowerCase(); - const normalizedValue = normalizeCountryKey(currentValue); - const displayValue = SUPPORTED_COUNTRY_KEYS.includes( - normalizedValue, - ) - ? normalizedValue - : "__default__"; - - return ( -
- - - {errors.jobspyCountryIndeed && ( -

- {errors.jobspyCountryIndeed.message} -

- )} -
- Select one of JobSpy's supported Indeed country values. -
-
- {`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`} -
-
- ); - }} - /> -
- - - -
- ( - field.onChange(!!checked)} - disabled={isLoading || isSaving} - /> - )} - /> -
- -

- If enabled, JobSpy will make extra requests to fetch full - descriptions. Slower but better data. -

-
- - Effective: {linkedinFetchDescription.effective ? "Yes" : "No"} - - - Default: {linkedinFetchDescription.default ? "Yes" : "No"} - -
-
-
- -
- ( - field.onChange(!!checked)} - disabled={isLoading || isSaving} - /> - )} - /> -
- -

- Only search for remote job listings -

-
- Effective: {isRemote.effective ? "Yes" : "No"} - Default: {isRemote.default ? "Yes" : "No"} -
-
-
-
-
-
- ); -}; diff --git a/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx b/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx deleted file mode 100644 index cb27bf3..0000000 --- a/orchestrator/src/client/pages/settings/components/NumericSettingSection.test.tsx +++ /dev/null @@ -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({ - defaultValues: { - ukvisajobsMaxJobs: 50, - }, - }); - - return ( - - - - - - ); -}; - -describe("NumericSettingSection", () => { - it("clamps out-of-range values and clears invalid number input", () => { - render(); - - 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); - }); -}); diff --git a/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx b/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx deleted file mode 100644 index f165478..0000000 --- a/orchestrator/src/client/pages/settings/components/NumericSettingSection.tsx +++ /dev/null @@ -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 = ({ - accordionValue, - title, - fieldName, - label, - helper, - values, - min, - max, - isLoading, - isSaving, -}) => { - const { effective, default: defaultValue } = values; - const { - control, - formState: { errors }, - } = useFormContext(); - - return ( - - - {title} - - -
- ( - { - 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)} - /> - )} - /> -
-
-
- ); -}; diff --git a/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx b/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx deleted file mode 100644 index 5a9d34b..0000000 --- a/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx +++ /dev/null @@ -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 = ({ - values, - isLoading, - isSaving, -}) => { - const { default: defaultSearchTerms, effective: effectiveSearchTerms } = - values; - const { - control, - formState: { errors }, - } = useFormContext(); - - return ( - - - Search Terms - - -
-
-
Global search terms
- ( -