Timeline introduced (#38)

* initial implementation

* onboarding doesn't pop until invalid values are present

* link to job page

* proactive inputs working slightly

* onboarding gate reinstated

* better proactive buttons

* fully manual tracking for now.

* edit and delete timeline events

* status showing correctly

* tests update

* tests

* Update orchestrator/src/server/services/applicationTracking.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update orchestrator/src/server/services/applicationTracking.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update orchestrator/src/server/services/applicationTracking.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update orchestrator/src/client/pages/job/Timeline.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update orchestrator/src/client/pages/JobPage.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add tests for application tracking routes and remove unused actionId from client API

* remove unnecessary await from synchronous transitionStage calls and improve test isolation

* relax externalUrl validation to allow non-URL metadata

* add toast notifications for data loading and event logging in JobPage

* comments

* fix: resolve type error in sponsor-matching.test.ts

* fix ci

* tests fix for github

* lint

* github comments

* build fix

* dedupe

* format

* types fix

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* formatting

* title and group id are discrete fields

* backfill

* hide view button on page

* show relevant dropdown options

* confetti!

* remove redundant

* confirm delete is a custom element now

* formatting

* fix styling

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shaheer Sarfaraz 2026-01-27 23:49:11 +00:00 committed by GitHub
parent ad6a79e74f
commit 6e771ce728
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2682 additions and 34 deletions

View File

@ -13,6 +13,7 @@
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@ -24,7 +25,9 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -1857,6 +1860,7 @@
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.3", "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
@ -3533,6 +3537,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"license": "MIT"
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -4326,6 +4336,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",

View File

@ -31,6 +31,7 @@
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@ -42,7 +43,9 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@ -1,4 +1,4 @@
/** /**
* Main App component. * Main App component.
*/ */
@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate"; import { OnboardingGate } from "./components/OnboardingGate";
import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage"; import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
@ -40,6 +41,7 @@ export const App: React.FC = () => {
<div ref={nodeRef}> <div ref={nodeRef}>
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} /> <Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} /> <Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} /> <Route path="/visa-sponsors" element={<VisaSponsorsPage />} />

View File

