From 1573d8dfbcc493e0656e3948295240908b39e098 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:39:54 +0000 Subject: [PATCH] response rate by source chart (#207) * response rate by source * docs * add gpt improvements * mobile resp * UX * chartkpi --- docs-site/docs/features/overview.md | 29 +- orchestrator/package.json | 1 + .../components/charts/ChartKpiPanel.tsx | 43 +++ .../components/charts/ConversionAnalytics.tsx | 28 +- .../charts/ResponseRateBySourceChart.tsx | 294 ++++++++++++++++++ .../src/client/components/charts/index.ts | 1 + orchestrator/src/client/pages/HomePage.tsx | 8 +- orchestrator/src/components/ui/switch.tsx | 27 ++ package-lock.json | 30 ++ 9 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 orchestrator/src/client/components/charts/ChartKpiPanel.tsx create mode 100644 orchestrator/src/client/components/charts/ResponseRateBySourceChart.tsx create mode 100644 orchestrator/src/components/ui/switch.tsx diff --git a/docs-site/docs/features/overview.md b/docs-site/docs/features/overview.md index 2621942..f65797a 100644 --- a/docs-site/docs/features/overview.md +++ b/docs-site/docs/features/overview.md @@ -1,7 +1,7 @@ --- id: overview title: Overview -description: Dashboard analytics for application volume and conversion over selectable time windows. +description: Dashboard analytics for application volume, conversion, and response rate by source over selectable time windows. sidebar_position: 1 --- @@ -16,6 +16,7 @@ It visualizes: - Applications per day - Application-to-response conversion - Funnel progression (Applied, Screening, Interview, Offer, Rejected) +- Response rate by source ### Graph-level views @@ -32,6 +33,7 @@ Use it to quickly answer: - Are application volumes increasing or dropping? - Is response conversion improving? - Where are applications stalling in the funnel? +- Which job boards are actually generating responses? ## How to use it @@ -40,6 +42,7 @@ Use it to quickly answer: 3. Review: - **Applications per day** for volume trend - **Application → Response Conversion** for quality/outcome trend + - **Response Rate by Source** to compare job board effectiveness 4. Compare periods and adjust your sourcing terms, filters, or tailoring strategy. ### Data and calculation defaults @@ -48,6 +51,20 @@ Use it to quickly answer: - Only jobs in statuses `applied` and `in_progress` are used as input. - Conversion counts any positive response-stage event (for example recruiter screen, assessment, interview stages, or offer). - Conversion trend chart uses a rolling window up to 7 days. +- Response rate by source is calculated across all time (not scoped to the duration selector), since response events may arrive well after the application window. +- Sources with fewer than 5 applications are hidden by default in the Response Rate by Source chart. Check **Include small samples** to show them. + +### Response Rate by Source + +The **Response Rate by Source** chart shows, for each job board (LinkedIn, Indeed, Gradcracker, etc.), what percentage of your applications received a non-rejection response. + +**What counts as a response:** the application reached at least one of these stages — recruiter screen, assessment, hiring manager screen, technical interview, onsite, or offer. Ghosted applications (no stage events) and rejected outcomes are both excluded from the numerator. + +Each bar is labelled `X% (n=Y)` where `n` is the number of applications from that source, so you can immediately tell whether a high rate comes from a meaningful sample or a single lucky application. The full breakdown (response rate, responded, applied) is also shown in the tooltip. + +Sources are sorted by response rate descending. Sources with fewer than 5 applications are hidden by default to avoid misleading percentages from tiny samples. Check **Include small samples** to show them. + +Use this chart to identify which sources produce genuine engagement versus silence, and concentrate future sourcing effort accordingly. ## Common problems @@ -66,6 +83,16 @@ Use it to quickly answer: - Volume trend compares first-half vs second-half averages in the selected window. - Changing the time window can materially change trend direction. +### Response Rate by Source shows only one source + +- Only sources with at least one applied job appear in the chart. +- If all your applications come from a single board, only that board will be shown. + +### Response rate for a source looks too high or too low + +- Check the `n=` value in the bar label or tooltip. A small sample (e.g. n=2) will produce an unreliable rate. +- Sources with fewer than 5 applications are hidden by default. If you see a suspiciously high rate, you may be looking at a small-sample source — check the n. + ## Related pages - [Orchestrator](/docs/next/features/orchestrator) diff --git a/orchestrator/package.json b/orchestrator/package.json index 4f31008..f47921a 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", diff --git a/orchestrator/src/client/components/charts/ChartKpiPanel.tsx b/orchestrator/src/client/components/charts/ChartKpiPanel.tsx new file mode 100644 index 0000000..792ccaa --- /dev/null +++ b/orchestrator/src/client/components/charts/ChartKpiPanel.tsx @@ -0,0 +1,43 @@ +/** + * ChartKpiPanel + * Reusable stat block used in card headers: label, bold rate, trend icon, subtext. + */ + +import { TrendingDown, TrendingUp } from "lucide-react"; + +interface ChartKpiPanelProps { + label: string; + rate: number; + subtext: string; + /** Rate below this threshold shows TrendingDown. Default 10. */ + lowThreshold?: number; + /** Rate above this threshold shows TrendingUp. Default 25. */ + highThreshold?: number; +} + +export function ChartKpiPanel({ + label, + rate, + subtext, + lowThreshold = 10, + highThreshold = 25, +}: ChartKpiPanelProps) { + return ( +
+
+ {label} +
+ + {rate.toFixed(1)}% + + {rate < lowThreshold ? ( + + ) : rate > highThreshold ? ( + + ) : null} +
+ {subtext} +
+
+ ); +} diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx index 7625116..15b2634 100644 --- a/orchestrator/src/client/components/charts/ConversionAnalytics.tsx +++ b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx @@ -4,7 +4,6 @@ */ import type { StageEvent } from "@shared/types.js"; -import { TrendingDown, TrendingUp } from "lucide-react"; import { useMemo } from "react"; import { Bar, @@ -27,6 +26,7 @@ import { CardTitle, } from "@/components/ui/card"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; +import { ChartKpiPanel } from "./ChartKpiPanel"; type FunnelStage = { name: string; @@ -293,27 +293,11 @@ export function ConversionAnalytics({ How many applications received a positive response from the company. -
-
- - Conversion Rate - -
- - {overallConversion.rate.toFixed(1)}% - - {overallConversion.rate < 10 ? ( - - ) : overallConversion.rate > 25 ? ( - - ) : null} -
- - {overallConversion.converted} of {overallConversion.total}{" "} - applications - -
-
+ {error ? ( diff --git a/orchestrator/src/client/components/charts/ResponseRateBySourceChart.tsx b/orchestrator/src/client/components/charts/ResponseRateBySourceChart.tsx new file mode 100644 index 0000000..449d1c3 --- /dev/null +++ b/orchestrator/src/client/components/charts/ResponseRateBySourceChart.tsx @@ -0,0 +1,294 @@ +/** + * Response Rate by Source Chart + * For each job source, shows the percentage of applications that received + * a non-rejection response — defined as reaching screening, interview, or offer. + * Ghosted/no-reply and rejected outcomes are both excluded from the numerator. + */ + +import type { JobSource, StageEvent } from "@shared/types.js"; +import { useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + LabelList, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ChartContainer } from "@/components/ui/chart"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ChartKpiPanel } from "./ChartKpiPanel"; + +type JobForSourceChart = { + id: string; + source: JobSource; + appliedAt: string | null; + events: StageEvent[]; +}; + +type SourceRateDataPoint = { + source: string; + applied: number; + responded: number; + rate: number; +}; + +const chartConfig = { + rate: { + label: "Response Rate", + color: "var(--chart-2)", + }, +}; + +/** + * Non-rejection response: the application reached screening, interview, or offer. + * Ghosted (no events) and rejected (outcome=rejected) are both excluded. + */ +const RESPONSE_STAGES = new Set([ + "recruiter_screen", + "assessment", + "hiring_manager_screen", + "technical_interview", + "onsite", + "offer", +]); + +const SOURCE_LABELS: Record = { + gradcracker: "Gradcracker", + indeed: "Indeed", + linkedin: "LinkedIn", + glassdoor: "Glassdoor", + ukvisajobs: "UKVisaJobs", + adzuna: "Adzuna", + hiringcafe: "HiringCafe", + manual: "Manual", +}; + +const BAR_COLORS = [ + "#3b82f6", + "#8b5cf6", + "#f59e0b", + "#10b981", + "#ef4444", + "#06b6d4", + "#f97316", + "#84cc16", +]; + +/** Minimum applications required for a source to appear by default. */ +const MIN_SAMPLE_DEFAULT = 5; + +const buildResponseRateBySource = ( + jobs: JobForSourceChart[], +): SourceRateDataPoint[] => { + const bySource = new Map(); + + for (const job of jobs) { + if (!job.appliedAt) continue; + + const existing = bySource.get(job.source) ?? { applied: 0, responded: 0 }; + existing.applied++; + + const hasResponse = job.events.some((e) => RESPONSE_STAGES.has(e.toStage)); + if (hasResponse) { + existing.responded++; + } + + bySource.set(job.source, existing); + } + + return Array.from(bySource.entries()) + .map(([source, { applied, responded }]) => ({ + source: `${SOURCE_LABELS[source] ?? source} (${applied})`, + applied, + responded, + rate: applied > 0 ? (responded / applied) * 100 : 0, + })) + .sort((a, b) => b.rate - a.rate || b.applied - a.applied); +}; + +interface ResponseRateBySourceChartProps { + jobs: JobForSourceChart[]; + error: string | null; +} + +export function ResponseRateBySourceChart({ + jobs, + error, +}: ResponseRateBySourceChartProps) { + const [includeSmall, setIncludeSmall] = useState(false); + + const allData = useMemo(() => buildResponseRateBySource(jobs), [jobs]); + + const data = useMemo( + () => + includeSmall + ? allData + : allData.filter((d) => d.applied >= MIN_SAMPLE_DEFAULT), + [allData, includeSmall], + ); + + const hiddenCount = allData.length - data.length; + + const totalApplied = useMemo( + () => allData.reduce((sum, d) => sum + d.applied, 0), + [allData], + ); + const totalResponded = useMemo( + () => allData.reduce((sum, d) => sum + d.responded, 0), + [allData], + ); + const overallRate = + totalApplied > 0 ? (totalResponded / totalApplied) * 100 : 0; + + const chartHeight = Math.max(80, data.length * 52); + + return ( + + +
+ Response Rate by Source + + % of applications that got a response (not rejected or ghosted). + +
+ +
+ + {error ? ( +
{error}
+ ) : allData.length === 0 ? ( +
+ No application data available. +
+ ) : ( +
+ {/* Minimum sample toggle */} +
+ + +
+ + {data.length === 0 ? ( +
+ All sources have fewer than {MIN_SAMPLE_DEFAULT} applications. + Enable the toggle above to show them. +
+ ) : ( + + + + + `${v}%`} + tick={{ fontSize: 11 }} + /> + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as SourceRateDataPoint; + return ( +
+
{d.source}
+
+
+ Response rate + + {d.rate.toFixed(1)}% + +
+
+ Responded + + {d.responded} + +
+
+
+ Screening, interview, or offer only +
+
+ ); + }} + /> + + {data.map((entry, index) => ( + + ))} + `${v.toFixed(0)}%`} + className="text-xs fill-foreground" + /> + +
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/orchestrator/src/client/components/charts/index.ts b/orchestrator/src/client/components/charts/index.ts index 537e5b5..2062945 100644 --- a/orchestrator/src/client/components/charts/index.ts +++ b/orchestrator/src/client/components/charts/index.ts @@ -2,3 +2,4 @@ export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart"; export { ConversionAnalytics } from "./ConversionAnalytics"; export type { DurationValue } from "./DurationSelector"; export { DurationSelector } from "./DurationSelector"; +export { ResponseRateBySourceChart } from "./ResponseRateBySourceChart"; diff --git a/orchestrator/src/client/pages/HomePage.tsx b/orchestrator/src/client/pages/HomePage.tsx index 3bc0693..706a8a2 100644 --- a/orchestrator/src/client/pages/HomePage.tsx +++ b/orchestrator/src/client/pages/HomePage.tsx @@ -4,9 +4,10 @@ import { ConversionAnalytics, DurationSelector, type DurationValue, + ResponseRateBySourceChart, } from "@client/components/charts"; import { PageHeader, PageMain } from "@client/components/layout"; -import type { StageEvent } from "@shared/types.js"; +import type { JobSource, StageEvent } from "@shared/types.js"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChartColumn } from "lucide-react"; import type React from "react"; @@ -16,6 +17,7 @@ import { queryKeys } from "@/client/lib/queryKeys"; type JobWithEvents = { id: string; + source: JobSource; datePosted: string | null; discoveredAt: string; appliedAt: string | null; @@ -54,10 +56,10 @@ export const HomePage: React.FC = () => { const appliedDates = response.jobs.map((job) => job.appliedAt); const jobSummaries = response.jobs.map((job) => ({ id: job.id, + source: job.source, datePosted: job.datePosted, discoveredAt: job.discoveredAt, appliedAt: job.appliedAt, - positiveResponse: false, })); const appliedJobs = jobSummaries.filter((job) => job.appliedAt); @@ -151,6 +153,8 @@ export const HomePage: React.FC = () => { error={error} daysToShow={duration} /> + + ); diff --git a/orchestrator/src/components/ui/switch.tsx b/orchestrator/src/components/ui/switch.tsx new file mode 100644 index 0000000..0881d38 --- /dev/null +++ b/orchestrator/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/package-lock.json b/package-lock.json index ba92e59..d03f40b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7118,6 +7118,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -24652,6 +24681,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18",