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-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8",
@ -24,7 +25,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@ -1857,6 +1860,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
@ -3533,6 +3537,12 @@
"@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": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -4326,6 +4336,16 @@
],
"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": {
"version": "2.0.1",
"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-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.8",
@ -42,7 +43,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@types/canvas-confetti": "^1.9.0",
"better-sqlite3": "^11.6.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",

View File

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

View File

@ -5,9 +5,12 @@
import { trackEvent } from "@/lib/analytics";
import type {
ApiResponse,
ApplicationStage,
ApplicationTask,
AppSettings,
CreateJobInput,
Job,
JobOutcome,
JobSource,
JobsListResponse,
ManualJobDraft,
@ -18,6 +21,9 @@ import type {
ResumeProfile,
ResumeProjectCatalogItem,
ResumeProjectsSettings,
StageEvent,
StageEventMetadata,
StageTransitionTarget,
UkVisaJobsImportResponse,
UkVisaJobsSearchResponse,
ValidationResult,
@ -67,7 +73,7 @@ export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
}
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(
@ -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
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
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 { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "../../shared/types";
import { useSettings } from "../hooks/useSettings";
@ -37,6 +38,8 @@ const mockJob: Job = {
salary: "£60,000",
deadline: "2025-12-31",
status: "discovered",
outcome: null,
closedAt: null,
source: "linkedin",
suitabilityScore: 85,
suitabilityReason: "Strong match",
@ -46,6 +49,9 @@ const mockJob: Job = {
} as Job;
describe("JobHeader", () => {
const renderWithRouter = (ui: React.ReactElement) =>
render(<MemoryRouter>{ui}</MemoryRouter>);
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
@ -54,21 +60,37 @@ describe("JobHeader", () => {
});
it("renders basic job information", () => {
render(<JobHeader job={mockJob} />);
renderWithRouter(<JobHeader job={mockJob} />);
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
expect(screen.getByText("London")).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 () => {
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");
expect(button).toBeInTheDocument();
fireEvent.click(button);
await act(async () => {
fireEvent.click(button);
});
expect(onCheckSponsor).toHaveBeenCalled();
});
@ -79,7 +101,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 98,
sponsorMatchNames: '["Tech Corp Ltd"]',
};
render(<JobHeader job={jobWithSponsor} />);
renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
});
@ -90,7 +112,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 85,
sponsorMatchNames: '["Techy Corp"]',
};
render(<JobHeader job={jobWithPotential} />);
renderWithRouter(<JobHeader job={jobWithPotential} />);
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
@ -101,7 +123,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 40,
sponsorMatchNames: '["Other Corp"]',
};
render(<JobHeader job={jobNoSponsor} />);
renderWithRouter(<JobHeader job={jobNoSponsor} />);
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
});
@ -112,11 +134,23 @@ describe("JobHeader", () => {
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
render(<JobHeader job={jobWithSponsor} />);
renderWithRouter(<JobHeader job={jobWithSponsor} />);
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
expect(
screen.queryByText("Check Sponsorship Status"),
).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 { useMemo, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -172,6 +180,8 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
onCheckSponsor,
}) => {
const { showSponsorInfo } = useSettings();
const { pathname } = useLocation();
const isJobPage = pathname.startsWith("/job/");
const deadline = formatDate(job.deadline);
return (
@ -179,19 +189,37 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
{/* Detail header: lighter weight than list items */}
<div className="flex flex-wrap items-start justify-between gap-2">
<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}
</div>
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{job.employer}</span>
</div>
</div>
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
{sourceLabel[job.source]}
</Badge>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
>
{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>
{/* 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 hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const hasCheckedValidations =
openrouterValidation.checked &&
rxresumeValidation.checked &&
baseResumeValidation.checked;
const shouldOpen =
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(
openrouterValidation.valid &&
rxresumeValidation.valid &&

View File

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

View File

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

View File

@ -22,6 +22,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 72,
suitabilityReason: 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) => {
if (!dateStr) return null;
try {

View File

@ -169,4 +169,137 @@ describe.sequential("Jobs API routes", () => {
expect(body.data.sponsorMatchScore).toBe(100);
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 { z } from "zod";
import type {
ApiResponse,
Job,
JobStatus,
JobsListResponse,
import {
APPLICATION_OUTCOMES,
APPLICATION_STAGES,
type ApiResponse,
type Job,
type JobStatus,
type JobsListResponse,
} from "../../../shared/types.js";
import {
generateFinalPdf,
@ -13,6 +15,14 @@ import {
} from "../../pipeline/index.js";
import * as jobsRepo from "../../repositories/jobs.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 { getProfile } from "../../services/profile.js";
import { scoreJobSuitability } from "../../services/scorer.js";
@ -72,6 +82,8 @@ const updateJobSchema = z.object({
"expired",
])
.optional(),
outcome: z.enum(APPLICATION_OUTCOMES).nullable().optional(),
closedAt: z.number().int().nullable().optional(),
jobDescription: z.string().optional(),
suitabilityScore: z.number().min(0).max(100).optional(),
suitabilityReason: z.string().optional(),
@ -82,6 +94,25 @@ const updateJobSchema = z.object({
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
* 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) => {
try {
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) {
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 });
} 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 });
}
@ -312,7 +457,8 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
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
const notionResult = await createNotionEntry({
@ -327,7 +473,18 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
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, {
status: "applied",
appliedAt,
@ -338,6 +495,10 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
notifyJobCompleteWebhook(updatedJob).catch(console.warn);
}
if (!updatedJob) {
return res.status(404).json({ success: false, error: "Job not found" });
}
res.json({ success: true, data: updatedJob });
} catch (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);
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 runsResult = sqlite.prepare("DELETE FROM pipeline_runs").run();
console.log(
`🗑️ Cleared database: ${jobsResult.changes} jobs, ${runsResult.changes} pipeline runs`,
);
return {
jobsDeleted: jobsResult.changes,
runsDeleted: runsResult.changes,

View File

@ -62,6 +62,8 @@ const migrations = [
starting TEXT,
job_description TEXT,
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'skipped', 'expired')),
outcome TEXT,
closed_at INTEGER,
suitability_score REAL,
suitability_reason TEXT,
tailored_summary TEXT,
@ -91,6 +93,40 @@ const migrations = [
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)
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
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_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_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_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...");
@ -150,7 +212,11 @@ for (const migration of migrations) {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
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");
if (isDuplicateColumn) {

View File

@ -4,6 +4,13 @@
import { sql } from "drizzle-orm";
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", {
id: text("id").primaryKey(),
@ -69,6 +76,8 @@ export const jobs = sqliteTable("jobs", {
})
.notNull()
.default("discovered"),
outcome: text("outcome", { enum: APPLICATION_OUTCOMES }),
closedAt: integer("closed_at", { mode: "number" }),
suitabilityScore: real("suitability_score"),
suitabilityReason: text("suitability_reason"),
tailoredSummary: text("tailored_summary"),
@ -88,6 +97,45 @@ export const jobs = sqliteTable("jobs", {
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", {
id: text("id").primaryKey(),
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 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 NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
export type SettingsRow = typeof settings.$inferSelect;

View File

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

View File

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

View File

@ -34,6 +34,8 @@ const mockJob: Job = {
starting: null,
jobDescription: "Looking for a TypeScript and React expert.",
status: "discovered",
outcome: null,
closedAt: null,
suitabilityScore: null,
suitabilityReason: 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
| "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 =
| "gradcracker"
| "indeed"
@ -42,6 +150,8 @@ export interface Job {
// Orchestrator enrichments
status: JobStatus;
outcome: JobOutcome | null;
closedAt: number | null;
suitabilityScore: number | null; // 0-100 AI-generated score
suitabilityReason: string | null; // AI explanation
tailoredSummary: string | null; // Generated resume summary
@ -161,6 +271,8 @@ export interface ManualJobFetchResponse {
export interface UpdateJobInput {
status?: JobStatus;
outcome?: JobOutcome | null;
closedAt?: number | null;
jobDescription?: string;
suitabilityScore?: number;
suitabilityReason?: string;