response rate by source chart (#207)

* response rate by source

* docs

* add gpt improvements

* mobile resp

* UX

* chartkpi
This commit is contained in:
Shaheer Sarfaraz 2026-02-20 01:39:54 +00:00 committed by GitHub
parent eed5c2adba
commit 1573d8dfbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 436 additions and 25 deletions

View File

@ -1,7 +1,7 @@
--- ---
id: overview id: overview
title: 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 sidebar_position: 1
--- ---
@ -16,6 +16,7 @@ It visualizes:
- Applications per day - Applications per day
- Application-to-response conversion - Application-to-response conversion
- Funnel progression (Applied, Screening, Interview, Offer, Rejected) - Funnel progression (Applied, Screening, Interview, Offer, Rejected)
- Response rate by source
### Graph-level views ### Graph-level views
@ -32,6 +33,7 @@ Use it to quickly answer:
- Are application volumes increasing or dropping? - Are application volumes increasing or dropping?
- Is response conversion improving? - Is response conversion improving?
- Where are applications stalling in the funnel? - Where are applications stalling in the funnel?
- Which job boards are actually generating responses?
## How to use it ## How to use it
@ -40,6 +42,7 @@ Use it to quickly answer:
3. Review: 3. Review:
- **Applications per day** for volume trend - **Applications per day** for volume trend
- **Application → Response Conversion** for quality/outcome 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. 4. Compare periods and adjust your sourcing terms, filters, or tailoring strategy.
### Data and calculation defaults ### 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. - 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 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. - 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 ## Common problems
@ -66,6 +83,16 @@ Use it to quickly answer:
- Volume trend compares first-half vs second-half averages in the selected window. - Volume trend compares first-half vs second-half averages in the selected window.
- Changing the time window can materially change trend direction. - 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 ## Related pages
- [Orchestrator](/docs/next/features/orchestrator) - [Orchestrator](/docs/next/features/orchestrator)

View File

@ -41,6 +41,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",

View File

@ -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 (
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold leading-none sm:text-3xl">
{rate.toFixed(1)}%
</span>
{rate < lowThreshold ? (
<TrendingDown className="h-4 w-4 text-destructive" />
) : rate > highThreshold ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : null}
</div>
<span className="text-xs text-muted-foreground">{subtext}</span>
</div>
</div>
);
}

View File