@ -5,9 +5,12 @@
import { trackEvent } from "@/lib/analytics"; import { trackEvent } from "@/lib/analytics";
import type { import type {
ApiResponse, ApiResponse,
ApplicationStage,
ApplicationTask,
AppSettings, AppSettings,
CreateJobInput, CreateJobInput,
Job, Job,
JobOutcome,
JobSource, JobSource,
JobsListResponse, JobsListResponse,
ManualJobDraft, ManualJobDraft,
@ -18,6 +21,9 @@ import type {
ResumeProfile, ResumeProfile,
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
ResumeProjectsSettings, ResumeProjectsSettings,
StageEvent,
StageEventMetadata,
StageTransitionTarget,
UkVisaJobsImportResponse, UkVisaJobsImportResponse,
UkVisaJobsSearchResponse, UkVisaJobsSearchResponse,
ValidationResult, ValidationResult,
@ -67,7 +73,7 @@ export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
} }
export async function getJob(id: string): Promise<Job> { export async function getJob(id: string): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}`); return fetchApi<Job>(`/jobs/${id}?t=${Date.now()}`);
} }
export async function updateJob( export async function updateJob(
@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise<Job> {
}); });
} }
export async function getJobStageEvents(id: string): Promise<StageEvent[]> {
return fetchApi<StageEvent[]>(`/jobs/${id}/events?t=${Date.now()}`);
}
export async function getJobTasks(
id: string,
options?: { includeCompleted?: boolean },
): Promise<ApplicationTask[]> {
const params = new URLSearchParams();
if (options?.includeCompleted) params.set("includeCompleted", "1");
params.set("t", Date.now().toString());
const query = params.toString();
return fetchApi<ApplicationTask[]>(`/jobs/${id}/tasks?${query}`);
}
export async function transitionJobStage(
id: string,
input: {
toStage: StageTransitionTarget;
occurredAt?: number | null;
metadata?: StageEventMetadata | null;
outcome?: JobOutcome | null;
},
): Promise<StageEvent> {
return fetchApi<StageEvent>(`/jobs/${id}/stages`, {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateJobStageEvent(
id: string,
eventId: string,
input: {
toStage?: ApplicationStage;
occurredAt?: number | null;
metadata?: StageEventMetadata | null;
outcome?: JobOutcome | null;
},
): Promise<void> {
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function deleteJobStageEvent(
id: string,
eventId: string,
): Promise<void> {
return fetchApi<void>(`/jobs/${id}/events/${eventId}`, {
method: "DELETE",
});
}
export async function updateJobOutcome(
id: string,
input: { outcome: JobOutcome | null; closedAt?: number | null },
): Promise<Job> {
return fetchApi<Job>(`/jobs/${id}/outcome`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
// Pipeline API // Pipeline API
export async function getPipelineStatus(): Promise<PipelineStatusResponse> { export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
return fetchApi<PipelineStatusResponse>("/pipeline/status"); return fetchApi<PipelineStatusResponse>("/pipeline/status");

View File

@ -0,0 +1,50 @@
import type React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmDeleteProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
description?: string;
}
export const ConfirmDelete: React.FC<ConfirmDeleteProps> = ({
isOpen,
onClose,
onConfirm,
title = "Are you sure?",
description = "This action cannot be undone. This will permanently delete this event from the timeline.",
}) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onConfirm();
onClose();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
import { useSettings } from "../hooks/useSettings"; import { useSettings } from "../hooks/useSettings";
@ -37,6 +38,8 @@ const mockJob: Job = {
salary: "£60,000", salary: "£60,000",
deadline: "2025-12-31", deadline: "2025-12-31",
status: "discovered", status: "discovered",
outcome: null,
closedAt: null,
source: "linkedin", source: "linkedin",
suitabilityScore: 85, suitabilityScore: 85,
suitabilityReason: "Strong match", suitabilityReason: "Strong match",
@ -46,6 +49,9 @@ const mockJob: Job = {
} as Job; } as Job;
describe("JobHeader", () => { describe("JobHeader", () => {
const renderWithRouter = (ui: React.ReactElement) =>
render(<MemoryRouter>{ui}</MemoryRouter>);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(useSettings as any).mockReturnValue({ (useSettings as any).mockReturnValue({
@ -54,21 +60,37 @@ describe("JobHeader", () => {
}); });
it("renders basic job information", () => { it("renders basic job information", () => {
render(<JobHeader job={mockJob} />); renderWithRouter(<JobHeader job={mockJob} />);
expect(screen.getByText("Software Engineer")).toBeInTheDocument(); expect(screen.getByText("Software Engineer")).toBeInTheDocument();
expect(screen.getByText("Tech Corp")).toBeInTheDocument(); expect(screen.getByText("Tech Corp")).toBeInTheDocument();
expect(screen.getByText("London")).toBeInTheDocument(); expect(screen.getByText("London")).toBeInTheDocument();
expect(screen.getByText("£60,000")).toBeInTheDocument(); expect(screen.getByText("£60,000")).toBeInTheDocument();
}); });
it("links the title and view button to the job page", () => {
renderWithRouter(<JobHeader job={mockJob} />);
expect(
screen.getByRole("link", { name: "Software Engineer" }),
).toHaveAttribute("href", "/job/job-1");
expect(screen.getByRole("link", { name: /view/i })).toHaveAttribute(
"href",
"/job/job-1",
);
});
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
const onCheckSponsor = vi.fn().mockResolvedValue(undefined); const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />); renderWithRouter(
<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />,
);
const button = screen.getByText("Check Sponsorship Status"); const button = screen.getByText("Check Sponsorship Status");
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
fireEvent.click(button); await act(async () => {
fireEvent.click(button);
});
expect(onCheckSponsor).toHaveBeenCalled(); expect(onCheckSponsor).toHaveBeenCalled();
}); });
@ -79,7 +101,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 98, sponsorMatchScore: 98,
sponsorMatchNames: '["Tech Corp Ltd"]', sponsorMatchNames: '["Tech Corp Ltd"]',
}; };
render(<JobHeader job={jobWithSponsor} />); renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
}); });
@ -90,7 +112,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 85, sponsorMatchScore: 85,
sponsorMatchNames: '["Techy Corp"]', sponsorMatchNames: '["Techy Corp"]',
}; };
render(<JobHeader job={jobWithPotential} />); renderWithRouter(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
}); });
@ -101,7 +123,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 40, sponsorMatchScore: 40,
sponsorMatchNames: '["Other Corp"]', sponsorMatchNames: '["Other Corp"]',
}; };
render(<JobHeader job={jobNoSponsor} />); renderWithRouter(<JobHeader job={jobNoSponsor} />);
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
}); });
@ -112,11 +134,23 @@ describe("JobHeader", () => {
}); });
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />); renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect( expect(
screen.queryByText("Check Sponsorship Status"), screen.queryByText("Check Sponsorship Status"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("hides the view button when already on a job page", () => {
render(
<MemoryRouter initialEntries={["/job/job-1"]}>
<JobHeader job={mockJob} />
</MemoryRouter>,
);
expect(
screen.queryByRole("link", { name: /view/i }),
).not.toBeInTheDocument();
});
}); });

View File

@ -1,6 +1,14 @@
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; import {
ArrowUpRight,
Calendar,
DollarSign,
Loader2,
MapPin,
Search,
} from "lucide-react";
import type React from "react"; import type React from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -172,6 +180,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
onCheckSponsor, onCheckSponsor,
}) => { }) => {
const { showSponsorInfo } = useSettings(); const { showSponsorInfo } = useSettings();
const { pathname } = useLocation();
const isJobPage = pathname.startsWith("/job/");
const deadline = formatDate(job.deadline); const deadline = formatDate(job.deadline);
return ( return (
@ -179,19 +189,37 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
{/* Detail header: lighter weight than list items */} {/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-base font-semibold text-foreground/90"> <Link
to={`/job/${job.id}`}
className="flex items-center gap-2 text-base font-semibold underline-offset-2 text-foreground/90 hover:underline"
>
{job.title} {job.title}
</div> </Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span> <span>{job.employer}</span>
</div> </div>
</div> </div>
<Badge <div className="flex items-center gap-2">
variant="outline" <Badge
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50" variant="outline"
> className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
{sourceLabel[job.source]} >
</Badge> {sourceLabel[job.source]}
</Badge>
{!isJobPage && (
<Button
asChild
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px] uppercase tracking-wide"
>
<Link to={`/job/${job.id}`}>
View
<ArrowUpRight className="h-3 w-3" />
</Link>
</Button>
)}
</div>
</div> </div>
{/* Tertiary metadata - subdued */} {/* Tertiary metadata - subdued */}

View File

@ -0,0 +1,113 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LogEventModal } from "./LogEventModal";
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogCancel: ({
children,
...props
}: {
children: React.ReactNode;
}) => (
<button type="button" {...props}>
{children}
</button>
),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
}) => (
<select
data-testid="select"
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
>
{children}
</select>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
SelectTrigger: () => null,
SelectValue: () => null,
}));
describe("LogEventModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the rejection reason selector and submits the form", async () => {
const onLog = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
const stageSelect = screen.getAllByTestId("select")[0];
fireEvent.change(stageSelect, { target: { value: "rejected" } });
expect(screen.getByText("Reason")).toBeInTheDocument();
const reasonSelect = screen.getAllByTestId("select")[1];
fireEvent.change(reasonSelect, { target: { value: "Visa" } });
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
await waitFor(() =>
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({ stage: "rejected", reasonCode: "Visa" }),
undefined,
),
);
});
it("blocks submit when the title is cleared", async () => {
const onLog = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
render(<LogEventModal isOpen onClose={onClose} onLog={onLog} />);
const titleInput = screen.getByPlaceholderText("e.g. Recruiter Screen");
fireEvent.change(titleInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /log event/i }));
expect(await screen.findByText("Title is required")).toBeInTheDocument();
expect(onLog).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,241 @@
import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { StageEvent } from "../../shared/types";
import { STAGE_LABELS } from "../../shared/types";
const logEventSchema = z.object({
stage: z.string(),
title: z.string().min(1, "Title is required"),
date: z.string().min(1, "Date is required"),
notes: z.string().optional(),
reasonCode: z.string().optional(),
salary: z.string().optional(),
});
export type LogEventFormValues = z.infer<typeof logEventSchema>;
interface LogEventModalProps {
isOpen: boolean;
onClose: () => void;
onLog: (values: LogEventFormValues, eventId?: string) => Promise<void>;
editingEvent?: StageEvent | null;
}
const STAGE_OPTIONS = [
{ label: "No Stage Change (Keep current status)", value: "no_change" },
{ label: STAGE_LABELS.applied, value: "applied" },
{ label: STAGE_LABELS.recruiter_screen, value: "recruiter_screen" },
{ label: STAGE_LABELS.assessment, value: "assessment" },
{ label: STAGE_LABELS.hiring_manager_screen, value: "hiring_manager_screen" },
{ label: STAGE_LABELS.technical_interview, value: "technical_interview" },
{ label: STAGE_LABELS.onsite, value: "onsite" },
{ label: STAGE_LABELS.offer, value: "offer" },
{ label: "Rejected", value: "rejected" },
{ label: "Withdrawn", value: "withdrawn" },
{ label: STAGE_LABELS.closed, value: "closed" },
];
const REASON_CODES = ["Skills", "Visa", "Timing", "Culture", "Unknown"];
const toDateTimeLocal = (value: Date) => {
const pad = (num: number) => String(num).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(
value.getMinutes(),
)}`;
};
export const LogEventModal: React.FC<LogEventModalProps> = ({
isOpen,
onClose,
onLog,
editingEvent,
}) => {
const {
register,
handleSubmit,
control,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<LogEventFormValues>({
resolver: zodResolver(logEventSchema),
defaultValues: {
stage: "no_change",
title: "Update",
date: toDateTimeLocal(new Date()),
notes: "",
},
});
const selectedStage = watch("stage");
React.useEffect(() => {
if (isOpen) {
if (editingEvent) {
reset({
stage: editingEvent.toStage,
title: editingEvent.metadata?.eventLabel || "",
date: toDateTimeLocal(new Date(editingEvent.occurredAt * 1000)),
notes: editingEvent.metadata?.note || "",
reasonCode: editingEvent.metadata?.reasonCode || undefined,
salary: editingEvent.metadata?.externalUrl?.startsWith("Salary: ")
? editingEvent.metadata.externalUrl.replace("Salary: ", "")
: undefined,
});
} else {
reset({
stage: "no_change",
title: "Update",
date: toDateTimeLocal(new Date()),
notes: "",
});
}
}
}, [isOpen, reset, editingEvent]);
React.useEffect(() => {
// Only auto-update title if we're not editing or if the title is empty/default
if (!editingEvent) {
if (selectedStage === "no_change") {
setValue("title", "Update");
} else {
const option = STAGE_OPTIONS.find((o) => o.value === selectedStage);
if (option) {
setValue("title", option.label);
}
}
}
}, [selectedStage, setValue, editingEvent]);
const onSubmit = async (values: LogEventFormValues) => {
await onLog(values, editingEvent?.id);
onClose();
};
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>
{editingEvent ? "Edit Event" : "Log Event"}
</AlertDialogTitle>
<AlertDialogDescription>
{editingEvent
? "Update the details of this event."
: "Record a new update or stage change for this application."}
</AlertDialogDescription>
</AlertDialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field>
<FieldLabel>New Stage</FieldLabel>
<Controller
name="stage"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
{STAGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.stage]} />
</Field>
<Field>
<FieldLabel>Event Title</FieldLabel>
<Input {...register("title")} placeholder="e.g. Recruiter Screen" />
<FieldError errors={[errors.title]} />
</Field>
<Field>
<FieldLabel>Date</FieldLabel>
<Input type="datetime-local" {...register("date")} />
<FieldError errors={[errors.date]} />
</Field>
<Field>
<FieldLabel>Notes (Optional)</FieldLabel>
<Textarea {...register("notes")} placeholder="Add details..." />
<FieldError errors={[errors.notes]} />
</Field>
{selectedStage === "rejected" && (
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
<FieldLabel>Reason</FieldLabel>
<Controller
name="reasonCode"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{REASON_CODES.map((code) => (
<SelectItem key={code} value={code}>
{code}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
)}
{selectedStage === "offer" && (
<Field className="animate-in fade-in slide-in-from-top-1 duration-200">
<FieldLabel>Salary / Details</FieldLabel>
<Input {...register("salary")} placeholder="e.g. £50k + bonus" />
</Field>
)}
<AlertDialogFooter className="pt-4">
<AlertDialogCancel type="button" onClick={onClose}>
Cancel
</AlertDialogCancel>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Saving..."
: editingEvent
? "Save Changes"
: "Log Event"}
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -0,0 +1,126 @@
import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
import { render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGate } from "./OnboardingGate";
vi.mock("@client/api", () => ({
validateOpenrouter: vi.fn(),
validateRxresume: vi.fn(),
validateResumeConfig: vi.fn(),
updateSettings: vi.fn(),
}));
vi.mock("@client/hooks/useSettings", () => ({
useSettings: vi.fn(),
}));
vi.mock("@client/pages/settings/components/SettingsInput", () => ({
SettingsInput: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
BaseResumeSelection: () => <div>Base resume selection</div>,
}));
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsList: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));
vi.mock("@/components/ui/progress", () => ({
Progress: () => <div>Progress</div>,
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
const settingsResponse = {
settings: {
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
rxresumeBaseResumeId: null,
},
isLoading: false,
refreshSettings: vi.fn(),
};
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSettings).mockReturnValue(settingsResponse as any);
});
it("renders the gate once validations complete and any fail", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
valid: false,
message: "Invalid",
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
it("hides the gate when all validations succeed", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: true,
message: null,
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
});

View File

@ -126,8 +126,13 @@ export const OnboardingGate: React.FC = () => {
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint); const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()); const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint); const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const hasCheckedValidations =
openrouterValidation.checked &&
rxresumeValidation.checked &&
baseResumeValidation.checked;
const shouldOpen = const shouldOpen =
Boolean(settings && !settingsLoading) && Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!( !(
openrouterValidation.valid && openrouterValidation.valid &&
rxresumeValidation.valid && rxresumeValidation.valid &&

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types"; import type { Job } from "../../shared/types";
@ -122,6 +123,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
appliedAt: null, appliedAt: null,
createdAt: "2025-01-01T00:00:00Z", createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z", updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides, ...overrides,
}); });
@ -136,7 +139,13 @@ describe("ReadyPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render( render(
<ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />, <MemoryRouter>
<ReadyPanel
job={job}
onJobUpdated={onJobUpdated}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
); );
fireEvent.click( fireEvent.click(

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../../shared/types"; import type { Job } from "../../../shared/types";
@ -115,6 +116,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
appliedAt: null, appliedAt: null,
createdAt: "2025-01-01T00:00:00Z", createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z", updatedAt: "2025-01-02T00:00:00Z",
outcome: null,
closedAt: null,
...overrides, ...overrides,
}); });
@ -129,11 +132,13 @@ describe("DiscoveredPanel", () => {
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job); vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
render( render(
<DiscoveredPanel <MemoryRouter>
job={job} <DiscoveredPanel
onJobUpdated={onJobUpdated} job={job}
onJobMoved={vi.fn()} onJobUpdated={onJobUpdated}
/>, onJobMoved={vi.fn()}
/>
</MemoryRouter>,
); );
fireEvent.click( fireEvent.click(

View File

@ -0,0 +1,375 @@
import confetti from "canvas-confetti";
import {
ArrowLeft,
CalendarClock,
ClipboardList,
DollarSign,
PlusCircle,
} from "lucide-react";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatTimestamp } from "@/lib/utils";
import {
type ApplicationStage,
type ApplicationTask,
type Job,
type JobOutcome,
STAGE_LABELS,
type StageEvent,
} from "../../shared/types";
import * as api from "../api";
import { ConfirmDelete } from "../components/ConfirmDelete";
import { JobHeader } from "../components/JobHeader";
import {
type LogEventFormValues,
LogEventModal,
} from "../components/LogEventModal";
import { JobTimeline } from "./job/Timeline";
export const JobPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [job, setJob] = React.useState<Job | null>(null);
const [events, setEvents] = React.useState<StageEvent[]>([]);
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
const [eventToDelete, setEventToDelete] = React.useState<string | null>(null);
const [editingEvent, setEditingEvent] = React.useState<StageEvent | null>(
null,
);
const pendingEventRef = React.useRef<StageEvent | null>(null);
const loadData = React.useCallback(async () => {
if (!id) return;
setIsLoading(true);
try {
const jobData = await api.getJob(id);
setJob(jobData);
api
.getJobStageEvents(id)
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
.catch(() => toast.error("Failed to load stage events"));
api
.getJobTasks(id)
.then((data) => setTasks(data))
.catch(() => toast.error("Failed to load tasks"));
} finally {
setIsLoading(false);
}
}, [id]);
React.useEffect(() => {
loadData();
}, [loadData]);
const handleLogEvent = async (
values: LogEventFormValues,
eventId?: string,
) => {
if (!job) return;
let toStage: ApplicationStage | "no_change" = values.stage as
| ApplicationStage
| "no_change";
let outcome: JobOutcome | null = null;
if (values.stage === "rejected") {
toStage = "closed";
outcome = "rejected";
} else if (values.stage === "withdrawn") {
toStage = "closed";
outcome = "withdrawn";
}
const currentStage =
events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null);
const effectiveStage =
toStage === "no_change" ? (currentStage ?? "applied") : toStage;
try {
if (eventId) {
await api.updateJobStageEvent(job.id, eventId, {
toStage: toStage === "no_change" ? undefined : toStage,
occurredAt: toTimestamp(values.date) ?? undefined,
metadata: {
note: values.notes?.trim() || undefined,
eventLabel: values.title.trim() || undefined,
reasonCode: values.reasonCode || undefined,
actor: "user",
eventType: values.stage === "no_change" ? "note" : "status_update",
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
},
outcome,
});
} else {
const newEvent = await api.transitionJobStage(job.id, {
toStage: effectiveStage,
occurredAt: toTimestamp(values.date),
metadata: {
note: values.notes?.trim() || undefined,
eventLabel: values.title.trim() || undefined,
reasonCode: values.reasonCode || undefined,
actor: "user",
eventType: values.stage === "no_change" ? "note" : "status_update",
externalUrl: values.salary ? `Salary: ${values.salary}` : undefined,
},
outcome,
});
pendingEventRef.current = newEvent;
}
const [jobData, eventData] = await Promise.all([
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
pendingEventRef.current = null;
setEditingEvent(null);
toast.success(eventId ? "Event updated" : "Event logged");
if (effectiveStage === "offer") {
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ["#10b981", "#34d399", "#6ee7b7", "#ffffff"],
});
}
} catch (error) {
console.error("Failed to log event:", error);
toast.error("Failed to log event");
}
};
const confirmDeleteEvent = (eventId: string) => {
setEventToDelete(eventId);
setIsDeleteModalOpen(true);
};
const handleDeleteEvent = async () => {
if (!job || !eventToDelete) return;
try {
await api.deleteJobStageEvent(job.id, eventToDelete);
const [jobData, eventData] = await Promise.all([
api.getJob(job.id),
api.getJobStageEvents(job.id),
]);
setJob(jobData);
setEvents(eventData);
toast.success("Event deleted");
} catch (error) {
console.error("Failed to delete event:", error);
toast.error("Failed to delete event");
} finally {
setIsDeleteModalOpen(false);
setEventToDelete(null);
}
};
const handleEditEvent = (event: StageEvent) => {
setEditingEvent(event);
setIsLogModalOpen(true);
};
const currentStage = job
? (events.at(-1)?.toStage ?? (job.status === "applied" ? "applied" : null))
: null;
if (!id) {
return null;
}
return (
<main className="container mx-auto max-w-6xl space-y-6 px-4 py-6 pb-12">
<div className="flex items-center justify-between gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-3">
<Button
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsLogModalOpen(true)}
disabled={!job}
>
<PlusCircle className="mr-2 h-4 w-4" />
Log Event
</Button>
</div>
</div>
{job ? (
<JobHeader
job={job}
className="rounded-lg border border-border/40 bg-muted/5 p-4"
/>
) : (
<div className="rounded-lg border border-dashed border-border/40 p-6 text-sm text-muted-foreground">
{isLoading ? "Loading application..." : "Application not found."}
</div>
)}
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="border-border/50">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-4 w-4" />
Stage timeline
</CardTitle>
<div className="flex items-center gap-2">
{job?.salary && (
<Badge
variant="outline"
className="border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400"
>
<DollarSign className="mr-1 h-3.5 w-3.5" />
{job.salary}
</Badge>
)}
{currentStage && (
<Badge
variant="secondary"
className="px-3 py-1 text-xs font-medium uppercase tracking-wider"
>
{STAGE_LABELS[currentStage as ApplicationStage] ||
currentStage}
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<JobTimeline
events={events}
onEdit={handleEditEvent}
onDelete={confirmDeleteEvent}
/>
</CardContent>
</Card>
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Application details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Current Stage
</div>
<div className="mt-1 text-sm font-medium">
{currentStage
? STAGE_LABELS[currentStage as ApplicationStage] ||
currentStage
: job?.status}
</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Outcome
</div>
<div className="mt-1 text-sm font-medium">
{job?.outcome ? job.outcome.replace(/_/g, " ") : "Open"}
</div>
</div>
{job?.closedAt && (
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
Closed On
</div>
<div className="mt-1 text-sm font-medium">
{formatTimestamp(job.closedAt)}
</div>
</div>
)}
</CardContent>
</Card>
{tasks.length > 0 && (
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="h-4 w-4" />
Upcoming tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tasks.map((task) => (
<div
key={task.id}
className="flex items-start justify-between gap-4"
>
<div className="space-y-1">
<div className="text-sm font-medium text-foreground/90">
{task.title}
</div>
{task.notes && (
<div className="text-xs text-muted-foreground">
{task.notes}
</div>
)}
</div>
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide"
>
{formatTimestamp(task.dueDate)}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
<LogEventModal
isOpen={isLogModalOpen}
onClose={() => {
setIsLogModalOpen(false);
setEditingEvent(null);
}}
onLog={handleLogEvent}
editingEvent={editingEvent}
/>
<ConfirmDelete
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setEventToDelete(null);
}}
onConfirm={handleDeleteEvent}
/>
</main>
);
};
const toTimestamp = (value: string) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.floor(date.getTime() / 1000);
};
const mergeEvents = (events: StageEvent[], pending: StageEvent | null) => {
if (!pending) return events;
if (events.some((event) => event.id === pending.id)) return events;
return [...events, pending].sort((a, b) => a.occurredAt - b.occurredAt);
};

View File

@ -24,6 +24,8 @@ const jobFixture: Job = {
starting: null, starting: null,
jobDescription: "Build APIs", jobDescription: "Build APIs",
status: "ready", status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90, suitabilityScore: 90,
suitabilityReason: null, suitabilityReason: null,
tailoredSummary: null, tailoredSummary: null,

View File

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { StageEvent } from "../../../shared/types";
import { JobTimeline } from "./Timeline";
const baseEvent: StageEvent = {
id: "event-1",
applicationId: "app-1",
fromStage: null,
toStage: "applied",
title: "Applied",
groupId: null,
occurredAt: 1735689600,
metadata: {
eventLabel: "Applied",
},
outcome: null,
};
describe("JobTimeline", () => {
it("renders edit and delete controls when callbacks are provided", () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
render(
<JobTimeline events={[baseEvent]} onEdit={onEdit} onDelete={onDelete} />,
);
const editButton = screen.getByTitle("Edit event");
const deleteButton = screen.getByTitle("Delete event");
fireEvent.click(editButton);
fireEvent.click(deleteButton);
expect(onEdit).toHaveBeenCalledWith(baseEvent);
expect(onDelete).toHaveBeenCalledWith("event-1");
});
it("omits edit and delete controls when callbacks are missing", () => {
render(<JobTimeline events={[baseEvent]} />);
expect(screen.queryByTitle("Edit event")).not.toBeInTheDocument();
expect(screen.queryByTitle("Delete event")).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,334 @@
import {
CheckCircle2,
ClipboardList,
Edit2,
FileText,
MailCheck,
PhoneCall,
Presentation,
Trash2,
UserRound,
Video,
} from "lucide-react";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils";
import {
type ApplicationStage,
STAGE_LABELS,
type StageEvent,
} from "../../../shared/types";
import { CollapsibleSection } from "../../components/discovered-panel/CollapsibleSection";
const stageIcons: Record<ApplicationStage, React.ReactNode> = {
applied: <CheckCircle2 className="h-4 w-4" />,
recruiter_screen: <PhoneCall className="h-4 w-4" />,
assessment: <FileText className="h-4 w-4" />,
hiring_manager_screen: <UserRound className="h-4 w-4" />,
technical_interview: <Video className="h-4 w-4" />,
onsite: <Presentation className="h-4 w-4" />,
offer: <MailCheck className="h-4 w-4" />,
closed: <ClipboardList className="h-4 w-4" />,
};
const formatRange = (start: number, end: number) => {
const startLabel = formatTimestamp(start);
const endLabel = formatTimestamp(end);
return startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
};
type TimelineEntry =
| { kind: "event"; event: StageEvent }
| {
kind: "group";
id: string;
label: string;
events: StageEvent[];
occurredAt: number;
};
interface JobTimelineProps {
events: StageEvent[];
onEdit?: (event: StageEvent) => void;
onDelete?: (eventId: string) => void;
}
export const JobTimeline: React.FC<JobTimelineProps> = ({
events,
onEdit,
onDelete,
}) => {
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>(
{},
);
const lastEvent = events.at(-1);
const currentStage = lastEvent?.toStage ?? null;
const entries = React.useMemo(() => {
const groups = new Map<string, { label: string; events: StageEvent[] }>();
const standalone: StageEvent[] = [];
events.forEach((event) => {
const groupId = event.groupId;
if (!groupId) {
standalone.push(event);
return;
}
const label = event.metadata?.groupLabel || "Grouped events";
const group = groups.get(groupId) ?? { label, events: [] };
group.events.push(event);
groups.set(groupId, group);
});
const mapped: TimelineEntry[] = standalone.map((event) => ({
kind: "event",
event,
}));
groups.forEach((value, id) => {
const sorted = [...value.events].sort(
(a, b) => a.occurredAt - b.occurredAt,
);
mapped.push({
kind: "group",
id,
label: value.label,
events: sorted,
occurredAt: sorted[0]?.occurredAt ?? 0,
});
});
return mapped.sort((a, b) => {
const timeA = a.kind === "event" ? a.event.occurredAt : a.occurredAt;
const timeB = b.kind === "event" ? b.event.occurredAt : b.occurredAt;
return timeA - timeB;
});
}, [events]);
if (entries.length === 0) {
return (
<div className="rounded-md border border-dashed border-border/50 p-6 text-sm text-muted-foreground">
No stage events yet.
</div>
);
}
return (
<div className="space-y-6">
{entries.map((entry, entryIndex) => {
if (entry.kind === "event") {
const title = entry.event.title || STAGE_LABELS[entry.event.toStage];
const note = entry.event.metadata?.note;
const reason = entry.event.metadata?.reasonCode;
const isCurrent =
currentStage === entry.event.toStage &&
entryIndex === entries.length - 1 &&
entry.event.toStage !== "applied";
const isOffer = entry.event.toStage === "offer";
const salary = entry.event.metadata?.externalUrl?.startsWith(
"Salary: ",
)
? entry.event.metadata.externalUrl.replace("Salary: ", "")
: null;
return (
<TimelineRow
key={entry.event.id}
date={formatTimestampWithTime(entry.event.occurredAt)}
title={title}
icon={stageIcons[entry.event.toStage]}
isCurrent={isCurrent}
isOffer={isOffer}
isLast={entryIndex === entries.length - 1}
onEdit={onEdit ? () => onEdit(entry.event) : undefined}
onDelete={onDelete ? () => onDelete(entry.event.id) : undefined}
>
{note && (
<div className="text-sm text-muted-foreground">{note}</div>
)}
{salary && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
{salary}
</div>
)}
{reason && (
<Badge
variant="outline"
className="mt-2 text-[10px] uppercase tracking-wide"
>
{reason}
</Badge>
)}
</TimelineRow>
);
}
const groupOpen = Boolean(openGroups[entry.id]);
const toggleGroup = () =>
setOpenGroups((prev) => ({ ...prev, [entry.id]: !prev[entry.id] }));
const groupStart = entry.events[0]?.occurredAt ?? entry.occurredAt;
const groupEnd = entry.events.at(-1)?.occurredAt ?? entry.occurredAt;
const groupCompleted = entry.events.some((event) =>
/submitted|completed|finished/i.test(event.title || ""),
);
const isCurrentGroup =
currentStage === entry.events.at(-1)?.toStage &&
entryIndex === entries.length - 1;
return (
<div key={entry.id} className="space-y-2">
<TimelineRow
date={formatRange(groupStart, groupEnd)}
title={entry.label}
icon={<ClipboardList className="h-4 w-4" />}
isCurrent={isCurrentGroup && !groupCompleted}
isCompleted={groupCompleted}
isLast={entryIndex === entries.length - 1}
>
<CollapsibleSection
isOpen={groupOpen}
label={groupOpen ? "Hide details" : "View details"}
onToggle={toggleGroup}
>
<div className="space-y-4">
{entry.events.map((event) => (
<TimelineRow
key={event.id}
date={formatTimestampWithTime(event.occurredAt)}
title={event.title || STAGE_LABELS[event.toStage]}
icon={stageIcons[event.toStage]}
isCompact
isLast={false}
onEdit={onEdit ? () => onEdit(event) : undefined}
onDelete={onDelete ? () => onDelete(event.id) : undefined}
>
{event.metadata?.note && (
<div className="text-xs text-muted-foreground">
{event.metadata.note}
</div>
)}
</TimelineRow>
))}
</div>
</CollapsibleSection>
</TimelineRow>
</div>
);
})}
</div>
);
};
interface TimelineRowProps {
date: string;
title: string;
icon: React.ReactNode;
isCurrent?: boolean;
isOffer?: boolean;
isCompleted?: boolean;
isLast?: boolean;
isCompact?: boolean;
onEdit?: () => void;
onDelete?: () => void;
children?: React.ReactNode;
}
const TimelineRow: React.FC<TimelineRowProps> = ({
date,
title,
icon,
isCurrent,
isOffer,
isCompleted,
isLast,
isCompact,
onEdit,
onDelete,
children,
}) => {
const isHollow = Boolean(isCurrent) && !isCompleted;
const isFilled = !isHollow;
return (
<div
className={cn(
"group relative",
isCompact ? "pl-8" : "",
isOffer && "rounded-lg border border-amber-500/20 bg-amber-500/5 p-4",
)}
>
<div
className={
isCompact
? "grid grid-cols-[80px_20px_1fr] gap-4"
: "grid grid-cols-[100px_24px_1fr] gap-4"
}
>
<div className="text-right text-xs font-medium text-muted-foreground">
{date}
</div>
<div className="relative flex flex-col items-center">
<span className="absolute inset-y-0 w-px bg-border" />
<div
className={
isCompact
? "relative z-10 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500 text-white"
: isHollow
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-emerald-500 bg-background text-emerald-600 animate-pulse"
: isOffer
? "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]"
: "relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white"
}
>
{isFilled && icon}
</div>
{isLast && (
<span className="absolute bottom-0 h-4 w-px bg-background" />
)}
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 min-w-0 flex-1">
<div
className={
isCompact ? "text-xs font-semibold" : "text-sm font-semibold"
}
>
{title}
</div>
{children}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
{onEdit && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="p-2 cursor-pointer rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Edit event"
>
<Edit2 className="size-4" />
</button>
)}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-2 cursor-pointer rounded-md hover:bg-muted text-destructive/70 hover:text-destructive transition-colors"
title="Delete event"
>
<Trash2 className="size-4" />
</button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@ -106,6 +106,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
starting: null, starting: null,
jobDescription: "Build APIs", jobDescription: "Build APIs",
status: "ready", status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 82, suitabilityScore: 82,
suitabilityReason: "Strong fit", suitabilityReason: "Strong fit",
tailoredSummary: null, tailoredSummary: null,

View File

@ -22,6 +22,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
starting: null, starting: null,
jobDescription: "Build APIs", jobDescription: "Build APIs",
status: "ready", status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 72, suitabilityScore: 72,
suitabilityReason: null, suitabilityReason: null,
tailoredSummary: null, tailoredSummary: null,

View File

@ -26,6 +26,26 @@ export const formatDate = (dateStr?: string | null) => {
} }
}; };
export const formatTimestamp = (value?: number | null) => {
if (!value) return "No due date";
return new Date(value * 1000).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
};
export const formatTimestampWithTime = (value?: number | null) => {
if (!value) return "No date";
const date = new Date(value * 1000);
const dateLabel = formatTimestamp(value);
const timeLabel = date.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
return `${dateLabel} ${timeLabel}`;
};
export const formatDateTime = (dateStr?: string | null) => { export const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return null; if (!dateStr) return null;
try { try {

View File

@ -169,4 +169,137 @@ describe.sequential("Jobs API routes", () => {
expect(body.data.sponsorMatchScore).toBe(100); expect(body.data.sponsorMatchScore).toBe(100);
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR"); expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
}); });
describe("Application Tracking", () => {
let jobId: string;
beforeEach(async () => {
const { createJob } = await import("../../repositories/jobs.js");
const job = await createJob({
source: "manual",
title: "Tracking Test",
employer: "Test Corp",
jobUrl: "https://example.com/tracking",
});
jobId = job.id;
});
it("transitions stages and retrieves events", async () => {
// 1. Initial transition to applied
const trans1 = await fetch(`${baseUrl}/api/jobs/${jobId}/stages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toStage: "applied" }),
});
const body1 = await trans1.json();
expect(body1.success).toBe(true);
expect(body1.data.toStage).toBe("applied");
const eventId = body1.data.id;
// 2. Transition to recruiter_screen with metadata
await fetch(`${baseUrl}/api/jobs/${jobId}/stages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
toStage: "recruiter_screen",
metadata: { note: "Called by recruiter" },
}),
});
// 3. Get events
const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
const eventsBody = await eventsRes.json();
expect(eventsBody.success).toBe(true);
expect(eventsBody.data).toHaveLength(2);
expect(eventsBody.data[0].toStage).toBe("applied");
expect(eventsBody.data[1].toStage).toBe("recruiter_screen");
expect(eventsBody.data[1].metadata.note).toBe("Called by recruiter");
// 4. Patch an event
const patchRes = await fetch(
`${baseUrl}/api/jobs/${jobId}/events/${eventId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata: { note: "Updated note" } }),
},
);
expect(patchRes.status).toBe(200);
const eventsRes2 = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
const eventsBody2 = await eventsRes2.json();
expect(eventsBody2.data[0].metadata.note).toBe("Updated note");
// 5. Delete an event
const deleteRes = await fetch(
`${baseUrl}/api/jobs/${jobId}/events/${eventId}`,
{
method: "DELETE",
},
);
expect(deleteRes.status).toBe(200);
const eventsRes3 = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
const eventsBody3 = await eventsRes3.json();
expect(eventsBody3.data).toHaveLength(1);
});
it("manages application tasks", async () => {
const { db, schema } = await import("../../db/index.js");
const { eq } = await import("drizzle-orm");
const { tasks } = schema;
// 1. Initial state
const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
const body1 = await res1.json();
expect(body1.success).toBe(true);
expect(body1.data).toEqual([]);
// 2. Insert a task
await (db as any)
.insert(tasks)
.values({
id: "task-1",
applicationId: jobId,
type: "todo",
title: "Complete test task",
isCompleted: false,
})
.run();
const res2 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
const body2 = await res2.json();
expect(body2.data).toHaveLength(1);
expect(body2.data[0].title).toBe("Complete test task");
// 3. Test filtering (completed vs non-completed)
await (db as any)
.update(tasks)
.set({ isCompleted: true })
.where(eq(tasks.id, "task-1"))
.run();
const res3 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
const body3 = await res3.json();
expect(body3.data).toHaveLength(0); // includeCompleted defaults to false
const res4 = await fetch(
`${baseUrl}/api/jobs/${jobId}/tasks?includeCompleted=true`,
);
const body4 = await res4.json();
expect(body4.data).toHaveLength(1);
});
it("updates job outcome", async () => {
const res = await fetch(`${baseUrl}/api/jobs/${jobId}/outcome`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outcome: "rejected" }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.outcome).toBe("rejected");
expect(body.data.closedAt).toBeTruthy();
});
});
}); });

View File

@ -1,10 +1,12 @@
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import type { import {
ApiResponse, APPLICATION_OUTCOMES,
Job, APPLICATION_STAGES,
JobStatus, type ApiResponse,
JobsListResponse, type Job,
type JobStatus,
type JobsListResponse,
} from "../../../shared/types.js"; } from "../../../shared/types.js";
import { import {
generateFinalPdf, generateFinalPdf,
@ -13,6 +15,14 @@ import {
} from "../../pipeline/index.js"; } from "../../pipeline/index.js";
import * as jobsRepo from "../../repositories/jobs.js"; import * as jobsRepo from "../../repositories/jobs.js";
import * as settingsRepo from "../../repositories/settings.js"; import * as settingsRepo from "../../repositories/settings.js";
import {
deleteStageEvent,
getStageEvents,
getTasks,
stageEventMetadataSchema,
transitionStage,
updateStageEvent,
} from "../../services/applicationTracking.js";
import { createNotionEntry } from "../../services/notion.js"; import { createNotionEntry } from "../../services/notion.js";
import { getProfile } from "../../services/profile.js"; import { getProfile } from "../../services/profile.js";
import { scoreJobSuitability } from "../../services/scorer.js"; import { scoreJobSuitability } from "../../services/scorer.js";
@ -72,6 +82,8 @@ const updateJobSchema = z.object({
"expired", "expired",
]) ])
.optional(), .optional(),
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
closedAt: z.number().int().nullable().optional(),
jobDescription: z.string().optional(), jobDescription: z.string().optional(),
suitabilityScore: z.number().min(0).max(100).optional(), suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(), suitabilityReason: z.string().optional(),
@ -82,6 +94,25 @@ const updateJobSchema = z.object({
sponsorMatchNames: z.string().optional(), sponsorMatchNames: z.string().optional(),
}); });
const transitionStageSchema = z.object({
toStage: z.enum([...APPLICATION_STAGES, "no_change"]),
occurredAt: z.number().int().nullable().optional(),
metadata: stageEventMetadataSchema.nullable().optional(),
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
});
const updateStageEventSchema = z.object({
toStage: z.enum(APPLICATION_STAGES).optional(),
occurredAt: z.number().int().optional(),
metadata: stageEventMetadataSchema.nullable().optional(),
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
});
const updateOutcomeSchema = z.object({
outcome: z.enum(APPLICATION_OUTCOMES).nullable(),
closedAt: z.number().int().nullable().optional(),
});
/** /**
* GET /api/jobs - List all jobs * GET /api/jobs - List all jobs
* Query params: status (comma-separated list of statuses to filter) * Query params: status (comma-separated list of statuses to filter)
@ -118,6 +149,117 @@ jobsRouter.get("/", async (req: Request, res: Response) => {
jobsRouter.get("/:id", async (req: Request, res: Response) => { jobsRouter.get("/:id", async (req: Request, res: Response) => {
try { try {
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) {
return res.status(404).json({ success: false, error: "Job not found" });
}
res.json({ success: true, data: job });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/jobs/:id/events - Get stage event timeline
*/
jobsRouter.get("/:id/events", async (req: Request, res: Response) => {
try {
const events = await getStageEvents(req.params.id);
res.json({ success: true, data: events });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
/**
* GET /api/jobs/:id/tasks - Get tasks for an application
*/
jobsRouter.get("/:id/tasks", async (req: Request, res: Response) => {
try {
const includeCompleted =
req.query.includeCompleted === "1" ||
req.query.includeCompleted === "true";
const tasks = await getTasks(req.params.id, includeCompleted);
res.json({ success: true, data: tasks });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
/**
* POST /api/jobs/:id/stages - Transition stage
*/
jobsRouter.post("/:id/stages", async (req: Request, res: Response) => {
try {
const input = transitionStageSchema.parse(req.body);
const event = transitionStage(
req.params.id,
input.toStage,
input.occurredAt ?? undefined,
input.metadata ?? null,
input.outcome ?? null,
);
res.json({ success: true, data: event });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
/**
* PATCH /api/jobs/:id/events/:eventId - Update an event
*/
jobsRouter.patch(
"/:id/events/:eventId",
async (req: Request, res: Response) => {
try {
const input = updateStageEventSchema.parse(req.body);
updateStageEvent(req.params.eventId, input);
res.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
},
);
/**
* DELETE /api/jobs/:id/events/:eventId - Delete an event
*/
jobsRouter.delete(
"/:id/events/:eventId",
async (req: Request, res: Response) => {
try {
deleteStageEvent(req.params.eventId);
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
},
);
/**
* PATCH /api/jobs/:id/outcome - Close out application
*/
jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
try {
const input = updateOutcomeSchema.parse(req.body);
const closedAt = input.outcome
? (input.closedAt ?? Math.floor(Date.now() / 1000))
: null;
const job = await jobsRepo.updateJob(req.params.id, {
outcome: input.outcome,
closedAt,
});
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return res.status(404).json({ success: false, error: "Job not found" });
@ -125,6 +267,9 @@ jobsRouter.get("/:id", async (req: Request, res: Response) => {
res.json({ success: true, data: job }); res.json({ success: true, data: job });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message });
}
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
@ -312,7 +457,8 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
return res.status(404).json({ success: false, error: "Job not found" }); return res.status(404).json({ success: false, error: "Job not found" });
} }
const appliedAt = new Date().toISOString(); const appliedAtDate = new Date();
const appliedAt = appliedAtDate.toISOString();
// Sync to Notion // Sync to Notion
const notionResult = await createNotionEntry({ const notionResult = await createNotionEntry({
@ -327,7 +473,18 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
appliedAt, appliedAt,
}); });
// Update job status transitionStage(
job.id,
"applied",
Math.floor(appliedAtDate.getTime() / 1000),
{
eventLabel: "Applied",
actor: "system",
},
null,
);
// Update job status + Notion metadata
const updatedJob = await jobsRepo.updateJob(job.id, { const updatedJob = await jobsRepo.updateJob(job.id, {
status: "applied", status: "applied",
appliedAt, appliedAt,
@ -338,6 +495,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
notifyJobCompleteWebhook(updatedJob).catch(console.warn); notifyJobCompleteWebhook(updatedJob).catch(console.warn);
} }
if (!updatedJob) {
return res.status(404).json({ success: false, error: "Job not found" });
}
res.json({ success: true, data: updatedJob }); res.json({ success: true, data: updatedJob });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -16,13 +16,15 @@ export function clearDatabase(): { jobsDeleted: number; runsDeleted: number } {
const sqlite = new Database(DB_PATH); const sqlite = new Database(DB_PATH);
try { try {
sqlite.prepare("DELETE FROM stage_events").run();
sqlite.prepare("DELETE FROM tasks").run();
sqlite.prepare("DELETE FROM interviews").run();
const jobsResult = sqlite.prepare("DELETE FROM jobs").run(); const jobsResult = sqlite.prepare("DELETE FROM jobs").run();
const runsResult = sqlite.prepare("DELETE FROM pipeline_runs").run(); const runsResult = sqlite.prepare("DELETE FROM pipeline_runs").run();
console.log( console.log(
`🗑️ Cleared database: ${jobsResult.changes} jobs, ${runsResult.changes} pipeline runs`, `🗑️ Cleared database: ${jobsResult.changes} jobs, ${runsResult.changes} pipeline runs`,
); );
return { return {
jobsDeleted: jobsResult.changes, jobsDeleted: jobsResult.changes,
runsDeleted: runsResult.changes, runsDeleted: runsResult.changes,

View File

@ -62,6 +62,8 @@ const migrations = [
starting TEXT, starting TEXT,
job_description TEXT, job_description TEXT,
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')), status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
outcome TEXT,
closed_at INTEGER,
suitability_score REAL, suitability_score REAL,
suitability_reason TEXT, suitability_reason TEXT,
tailored_summary TEXT, tailored_summary TEXT,
@ -91,6 +93,40 @@ const migrations = [
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`, )`,
`CREATE TABLE IF NOT EXISTS stage_events (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
group_id TEXT,
from_stage TEXT,
to_stage TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
metadata TEXT,
outcome TEXT,
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
due_date INTEGER,
is_completed INTEGER NOT NULL DEFAULT 0,
notes TEXT,
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS interviews (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
duration_mins INTEGER,
type TEXT NOT NULL,
outcome TEXT,
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
)`,
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run) // Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at) `INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`, SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
@ -136,9 +172,35 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`, `ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`, `ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
// Add application tracking columns
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
`ALTER TABLE stage_events ADD COLUMN outcome TEXT`,
`ALTER TABLE stage_events ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE stage_events ADD COLUMN group_id TEXT`,
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`, `CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`, `CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`, `CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
`CREATE INDEX IF NOT EXISTS idx_stage_events_application_id ON stage_events(application_id)`,
`CREATE INDEX IF NOT EXISTS idx_stage_events_occurred_at ON stage_events(occurred_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_application_id ON tasks(application_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`,
`CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id)`,
// Backfill: Create "Applied" events for legacy jobs that have applied_at set but no event entry
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata)
SELECT
'backfill-applied-' || id,
id,
'Applied',
NULL,
'applied',
CAST(strftime('%s', applied_at) AS INTEGER),
'{"eventLabel":"Applied","actor":"system"}'
FROM jobs
WHERE applied_at IS NOT NULL
AND id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'applied')`,
]; ];
console.log("🔧 Running database migrations..."); console.log("🔧 Running database migrations...");
@ -150,7 +212,11 @@ for (const migration of migrations) {
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const isDuplicateColumn = const isDuplicateColumn =
migration.toLowerCase().includes("alter table jobs add column") && (migration.toLowerCase().includes("alter table jobs add column") ||
migration.toLowerCase().includes("alter table tasks add column") ||
migration
.toLowerCase()
.includes("alter table stage_events add column")) &&
message.toLowerCase().includes("duplicate column name"); message.toLowerCase().includes("duplicate column name");
if (isDuplicateColumn) { if (isDuplicateColumn) {

View File

@ -4,6 +4,13 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import {
APPLICATION_OUTCOMES,
APPLICATION_STAGES,
APPLICATION_TASK_TYPES,
INTERVIEW_OUTCOMES,
INTERVIEW_TYPES,
} from "../../shared/types.js";
export const jobs = sqliteTable("jobs", { export const jobs = sqliteTable("jobs", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -69,6 +76,8 @@ export const jobs = sqliteTable("jobs", {
}) })
.notNull() .notNull()
.default("discovered"), .default("discovered"),
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
closedAt: integer("closed_at", { mode: "number" }),
suitabilityScore: real("suitability_score"), suitabilityScore: real("suitability_score"),
suitabilityReason: text("suitability_reason"), suitabilityReason: text("suitability_reason"),
tailoredSummary: text("tailored_summary"), tailoredSummary: text("tailored_summary"),
@ -88,6 +97,45 @@ export const jobs = sqliteTable("jobs", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}); });
export const stageEvents = sqliteTable("stage_events", {
id: text("id").primaryKey(),
applicationId: text("application_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
title: text("title").notNull(),
groupId: text("group_id"),
fromStage: text("from_stage", { enum: APPLICATION_STAGES }),
toStage: text("to_stage", { enum: APPLICATION_STAGES }).notNull(),
occurredAt: integer("occurred_at", { mode: "number" }).notNull(),
metadata: text("metadata", { mode: "json" }),
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
});
export const tasks = sqliteTable("tasks", {
id: text("id").primaryKey(),
applicationId: text("application_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
type: text("type", { enum: APPLICATION_TASK_TYPES }).notNull(),
title: text("title").notNull(),
dueDate: integer("due_date", { mode: "number" }),
isCompleted: integer("is_completed", { mode: "boolean" })
.notNull()
.default(false),
notes: text("notes"),
});
export const interviews = sqliteTable("interviews", {
id: text("id").primaryKey(),
applicationId: text("application_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
scheduledAt: integer("scheduled_at", { mode: "number" }).notNull(),
durationMins: integer("duration_mins"),
type: text("type", { enum: INTERVIEW_TYPES }).notNull(),
outcome: text("outcome", { enum: INTERVIEW_OUTCOMES }),
});
export const pipelineRuns = sqliteTable("pipeline_runs", { export const pipelineRuns = sqliteTable("pipeline_runs", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
startedAt: text("started_at").notNull().default(sql`(datetime('now'))`), startedAt: text("started_at").notNull().default(sql`(datetime('now'))`),
@ -111,6 +159,12 @@ export const settings = sqliteTable("settings", {
export type JobRow = typeof jobs.$inferSelect; export type JobRow = typeof jobs.$inferSelect;
export type NewJobRow = typeof jobs.$inferInsert; export type NewJobRow = typeof jobs.$inferInsert;
export type StageEventRow = typeof stageEvents.$inferSelect;
export type NewStageEventRow = typeof stageEvents.$inferInsert;
export type TaskRow = typeof tasks.$inferSelect;
export type NewTaskRow = typeof tasks.$inferInsert;
export type InterviewRow = typeof interviews.$inferSelect;
export type NewInterviewRow = typeof interviews.$inferInsert;
export type PipelineRunRow = typeof pipelineRuns.$inferSelect; export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert; export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect; export type SettingsRow = typeof settings.$inferSelect;

View File

@ -73,6 +73,8 @@ const createMockJob = (overrides: Partial<Job> = {}): Job => ({
starting: null, starting: null,
jobDescription: "Looking for a TypeScript developer.", jobDescription: "Looking for a TypeScript developer.",
status: "discovered", status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null, suitabilityScore: null,
suitabilityReason: null, suitabilityReason: null,
tailoredSummary: null, tailoredSummary: null,

View File

@ -263,6 +263,8 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
starting: row.starting, starting: row.starting,
jobDescription: row.jobDescription, jobDescription: row.jobDescription,
status: row.status as JobStatus, status: row.status as JobStatus,
outcome: row.outcome ?? null,
closedAt: row.closedAt ?? null,
suitabilityScore: row.suitabilityScore, suitabilityScore: row.suitabilityScore,
suitabilityReason: row.suitabilityReason, suitabilityReason: row.suitabilityReason,
tailoredSummary: row.tailoredSummary, tailoredSummary: row.tailoredSummary,

View File

@ -34,6 +34,8 @@ const mockJob: Job = {
starting: null, starting: null,
jobDescription: "Looking for a TypeScript and React expert.", jobDescription: "Looking for a TypeScript and React expert.",
status: "discovered", status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null, suitabilityScore: null,
suitabilityReason: null, suitabilityReason: null,
tailoredSummary: null, tailoredSummary: null,

View File

@ -0,0 +1,268 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe.sequential("Application Tracking Service", () => {
let tempDir: string;
let db: any;
let schema: any;
let applicationTracking: any;
let jobsRepo: any;
beforeEach(async () => {
vi.resetModules();
tempDir = await mkdtemp(join(tmpdir(), "job-ops-service-test-"));
process.env.DATA_DIR = tempDir;
process.env.NODE_ENV = "test";
// Run migrations
await import("../db/migrate.js");
// Import modules after env is set
const dbModule = await import("../db/index.js");
db = dbModule.db;
schema = dbModule.schema;
applicationTracking = await import("./applicationTracking.js");
jobsRepo = await import("../repositories/jobs.js");
});
afterEach(async () => {
const { closeDb } = await import("../db/index.js");
closeDb();
await rm(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("transitions stage and updates job status", async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "Test Developer",
employer: "Tech Corp",
jobUrl: "https://example.com/job/1",
});
// 1. Initial Transition (Applied)
const event1 = applicationTracking.transitionStage(job.id, "applied");
expect(event1.toStage).toBe("applied");
// Check Job Status
const jobAfter1 = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobAfter1?.status).toBe("applied");
expect(jobAfter1?.appliedAt).toBeTruthy();
// 2. Next Transition (Recruiter Screen)
const event2 = applicationTracking.transitionStage(
job.id,
"recruiter_screen",
);
expect(event2.fromStage).toBe("applied");
expect(event2.toStage).toBe("recruiter_screen");
// Check Job Status (still applied for recruiter screen)
const jobAfter2 = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobAfter2?.status).toBe("applied");
});
it("updates stage event and reflects in job status if latest", async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "Frontend Engineer",
employer: "Web Co",
jobUrl: "https://example.com/job/2",
});
const now = Math.floor(Date.now() / 1000);
applicationTracking.transitionStage(job.id, "applied", now - 100);
const event2 = applicationTracking.transitionStage(
job.id,
"recruiter_screen",
now,
);
// Update event2 (latest) to 'offer'
applicationTracking.updateStageEvent(event2.id, { toStage: "offer" });
// Verify Event Updated
const events = await applicationTracking.getStageEvents(job.id);
const updatedEvent2 = events.find((e: any) => e.id === event2.id);
expect(updatedEvent2?.toStage).toBe("offer");
// Verify Job Status Updated
const jobUpdated = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobUpdated?.status).toBe("applied"); // 'offer' maps to 'applied' in status (active)
expect(jobUpdated?.outcome).toBe("offer_accepted");
});
it("deletes stage event and reverts job status", async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "Backend Engineer",
employer: "Server Co",
jobUrl: "https://example.com/job/3",
});
const now = Math.floor(Date.now() / 1000);
applicationTracking.transitionStage(job.id, "applied", now - 100); // event1
// Simulate UI sending outcome for rejection
const event2 = applicationTracking.transitionStage(
job.id,
"closed",
now,
{ reasonCode: "Skills" },
"rejected",
); // event2
// Verify job is closed/rejected
let jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.status).toBe("applied");
expect(jobCheck?.outcome).toBe("rejected");
// Delete event2
applicationTracking.deleteStageEvent(event2.id);
// Verify job status reverted to event1 (applied)
jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.status).toBe("applied");
expect(jobCheck?.outcome).toBeNull();
});
it('handles "no_change" transitions (notes)', async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "DevOps",
employer: "Cloud Inc",
jobUrl: "https://example.com/job/4",
});
applicationTracking.transitionStage(job.id, "applied");
const noteEvent = applicationTracking.transitionStage(
job.id,
"no_change",
undefined,
{
note: "Just checking in",
},
);
expect(noteEvent.toStage).toBe("applied");
const events = await applicationTracking.getStageEvents(job.id);
expect(events).toHaveLength(2);
expect(events[1].metadata?.note).toBe("Just checking in");
});
it("updates closedAt when outcome changes via event update/delete", async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "QA Engineer",
employer: "Test Labs",
jobUrl: "https://example.com/job/5",
});
const now = Math.floor(Date.now() / 1000);
applicationTracking.transitionStage(job.id, "applied", now - 100);
const event2 = applicationTracking.transitionStage(
job.id,
"closed",
now,
{ reasonCode: "Other" },
"rejected",
);
let jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.outcome).toBe("rejected");
expect(jobCheck?.closedAt).toBe(now);
// 1. Update event2 to not be a closure
applicationTracking.updateStageEvent(event2.id, {
toStage: "technical_interview",
});
jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.outcome).toBeNull();
expect(jobCheck?.closedAt).toBeNull();
// 2. Update event2 back to a closure
applicationTracking.updateStageEvent(event2.id, { toStage: "offer" });
jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.outcome).toBe("offer_accepted");
expect(jobCheck?.closedAt).toBe(now);
// 3. Delete the closure event
applicationTracking.deleteStageEvent(event2.id);
jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.outcome).toBeNull();
expect(jobCheck?.closedAt).toBeNull();
});
it("preserves explicit outcome when updating metadata", async () => {
const job = await jobsRepo.createJob({
source: "manual",
title: "Support Engineer",
employer: "Helpdesk Co",
jobUrl: "https://example.com/job/6",
});
const now = Math.floor(Date.now() / 1000);
applicationTracking.transitionStage(job.id, "applied", now - 100);
const closedEvent = applicationTracking.transitionStage(
job.id,
"closed",
now,
{ reasonCode: "Other" },
"withdrawn",
);
applicationTracking.updateStageEvent(closedEvent.id, {
metadata: { note: "Withdrew after offer" },
});
const jobCheck = await db
.select()
.from(schema.jobs)
.where(eq(schema.jobs.id, job.id))
.get();
expect(jobCheck?.outcome).toBe("withdrawn");
expect(jobCheck?.closedAt).toBe(now);
});
});

