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