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:
parent
ad6a79e74f
commit
6e771ce728
20
orchestrator/package-lock.json
generated
20
orchestrator/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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");
|
||||
|
||||
50
orchestrator/src/client/components/ConfirmDelete.tsx
Normal file
50
orchestrator/src/client/components/ConfirmDelete.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 */}
|
||||
|
||||
113
orchestrator/src/client/components/LogEventModal.test.tsx
Normal file
113
orchestrator/src/client/components/LogEventModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
241
orchestrator/src/client/components/LogEventModal.tsx
Normal file
241
orchestrator/src/client/components/LogEventModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
orchestrator/src/client/components/OnboardingGate.test.tsx
Normal file
126
orchestrator/src/client/components/OnboardingGate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 &&
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
375
orchestrator/src/client/pages/JobPage.tsx
Normal file
375
orchestrator/src/client/pages/JobPage.tsx
Normal 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);
|
||||
};
|
||||
@ -24,6 +24,8 @@ const jobFixture: Job = {
|
||||
starting: null,
|
||||
jobDescription: "Build APIs",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
|
||||
45
orchestrator/src/client/pages/job/Timeline.test.tsx
Normal file
45
orchestrator/src/client/pages/job/Timeline.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
334
orchestrator/src/client/pages/job/Timeline.tsx
Normal file
334
orchestrator/src/client/pages/job/Timeline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
268
orchestrator/src/server/services/applicationTracking.test.ts
Normal file
268
orchestrator/src/server/services/applicationTracking.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
359
orchestrator/src/server/services/applicationTracking.ts
Normal file
359
orchestrator/src/server/services/applicationTracking.ts
Normal 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";
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user