View File

@ -0,0 +1,359 @@
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import type {
ApplicationStage,
ApplicationTask,
ApplicationTaskType,
JobOutcome,
JobStatus,
StageEvent,
StageEventMetadata,
} from "../../shared/types.js";
import { db, schema } from "../db/index.js";
const { jobs, stageEvents, tasks } = schema;
const STAGE_TO_STATUS: Record<ApplicationStage, JobStatus> = {
applied: "applied",
recruiter_screen: "applied",
assessment: "applied",
hiring_manager_screen: "applied",
technical_interview: "applied",
onsite: "applied",
offer: "applied",
closed: "applied",
};
export const stageEventMetadataSchema = z
.object({
note: z.string().nullable().optional(),
actor: z.enum(["system", "user"]).optional(),
groupId: z.string().nullable().optional(),
groupLabel: z.string().nullable().optional(),
eventLabel: z.string().nullable().optional(),
externalUrl: z.string().nullable().optional(),
reasonCode: z.string().nullable().optional(),
eventType: z
.enum(["interview_log", "status_update", "note"])
.nullable()
.optional(),
})
.strict();
export async function getStageEvents(
applicationId: string,
): Promise<StageEvent[]> {
const rows = await db
.select()
.from(stageEvents)
.where(eq(stageEvents.applicationId, applicationId))
.orderBy(asc(stageEvents.occurredAt));
return rows.map((row) => ({
id: row.id,
applicationId: row.applicationId,
title: row.title,
groupId: row.groupId ?? null,
fromStage: row.fromStage as ApplicationStage | null,
toStage: row.toStage as ApplicationStage,
occurredAt: row.occurredAt,
metadata: parseMetadata(row.metadata),
outcome: (row.outcome as JobOutcome | null) ?? null,
}));
}
export async function getTasks(
applicationId: string,
includeCompleted = false,
): Promise<ApplicationTask[]> {
const rows = await db
.select()
.from(tasks)
.where(
includeCompleted
? eq(tasks.applicationId, applicationId)
: and(
eq(tasks.applicationId, applicationId),
eq(tasks.isCompleted, false),
),
)
.orderBy(asc(tasks.dueDate));
return rows.map((row) => ({
id: row.id,
applicationId: row.applicationId,
type: row.type as ApplicationTaskType,
title: row.title,
dueDate: row.dueDate,
isCompleted: row.isCompleted ?? false,
notes: row.notes ?? null,
}));
}
export function transitionStage(
applicationId: string,
toStage: ApplicationStage | "no_change",
occurredAt?: number,
metadata?: StageEventMetadata | null,
outcome?: JobOutcome | null,
): StageEvent {
const parsedMetadata = metadata
? stageEventMetadataSchema.parse(metadata)
: null;
const now = Math.floor(Date.now() / 1000);
const timestamp = occurredAt ?? now;
return db.transaction((tx) => {
const job = tx.select().from(jobs).where(eq(jobs.id, applicationId)).get();
if (!job) {
throw new Error("Job not found");
}
const lastEvent = tx
.select()
.from(stageEvents)
.where(eq(stageEvents.applicationId, applicationId))
.orderBy(desc(stageEvents.occurredAt))
.limit(1)
.get();
const fromStage =
(lastEvent?.toStage as ApplicationStage | undefined) ?? null;
const finalToStage =
toStage === "no_change" ? (fromStage ?? "applied") : toStage;
const eventId = randomUUID();
const isNoteEvent = parsedMetadata?.eventType === "note";
tx.insert(stageEvents)
.values({
id: eventId,
applicationId,
title: parsedMetadata?.eventLabel ?? finalToStage,
groupId: parsedMetadata?.groupId ?? null,
fromStage,
toStage: finalToStage,
occurredAt: timestamp,
metadata: parsedMetadata,
outcome,
})
.run();
const updates: Partial<typeof jobs.$inferInsert> = {
updatedAt: new Date().toISOString(),
};
if (toStage !== "no_change" && !isNoteEvent) {
updates.status = STAGE_TO_STATUS[finalToStage];
if (finalToStage === "applied" && !job.appliedAt) {
updates.appliedAt = new Date().toISOString();
}
}
if (outcome) {
updates.outcome = outcome;
updates.closedAt = timestamp;
}
tx.update(jobs).set(updates).where(eq(jobs.id, applicationId)).run();
return {
id: eventId,
applicationId,
title: parsedMetadata?.eventLabel ?? finalToStage,
groupId: parsedMetadata?.groupId ?? null,
fromStage,
toStage: finalToStage,
occurredAt: timestamp,
metadata: parsedMetadata,
outcome: outcome ?? null,
};
});
}
export function updateStageEvent(
eventId: string,
payload: {
toStage?: ApplicationStage;
occurredAt?: number;
metadata?: StageEventMetadata | null;
outcome?: JobOutcome | null;
},
): void {
const { toStage, occurredAt, metadata, outcome } = payload;
const parsedMetadata = metadata
? stageEventMetadataSchema.parse(metadata)
: undefined;
const hasOutcome = Object.hasOwn(payload, "outcome");
db.transaction((tx) => {
const event = tx
.select()
.from(stageEvents)
.where(eq(stageEvents.id, eventId))
.get();
if (!event) throw new Error("Event not found");
const updates: Partial<typeof stageEvents.$inferInsert> = {};
if (toStage) updates.toStage = toStage;
if (occurredAt) updates.occurredAt = occurredAt;
if (parsedMetadata !== undefined) {
updates.metadata = parsedMetadata;
if (parsedMetadata?.eventLabel) updates.title = parsedMetadata.eventLabel;
if (parsedMetadata?.groupId !== undefined)
updates.groupId = parsedMetadata.groupId;
}
if (hasOutcome) updates.outcome = outcome ?? null;
if (toStage && !hasOutcome && !isClosingStage(toStage)) {
updates.outcome = null;
}
tx.update(stageEvents)
.set(updates)
.where(eq(stageEvents.id, eventId))
.run();
// If this was the latest event, update the job status
const lastEvent = tx
.select()
.from(stageEvents)
.where(eq(stageEvents.applicationId, event.applicationId))
.orderBy(desc(stageEvents.occurredAt))
.limit(1)
.get();
if (lastEvent && lastEvent.id === eventId) {
const job = tx
.select()
.from(jobs)
.where(eq(jobs.id, event.applicationId))
.get();
if (!job) throw new Error("Job not found");
const metadata = parseMetadata(lastEvent.metadata);
const lastStage = lastEvent.toStage as ApplicationStage;
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
const inferredOutcome = inferOutcome(lastStage, metadata);
const closingStage = isClosingStage(lastStage);
const outcome =
storedOutcome ??
inferredOutcome ??
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
const closedAt = outcome
? storedOutcome || inferredOutcome
? lastEvent.occurredAt
: (job.closedAt ?? null)
: closingStage
? (job.closedAt ?? null)
: null;
tx.update(jobs)
.set({
status: STAGE_TO_STATUS[lastStage],
outcome,
closedAt,
updatedAt: new Date().toISOString(),
})
.where(eq(jobs.id, event.applicationId))
.run();
}
});
}
export function deleteStageEvent(eventId: string): void {
db.transaction((tx) => {
const event = tx
.select()
.from(stageEvents)
.where(eq(stageEvents.id, eventId))
.get();
if (!event) return;
tx.delete(stageEvents).where(eq(stageEvents.id, eventId)).run();
// Update job status based on the new latest event
const lastEvent = tx
.select()
.from(stageEvents)
.where(eq(stageEvents.applicationId, event.applicationId))
.orderBy(desc(stageEvents.occurredAt))
.limit(1)
.get();
if (lastEvent) {
const job = tx
.select()
.from(jobs)
.where(eq(jobs.id, event.applicationId))
.get();
if (!job) throw new Error("Job not found");
const metadata = parseMetadata(lastEvent.metadata);
const lastStage = lastEvent.toStage as ApplicationStage;
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
const inferredOutcome = inferOutcome(lastStage, metadata);
const closingStage = isClosingStage(lastStage);
const outcome =
storedOutcome ??
inferredOutcome ??
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
const closedAt = outcome
? storedOutcome || inferredOutcome
? lastEvent.occurredAt
: (job.closedAt ?? null)
: closingStage
? (job.closedAt ?? null)
: null;
tx.update(jobs)
.set({
status: STAGE_TO_STATUS[lastStage],
outcome,
closedAt,
updatedAt: new Date().toISOString(),
})
.where(eq(jobs.id, event.applicationId))
.run();
} else {
// If no events left, maybe revert to discovered?
// For now just keep it as is or set to discovered if it was applied
tx.update(jobs)
.set({
status: "discovered",
appliedAt: null,
outcome: null,
closedAt: null,
updatedAt: new Date().toISOString(),
})
.where(eq(jobs.id, event.applicationId))
.run();
}
});
}
function parseMetadata(raw: unknown): StageEventMetadata | null {
if (!raw) return null;
if (typeof raw === "string") {
try {
return JSON.parse(raw) as StageEventMetadata;
} catch {
return null;
}
}
return raw as StageEventMetadata;
}
function inferOutcome(
toStage: ApplicationStage,
metadata: StageEventMetadata | null,
): JobOutcome | null {
if (toStage === "offer") return "offer_accepted";
if (toStage === "closed" && metadata?.reasonCode) return "rejected";
return null;
}
function isClosingStage(toStage: ApplicationStage): boolean {
return toStage === "closed" || toStage === "offer";
}