@ -4,7 +4,6 @@
*/ */
import type { StageEvent } from "@shared/types.js"; import type { StageEvent } from "@shared/types.js";
import { TrendingDown, TrendingUp } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
Bar, Bar,
@ -27,6 +26,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import { ChartKpiPanel } from "./ChartKpiPanel";
type FunnelStage = { type FunnelStage = {
name: string; name: string;
@ -293,27 +293,11 @@ export function ConversionAnalytics({
How many applications received a positive response from the company. How many applications received a positive response from the company.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6"> <ChartKpiPanel
<div className="flex flex-col gap-1"> label="Conversion Rate"
<span className="text-xs text-muted-foreground"> rate={overallConversion.rate}
Conversion Rate subtext={`${overallConversion.converted} of ${overallConversion.total} applications`}
</span> />
<div className="flex items-center gap-2">
<span className="text-lg font-bold leading-none sm:text-3xl">
{overallConversion.rate.toFixed(1)}%
</span>
{overallConversion.rate < 10 ? (
<TrendingDown className="h-4 w-4 text-destructive" />
) : overallConversion.rate > 25 ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : null}
</div>
<span className="text-xs text-muted-foreground">
{overallConversion.converted} of {overallConversion.total}{" "}
applications
</span>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6"> <CardContent className="px-2 sm:p-6">
{error ? ( {error ? (

View File

@ -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<JobSource, string> = {
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<JobSource, { applied: number; responded: number }>();
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 (
<Card className="py-0">
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
<CardTitle>Response Rate by Source</CardTitle>
<CardDescription>
% of applications that got a response (not rejected or ghosted).
</CardDescription>
</div>
<ChartKpiPanel
label="Response Rate"
rate={overallRate}
subtext={`${totalResponded} of ${totalApplied} applications`}
/>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{error ? (
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
) : allData.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
No application data available.
</div>
) : (
<div className="space-y-4">
{/* Minimum sample toggle */}
<div className="flex items-center justify-end gap-2">
<Label
htmlFor="include-small-toggle"
className="text-xs text-muted-foreground cursor-pointer"
>
{includeSmall ? (
"All sources"
) : (
<>
n &lt; {MIN_SAMPLE_DEFAULT} hidden
{hiddenCount > 0 && (
<span className="ml-1 text-muted-foreground/60">
({hiddenCount})
</span>
)}
</>
)}
</Label>
<Switch
id="include-small-toggle"
checked={includeSmall}
onCheckedChange={setIncludeSmall}
aria-label="Include small samples"
/>
</div>
{data.length === 0 ? (
<div className="px-4 py-4 text-sm text-muted-foreground">
All sources have fewer than {MIN_SAMPLE_DEFAULT} applications.
Enable the toggle above to show them.
</div>
) : (
<ChartContainer
config={chartConfig}
className="w-full"
style={{ height: chartHeight }}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
layout="vertical"
margin={{ left: 4, right: 36, top: 4, bottom: 4 }}
>
<CartesianGrid vertical={false} />
<XAxis
type="number"
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
tick={{ fontSize: 11 }}
/>
<YAxis
dataKey="source"
type="category"
tickLine={false}
axisLine={false}
width={108}
tick={{ fontSize: 11 }}
/>
<Tooltip
cursor={{ fill: "var(--chart-2)", opacity: 0.15 }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as SourceRateDataPoint;
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
<div className="mb-1.5 font-medium">{d.source}</div>
<div className="space-y-1 text-muted-foreground">
<div className="flex items-center justify-between gap-4">
<span>Response rate</span>
<span className="font-semibold text-foreground">
{d.rate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span>Responded</span>
<span className="font-semibold text-foreground">
{d.responded}
</span>
</div>
</div>
<div className="mt-2 border-t pt-1.5 text-[10px] text-muted-foreground/70">
Screening, interview, or offer only
</div>
</div>
);
}}
/>
<Bar dataKey="rate" radius={[0, 4, 4, 0]}>
{data.map((entry, index) => (
<Cell
key={entry.source}
fill={BAR_COLORS[index % BAR_COLORS.length]}
/>
))}
<LabelList
dataKey="rate"
position="right"
formatter={(v: number) => `${v.toFixed(0)}%`}
className="text-xs fill-foreground"
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -2,3 +2,4 @@ export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
export { ConversionAnalytics } from "./ConversionAnalytics"; export { ConversionAnalytics } from "./ConversionAnalytics";
export type { DurationValue } from "./DurationSelector"; export type { DurationValue } from "./DurationSelector";
export { DurationSelector } from "./DurationSelector"; export { DurationSelector } from "./DurationSelector";
export { ResponseRateBySourceChart } from "./ResponseRateBySourceChart";

View File

@ -4,9 +4,10 @@ import {
ConversionAnalytics, ConversionAnalytics,
DurationSelector, DurationSelector,
type DurationValue, type DurationValue,
ResponseRateBySourceChart,
} from "@client/components/charts"; } from "@client/components/charts";
import { PageHeader, PageMain } from "@client/components/layout"; 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChartColumn } from "lucide-react"; import { ChartColumn } from "lucide-react";
import type React from "react"; import type React from "react";
@ -16,6 +17,7 @@ import { queryKeys } from "@/client/lib/queryKeys";
type JobWithEvents = { type JobWithEvents = {
id: string; id: string;
source: JobSource;
datePosted: string | null; datePosted: string | null;
discoveredAt: string; discoveredAt: string;
appliedAt: string | null; appliedAt: string | null;
@ -54,10 +56,10 @@ export const HomePage: React.FC = () => {
const appliedDates = response.jobs.map((job) => job.appliedAt); const appliedDates = response.jobs.map((job) => job.appliedAt);
const jobSummaries = response.jobs.map((job) => ({ const jobSummaries = response.jobs.map((job) => ({
id: job.id, id: job.id,
source: job.source,
datePosted: job.datePosted, datePosted: job.datePosted,
discoveredAt: job.discoveredAt, discoveredAt: job.discoveredAt,
appliedAt: job.appliedAt, appliedAt: job.appliedAt,
positiveResponse: false,
})); }));
const appliedJobs = jobSummaries.filter((job) => job.appliedAt); const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
@ -151,6 +153,8 @@ export const HomePage: React.FC = () => {
error={error} error={error}
daysToShow={duration} daysToShow={duration}
/> />
<ResponseRateBySourceChart jobs={jobsWithEvents} error={error} />
</PageMain> </PageMain>
</> </>
); );

View File

@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

30
package-lock.json generated
View File

@ -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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",