From 6e771ce728b071f2a1820eb7c8ff2e97aebefa63 Mon Sep 17 00:00:00 2001
From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com>
Date: Tue, 27 Jan 2026 23:49:11 +0000
Subject: [PATCH] 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>
---
orchestrator/package-lock.json | 20 +
orchestrator/package.json | 3 +
orchestrator/src/client/App.tsx | 4 +-
orchestrator/src/client/api/client.ts | 73 +++-
.../src/client/components/ConfirmDelete.tsx | 50 +++
.../src/client/components/JobHeader.test.tsx | 50 ++-
.../src/client/components/JobHeader.tsx | 46 ++-
.../client/components/LogEventModal.test.tsx | 113 ++++++
.../src/client/components/LogEventModal.tsx | 241 +++++++++++
.../client/components/OnboardingGate.test.tsx | 126 ++++++
.../src/client/components/OnboardingGate.tsx | 5 +
.../src/client/components/ReadyPanel.test.tsx | 11 +-
.../discovered-panel/DiscoveredPanel.test.tsx | 15 +-
orchestrator/src/client/pages/JobPage.tsx | 375 ++++++++++++++++++
.../client/pages/OrchestratorPage.test.tsx | 2 +
.../src/client/pages/job/Timeline.test.tsx | 45 +++
.../src/client/pages/job/Timeline.tsx | 334 ++++++++++++++++
.../orchestrator/JobDetailPanel.test.tsx | 2 +
.../pages/orchestrator/JobListPanel.test.tsx | 2 +
orchestrator/src/lib/utils.ts | 20 +
.../src/server/api/routes/jobs.test.ts | 133 +++++++
orchestrator/src/server/api/routes/jobs.ts | 175 +++++++-
orchestrator/src/server/db/clear.ts | 4 +-
orchestrator/src/server/db/migrate.ts | 68 +++-
orchestrator/src/server/db/schema.ts | 54 +++
.../server/pipeline/sponsor-matching.test.ts | 2 +
orchestrator/src/server/repositories/jobs.ts | 2 +
.../src/server/services/ai-resilience.test.ts | 2 +
.../services/applicationTracking.test.ts | 268 +++++++++++++
.../server/services/applicationTracking.ts | 359 +++++++++++++++++
orchestrator/src/shared/types.ts | 112 ++++++
31 files changed, 2682 insertions(+), 34 deletions(-)
create mode 100644 orchestrator/src/client/components/ConfirmDelete.tsx
create mode 100644 orchestrator/src/client/components/LogEventModal.test.tsx
create mode 100644 orchestrator/src/client/components/LogEventModal.tsx
create mode 100644 orchestrator/src/client/components/OnboardingGate.test.tsx
create mode 100644 orchestrator/src/client/pages/JobPage.tsx
create mode 100644 orchestrator/src/client/pages/job/Timeline.test.tsx
create mode 100644 orchestrator/src/client/pages/job/Timeline.tsx
create mode 100644 orchestrator/src/server/services/applicationTracking.test.ts
create mode 100644 orchestrator/src/server/services/applicationTracking.ts
diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json
index 0e8e7f0..ef42ff4 100644
--- a/orchestrator/package-lock.json
+++ b/orchestrator/package-lock.json
@@ -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",
diff --git a/orchestrator/package.json b/orchestrator/package.json
index 9a48c4b..9aaf88f 100644
--- a/orchestrator/package.json
+++ b/orchestrator/package.json
@@ -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",
diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx
index 152e650..6056102 100644
--- a/orchestrator/src/client/App.tsx
+++ b/orchestrator/src/client/App.tsx
@@ -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 = () => {
} />
+ } />
} />
} />
} />
diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts
index 875404d..1ef8e19 100644
--- a/orchestrator/src/client/api/client.ts
+++ b/orchestrator/src/client/api/client.ts
@@ -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 {
}
export async function getJob(id: string): Promise {
- return fetchApi(`/jobs/${id}`);
+ return fetchApi(`/jobs/${id}?t=${Date.now()}`);
}
export async function updateJob(
@@ -130,6 +136,71 @@ export async function skipJob(id: string): Promise {
});
}
+export async function getJobStageEvents(id: string): Promise {
+ return fetchApi(`/jobs/${id}/events?t=${Date.now()}`);
+}
+
+export async function getJobTasks(
+ id: string,
+ options?: { includeCompleted?: boolean },
+): Promise {
+ const params = new URLSearchParams();
+ if (options?.includeCompleted) params.set("includeCompleted", "1");
+ params.set("t", Date.now().toString());
+ const query = params.toString();
+ return fetchApi(`/jobs/${id}/tasks?${query}`);
+}
+
+export async function transitionJobStage(
+ id: string,
+ input: {
+ toStage: StageTransitionTarget;
+ occurredAt?: number | null;
+ metadata?: StageEventMetadata | null;
+ outcome?: JobOutcome | null;
+ },
+): Promise {
+ return fetchApi(`/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 {
+ return fetchApi(`/jobs/${id}/events/${eventId}`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
+export async function deleteJobStageEvent(
+ id: string,
+ eventId: string,
+): Promise {
+ return fetchApi(`/jobs/${id}/events/${eventId}`, {
+ method: "DELETE",
+ });
+}
+
+export async function updateJobOutcome(
+ id: string,
+ input: { outcome: JobOutcome | null; closedAt?: number | null },
+): Promise {
+ return fetchApi(`/jobs/${id}/outcome`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
// Pipeline API
export async function getPipelineStatus(): Promise {
return fetchApi("/pipeline/status");
diff --git a/orchestrator/src/client/components/ConfirmDelete.tsx b/orchestrator/src/client/components/ConfirmDelete.tsx
new file mode 100644
index 0000000..b7bb31b
--- /dev/null
+++ b/orchestrator/src/client/components/ConfirmDelete.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ title = "Are you sure?",
+ description = "This action cannot be undone. This will permanently delete this event from the timeline.",
+}) => {
+ return (
+ !open && onClose()}>
+
+
+ {title}
+ {description}
+
+
+ Cancel
+ {
+ onConfirm();
+ onClose();
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete
+
+
+
+
+ );
+};
diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx
index 90180e0..94581fa 100644
--- a/orchestrator/src/client/components/JobHeader.test.tsx
+++ b/orchestrator/src/client/components/JobHeader.test.tsx
@@ -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({ui});
+
beforeEach(() => {
vi.clearAllMocks();
(useSettings as any).mockReturnValue({
@@ -54,21 +60,37 @@ describe("JobHeader", () => {
});
it("renders basic job information", () => {
- render();
+ renderWithRouter();
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();
+
+ 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();
+ renderWithRouter(
+ ,
+ );
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();
+ renderWithRouter();
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
});
@@ -90,7 +112,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 85,
sponsorMatchNames: '["Techy Corp"]',
};
- render();
+ renderWithRouter();
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
});
@@ -101,7 +123,7 @@ describe("JobHeader", () => {
sponsorMatchScore: 40,
sponsorMatchNames: '["Other Corp"]',
};
- render();
+ renderWithRouter();
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
});
@@ -112,11 +134,23 @@ describe("JobHeader", () => {
});
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
- render();
+ renderWithRouter();
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(
+
+
+ ,
+ );
+
+ expect(
+ screen.queryByRole("link", { name: /view/i }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx
index 0c8f803..2ee8ac8 100644
--- a/orchestrator/src/client/components/JobHeader.tsx
+++ b/orchestrator/src/client/components/JobHeader.tsx
@@ -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 = ({
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 = ({
{/* Detail header: lighter weight than list items */}
-
+
{job.title}
-
+
{job.employer}
-
- {sourceLabel[job.source]}
-
+
+
+ {sourceLabel[job.source]}
+
+ {!isJobPage && (
+
+ )}
+
{/* Tertiary metadata - subdued */}
diff --git a/orchestrator/src/client/components/LogEventModal.test.tsx b/orchestrator/src/client/components/LogEventModal.test.tsx
new file mode 100644
index 0000000..50c3b62
--- /dev/null
+++ b/orchestrator/src/client/components/LogEventModal.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogCancel: ({
+ children,
+ ...props
+ }: {
+ children: React.ReactNode;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/components/ui/select", () => ({
+ Select: ({
+ children,
+ value,
+ onValueChange,
+ }: {
+ children: React.ReactNode;
+ value?: string;
+ onValueChange?: (value: string) => void;
+ }) => (
+
+ ),
+ SelectContent: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ SelectItem: ({
+ children,
+ value,
+ }: {
+ children: React.ReactNode;
+ value: string;
+ }) => ,
+ 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();
+
+ 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();
+
+ 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();
+ });
+});
diff --git a/orchestrator/src/client/components/LogEventModal.tsx b/orchestrator/src/client/components/LogEventModal.tsx
new file mode 100644
index 0000000..ebeab8c
--- /dev/null
+++ b/orchestrator/src/client/components/LogEventModal.tsx
@@ -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;
+
+interface LogEventModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onLog: (values: LogEventFormValues, eventId?: string) => Promise;
+ 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 = ({
+ isOpen,
+ onClose,
+ onLog,
+ editingEvent,
+}) => {
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ setValue,
+ reset,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ 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 (
+ !open && onClose()}>
+
+
+
+ {editingEvent ? "Edit Event" : "Log Event"}
+
+
+ {editingEvent
+ ? "Update the details of this event."
+ : "Record a new update or stage change for this application."}
+
+
+
+
+
+
+ );
+};
diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx
new file mode 100644
index 0000000..389deb8
--- /dev/null
+++ b/orchestrator/src/client/components/OnboardingGate.test.tsx
@@ -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 }) => {label}
,
+}));
+
+vi.mock("@client/pages/settings/components/BaseResumeSelection", () => ({
+ BaseResumeSelection: () => Base resume selection
,
+}));
+
+vi.mock("@/components/ui/alert-dialog", () => ({
+ AlertDialog: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/components/ui/tabs", () => ({
+ Tabs: ({ children }: { children: React.ReactNode }) => {children}
,
+ TabsContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TabsList: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TabsTrigger: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+}));
+
+vi.mock("@/components/ui/progress", () => ({
+ Progress: () => Progress
,
+}));
+
+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();
+
+ 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();
+
+ await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
+ expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
+ });
+});
diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx
index edbb52c..7359da2 100644
--- a/orchestrator/src/client/components/OnboardingGate.tsx
+++ b/orchestrator/src/client/components/OnboardingGate.tsx
@@ -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 &&
diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx
index 92741bf..1692078 100644
--- a/orchestrator/src/client/components/ReadyPanel.test.tsx
+++ b/orchestrator/src/client/components/ReadyPanel.test.tsx
@@ -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 => ({
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(
- ,
+
+
+ ,
);
fireEvent.click(
diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx
index cc98084..4ad0943 100644
--- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx
+++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.test.tsx
@@ -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 => ({
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(
- ,
+
+
+ ,
);
fireEvent.click(
diff --git a/orchestrator/src/client/pages/JobPage.tsx b/orchestrator/src/client/pages/JobPage.tsx
new file mode 100644
index 0000000..a766c9d
--- /dev/null
+++ b/orchestrator/src/client/pages/JobPage.tsx
@@ -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(null);
+ const [events, setEvents] = React.useState([]);
+ const [tasks, setTasks] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
+ const [eventToDelete, setEventToDelete] = React.useState(null);
+ const [editingEvent, setEditingEvent] = React.useState(
+ null,
+ );
+ const pendingEventRef = React.useRef(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 (
+
+
+
+
+
+
+
+
+ {job ? (
+
+ ) : (
+
+ {isLoading ? "Loading application..." : "Application not found."}
+
+ )}
+
+
+
+
+
+
+
+ Stage timeline
+
+
+ {job?.salary && (
+
+
+ {job.salary}
+
+ )}
+ {currentStage && (
+
+ {STAGE_LABELS[currentStage as ApplicationStage] ||
+ currentStage}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Application details
+
+
+
+
+
+ Current Stage
+
+
+ {currentStage
+ ? STAGE_LABELS[currentStage as ApplicationStage] ||
+ currentStage
+ : job?.status}
+
+
+
+
+ Outcome
+
+
+ {job?.outcome ? job.outcome.replace(/_/g, " ") : "Open"}
+
+
+ {job?.closedAt && (
+
+
+ Closed On
+
+
+ {formatTimestamp(job.closedAt)}
+
+
+ )}
+
+
+
+ {tasks.length > 0 && (
+
+
+
+
+ Upcoming tasks
+
+
+
+
+ {tasks.map((task) => (
+
+
+
+ {task.title}
+
+ {task.notes && (
+
+ {task.notes}
+
+ )}
+
+
+ {formatTimestamp(task.dueDate)}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {
+ setIsLogModalOpen(false);
+ setEditingEvent(null);
+ }}
+ onLog={handleLogEvent}
+ editingEvent={editingEvent}
+ />
+
+ {
+ setIsDeleteModalOpen(false);
+ setEventToDelete(null);
+ }}
+ onConfirm={handleDeleteEvent}
+ />
+
+ );
+};
+
+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);
+};
diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
index e6a297e..cfbd7dc 100644
--- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx
+++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
@@ -24,6 +24,8 @@ const jobFixture: Job = {
starting: null,
jobDescription: "Build APIs",
status: "ready",
+ outcome: null,
+ closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
diff --git a/orchestrator/src/client/pages/job/Timeline.test.tsx b/orchestrator/src/client/pages/job/Timeline.test.tsx
new file mode 100644
index 0000000..db1f610
--- /dev/null
+++ b/orchestrator/src/client/pages/job/Timeline.test.tsx
@@ -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(
+ ,
+ );
+
+ 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();
+
+ expect(screen.queryByTitle("Edit event")).not.toBeInTheDocument();
+ expect(screen.queryByTitle("Delete event")).not.toBeInTheDocument();
+ });
+});
diff --git a/orchestrator/src/client/pages/job/Timeline.tsx b/orchestrator/src/client/pages/job/Timeline.tsx
new file mode 100644
index 0000000..2d6edb5
--- /dev/null
+++ b/orchestrator/src/client/pages/job/Timeline.tsx
@@ -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 = {
+ applied: ,
+ recruiter_screen: ,
+ assessment: ,
+ hiring_manager_screen: ,
+ technical_interview: ,
+ onsite: ,
+ offer: ,
+ closed: ,
+};
+
+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 = ({
+ events,
+ onEdit,
+ onDelete,
+}) => {
+ const [openGroups, setOpenGroups] = React.useState>(
+ {},
+ );
+ const lastEvent = events.at(-1);
+ const currentStage = lastEvent?.toStage ?? null;
+
+ const entries = React.useMemo(() => {
+ const groups = new Map();
+ 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 (
+
+ No stage events yet.
+
+ );
+ }
+
+ return (
+
+ {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 (
+
onEdit(entry.event) : undefined}
+ onDelete={onDelete ? () => onDelete(entry.event.id) : undefined}
+ >
+ {note && (
+ {note}
+ )}
+ {salary && (
+
+ {salary}
+
+ )}
+ {reason && (
+
+ {reason}
+
+ )}
+
+ );
+ }
+
+ 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 (
+
+
}
+ isCurrent={isCurrentGroup && !groupCompleted}
+ isCompleted={groupCompleted}
+ isLast={entryIndex === entries.length - 1}
+ >
+
+
+ {entry.events.map((event) => (
+
onEdit(event) : undefined}
+ onDelete={onDelete ? () => onDelete(event.id) : undefined}
+ >
+ {event.metadata?.note && (
+
+ {event.metadata.note}
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+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 = ({
+ date,
+ title,
+ icon,
+ isCurrent,
+ isOffer,
+ isCompleted,
+ isLast,
+ isCompact,
+ onEdit,
+ onDelete,
+ children,
+}) => {
+ const isHollow = Boolean(isCurrent) && !isCompleted;
+ const isFilled = !isHollow;
+
+ return (
+
+
+
+ {date}
+
+
+
+
+ {isFilled && icon}
+
+ {isLast && (
+
+ )}
+
+
+
+
+ {title}
+
+ {children}
+
+
+
+ {onEdit && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx
index a922cb9..b758ebe 100644
--- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx
@@ -106,6 +106,8 @@ const createJob = (overrides: Partial = {}): Job => ({
starting: null,
jobDescription: "Build APIs",
status: "ready",
+ outcome: null,
+ closedAt: null,
suitabilityScore: 82,
suitabilityReason: "Strong fit",
tailoredSummary: null,
diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
index b2361ee..df61589 100644
--- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
@@ -22,6 +22,8 @@ const createJob = (overrides: Partial = {}): Job => ({
starting: null,
jobDescription: "Build APIs",
status: "ready",
+ outcome: null,
+ closedAt: null,
suitabilityScore: 72,
suitabilityReason: null,
tailoredSummary: null,
diff --git a/orchestrator/src/lib/utils.ts b/orchestrator/src/lib/utils.ts
index 93f0754..f0f0119 100644
--- a/orchestrator/src/lib/utils.ts
+++ b/orchestrator/src/lib/utils.ts
@@ -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 {
diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts
index 0a7e8a4..ebb1314 100644
--- a/orchestrator/src/server/api/routes/jobs.test.ts
+++ b/orchestrator/src/server/api/routes/jobs.test.ts
@@ -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();
+ });
+ });
});
diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts
index b02f55d..64ba48e 100644
--- a/orchestrator/src/server/api/routes/jobs.ts
+++ b/orchestrator/src/server/api/routes/jobs.ts
@@ -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";
diff --git a/orchestrator/src/server/db/clear.ts b/orchestrator/src/server/db/clear.ts
index cdbfd62..4ebef9b 100644
--- a/orchestrator/src/server/db/clear.ts
+++ b/orchestrator/src/server/db/clear.ts
@@ -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,
diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts
index 7743c10..4b5fea2 100644
--- a/orchestrator/src/server/db/migrate.ts
+++ b/orchestrator/src/server/db/migrate.ts
@@ -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) {
diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts
index 9b876bd..55f119e 100644
--- a/orchestrator/src/server/db/schema.ts
+++ b/orchestrator/src/server/db/schema.ts
@@ -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;
diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts
index c115918..5e1c5dc 100644
--- a/orchestrator/src/server/pipeline/sponsor-matching.test.ts
+++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts
@@ -73,6 +73,8 @@ const createMockJob = (overrides: Partial = {}): Job => ({
starting: null,
jobDescription: "Looking for a TypeScript developer.",
status: "discovered",
+ outcome: null,
+ closedAt: null,
suitabilityScore: null,
suitabilityReason: null,
tailoredSummary: null,
diff --git a/orchestrator/src/server/repositories/jobs.ts b/orchestrator/src/server/repositories/jobs.ts
index 09f800c..3f9ba46 100644
--- a/orchestrator/src/server/repositories/jobs.ts
+++ b/orchestrator/src/server/repositories/jobs.ts
@@ -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,
diff --git a/orchestrator/src/server/services/ai-resilience.test.ts b/orchestrator/src/server/services/ai-resilience.test.ts
index 0cb2c45..4283780 100644
--- a/orchestrator/src/server/services/ai-resilience.test.ts
+++ b/orchestrator/src/server/services/ai-resilience.test.ts
@@ -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,
diff --git a/orchestrator/src/server/services/applicationTracking.test.ts b/orchestrator/src/server/services/applicationTracking.test.ts
new file mode 100644
index 0000000..44e4512
--- /dev/null
+++ b/orchestrator/src/server/services/applicationTracking.test.ts
@@ -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);
+ });
+});
diff --git a/orchestrator/src/server/services/applicationTracking.ts b/orchestrator/src/server/services/applicationTracking.ts
new file mode 100644
index 0000000..8b7de2c
--- /dev/null
+++ b/orchestrator/src/server/services/applicationTracking.ts
@@ -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 = {
+ 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 {
+ 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 {
+ 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 = {
+ 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 = {};
+ 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";
+}
diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts
index 094f849..5ed311c 100644
--- a/orchestrator/src/shared/types.ts
+++ b/orchestrator/src/shared/types.ts
@@ -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 = {
+ 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;