View File

@ -10,6 +10,114 @@ export type JobStatus =
| "skipped" // User skipped this job | "skipped" // User skipped this job
| "expired"; // Deadline passed | "expired"; // Deadline passed
export const APPLICATION_STAGES = [
"applied",
"recruiter_screen",
"assessment",
"hiring_manager_screen",
"technical_interview",
"onsite",
"offer",
"closed",
] as const;
export type ApplicationStage = (typeof APPLICATION_STAGES)[number];
export const STAGE_LABELS: Record<ApplicationStage, string> = {
applied: "Applied",
recruiter_screen: "Recruiter Screen",
assessment: "Assessment",
hiring_manager_screen: "Hiring Manager Screen",
technical_interview: "Technical Interview",
onsite: "Final Round",
offer: "Offer",
closed: "Closed",
};
export type StageTransitionTarget = ApplicationStage | "no_change";
export const APPLICATION_OUTCOMES = [
"offer_accepted",
"offer_declined",
"rejected",
"withdrawn",
"no_response",
"ghosted",
] as const;
export type JobOutcome = (typeof APPLICATION_OUTCOMES)[number];
export const APPLICATION_TASK_TYPES = [
"prep",
"todo",
"follow_up",
"check_status",
] as const;
export type ApplicationTaskType = (typeof APPLICATION_TASK_TYPES)[number];
export const INTERVIEW_TYPES = [
"recruiter_screen",
"technical",
"onsite",
"panel",
"behavioral",
"final",
] as const;
export type InterviewType = (typeof INTERVIEW_TYPES)[number];
export const INTERVIEW_OUTCOMES = [
"pass",
"fail",
"pending",
"cancelled",
] as const;
export type InterviewOutcome = (typeof INTERVIEW_OUTCOMES)[number];
export interface StageEventMetadata {
note?: string | null;
actor?: "system" | "user";
groupId?: string | null;
groupLabel?: string | null;
eventLabel?: string | null;
externalUrl?: string | null;
reasonCode?: string | null;
eventType?: "interview_log" | "status_update" | "note" | null;
}
export interface StageEvent {
id: string;
applicationId: string;
title: string;
groupId: string | null;
fromStage: ApplicationStage | null;
toStage: ApplicationStage;
occurredAt: number;
metadata: StageEventMetadata | null;
outcome: JobOutcome | null;
}
export interface ApplicationTask {
id: string;
applicationId: string;
type: ApplicationTaskType;
title: string;
dueDate: number | null;
isCompleted: boolean;
notes: string | null;
}
export interface Interview {
id: string;
applicationId: string;
scheduledAt: number;
durationMins: number | null;
type: InterviewType;
outcome: InterviewOutcome | null;
}
export type JobSource = export type JobSource =
| "gradcracker" | "gradcracker"
| "indeed" | "indeed"
@ -42,6 +150,8 @@ export interface Job {
// Orchestrator enrichments // Orchestrator enrichments
status: JobStatus; status: JobStatus;
outcome: JobOutcome | null;
closedAt: number | null;
suitabilityScore: number | null; // 0-100 AI-generated score suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation suitabilityReason: string | null; // AI explanation
tailoredSummary: string | null; // Generated resume summary tailoredSummary: string | null; // Generated resume summary
@ -161,6 +271,8 @@ export interface ManualJobFetchResponse {
export interface UpdateJobInput { export interface UpdateJobInput {
status?: JobStatus; status?: JobStatus;
outcome?: JobOutcome | null;
closedAt?: number | null;
jobDescription?: string; jobDescription?: string;
suitabilityScore?: number; suitabilityScore?: number;
suitabilityReason?: string; suitabilityReason?: string;