diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx index 613dcd1..43560dd 100644 --- a/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx +++ b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx @@ -40,8 +40,17 @@ vi.mock("@/components/ui/chart", () => ({ })); vi.mock("recharts", () => ({ - BarChart: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ BarChart: ({ + children, + data, + }: { + children: React.ReactNode; + data?: unknown; + }) => ( +
+ {children} +
{JSON.stringify(data)}
+
), Bar: () =>
Bar
, Cell: () =>
Cell
, @@ -327,6 +336,69 @@ describe("ConversionAnalytics - Edge Cases", () => { // Job should count in all stages it reached expect(screen.getByTestId("bar-chart")).toBeInTheDocument(); }); + + it("adds rejected funnel bar using rejected outcome/reason code only", () => { + const today = mockDate.toISOString(); + const jobs = [ + createJob("job-1", today, [ + createEvent("closed", 1704844800000), + createStageEvent({ + id: "event-rejected-1", + applicationId: "job-1", + toStage: "closed", + occurredAt: 1704844800001, + outcome: "rejected", + }), + ]), + createJob("job-2", today, [ + createStageEvent({ + id: "event-rejected-2", + applicationId: "job-2", + toStage: "closed", + occurredAt: 1704844800002, + metadata: { reasonCode: "rejected" }, + }), + ]), + createJob("job-3", today, [ + createStageEvent({ + id: "event-withdrawn", + applicationId: "job-3", + toStage: "closed", + occurredAt: 1704844800003, + outcome: "withdrawn", + }), + ]), + ]; + + render( + , + ); + + expect( + screen.getByText( + "Funnel: Applied → Screening → Interview → Offer → Rejected", + ), + ).toBeInTheDocument(); + + const chartDataRaw = screen.getByTestId("bar-chart-data").textContent; + expect(chartDataRaw).not.toBeNull(); + + const chartData = JSON.parse(chartDataRaw ?? "[]") as Array<{ + name: string; + value: number; + }>; + const rejectedDataPoint = chartData.find( + (point) => point.name === "Rejected", + ); + + expect(rejectedDataPoint).toEqual( + expect.objectContaining({ name: "Rejected", value: 2 }), + ); + }); }); describe("Date Range and Invalid Dates", () => { diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx index f7cf13a..7625116 100644 --- a/orchestrator/src/client/components/charts/ConversionAnalytics.tsx +++ b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx @@ -62,6 +62,7 @@ const FUNNEL_STAGES = [ { key: "screening", label: "Screening", color: "#8b5cf6" }, { key: "interview", label: "Interview", color: "#f59e0b" }, { key: "offer", label: "Offer", color: "#10b981" }, + { key: "rejected", label: "Rejected", color: "#ef4444" }, ] as const; // Stages that count as "screening" @@ -87,6 +88,9 @@ const CONVERSION_STAGES = new Set([ // Stages that count as "offer" const OFFER_STAGES = new Set(["offer"]); +const isRejectedEvent = (event: StageEvent) => + event.outcome === "rejected" || event.metadata?.reasonCode === "rejected"; + const toDateKey = (value: Date) => { const year = value.getFullYear(); const month = `${value.getMonth() + 1}`.padStart(2, "0"); @@ -100,6 +104,7 @@ const buildFunnelData = (jobsWithEvents: JobWithEvents[]): FunnelStage[] => { let screening = 0; let interview = 0; let offer = 0; + let rejected = 0; for (const job of jobsWithEvents) { if (!job.appliedAt) continue; @@ -133,6 +138,11 @@ const buildFunnelData = (jobsWithEvents: JobWithEvents[]): FunnelStage[] => { break; } } + + const reachedRejected = job.events.some(isRejectedEvent); + if (reachedRejected) { + rejected++; + } } return [ @@ -140,6 +150,7 @@ const buildFunnelData = (jobsWithEvents: JobWithEvents[]): FunnelStage[] => { { name: "Screening", value: screening, fill: FUNNEL_STAGES[1].color }, { name: "Interview", value: interview, fill: FUNNEL_STAGES[2].color }, { name: "Offer", value: offer, fill: FUNNEL_STAGES[3].color }, + { name: "Rejected", value: rejected, fill: FUNNEL_STAGES[4].color }, ]; }; @@ -312,7 +323,7 @@ export function ConversionAnalytics({ {/* Funnel Chart */}

- Funnel: Applied → Screening → Interview → Offer + Funnel: Applied → Screening → Interview → Offer → Rejected