mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 06:47:00 +10:00
84 lines
3.6 KiB
TypeScript
84 lines
3.6 KiB
TypeScript
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
|
import { STAGES } from "@reactive-resume/schema/applications/data";
|
|
|
|
export type StageCount = { status: ApplicationStatus; count: number };
|
|
|
|
// The forward pipeline (rejected is a terminal outcome, handled separately).
|
|
const FORWARD: ApplicationStatus[] = ["saved", "applied", "screening", "interview", "offer"];
|
|
|
|
export type Insights = {
|
|
total: number;
|
|
tiles: { label: string; value: string; sub: string }[];
|
|
funnel: { label: string; color: string; count: number; reached: number; pct: number; conv: string }[];
|
|
rejected: number;
|
|
};
|
|
|
|
// Pure function of the raw per-stage counts, so it's trivially testable and shared by every
|
|
// chart in the Insights view. "reached[i]" assumes the pipeline is monotonic for currently
|
|
// active applications (an app at Interview has passed through Screening).
|
|
export function computeInsights(byStage: StageCount[]): Insights {
|
|
const counts = new Map<ApplicationStatus, number>(byStage.map((row) => [row.status, row.count]));
|
|
const at = (status: ApplicationStatus) => counts.get(status) ?? 0;
|
|
const total = byStage.reduce((sum, row) => sum + row.count, 0);
|
|
const rejected = at("rejected");
|
|
|
|
// reached[i] = active apps that got at least as far as FORWARD[i].
|
|
const reached = FORWARD.map((_, i) => FORWARD.slice(i).reduce((sum, s) => sum + at(s), 0));
|
|
const appliedOn = reached[1] ?? 0; // everything that made it past "saved"
|
|
|
|
const funnel = FORWARD.map((status, i) => {
|
|
const stage = STAGES.find((s) => s.value === status);
|
|
const reachedCount = reached[i] ?? 0;
|
|
const prev = i === 0 ? reachedCount : (reached[i - 1] ?? reachedCount);
|
|
return {
|
|
label: stage?.label ?? status,
|
|
color: stage?.color ?? "var(--muted)",
|
|
count: at(status),
|
|
reached: reachedCount,
|
|
pct: total > 0 ? Math.round((reachedCount / total) * 100) : 0,
|
|
conv: prev > 0 ? `${Math.round((reachedCount / prev) * 100)}%` : "—",
|
|
};
|
|
});
|
|
|
|
const interviews = at("interview") + at("offer");
|
|
const offers = at("offer");
|
|
const responseRate = appliedOn > 0 ? Math.round(((reached[2] ?? 0) / appliedOn) * 100) : 0;
|
|
|
|
const tiles = [
|
|
{ label: "Total applications", value: String(total), sub: "in this view" },
|
|
{ label: "Applied", value: String(appliedOn), sub: "past saved" },
|
|
{ label: "Response rate", value: `${responseRate}%`, sub: "reached screening" },
|
|
{ label: "Interviews", value: String(interviews), sub: "interview or beyond" },
|
|
{ label: "Offers", value: String(offers), sub: rejected > 0 ? `${rejected} rejected` : "so far" },
|
|
];
|
|
|
|
return { total, tiles, funnel, rejected };
|
|
}
|
|
|
|
export type TimelineBucket = { label: string; count: number };
|
|
|
|
// Bucket application dates into the last `weeks` calendar weeks (Sunday-started) so the Insights
|
|
// view can show application velocity over time — a dimension the funnel/tiles don't capture.
|
|
export function computeTimeline(dates: Date[], weeks = 8): TimelineBucket[] {
|
|
const msWeek = 7 * 86_400_000;
|
|
const startOfWeek = new Date();
|
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
|
|
|
|
const buckets = Array.from({ length: weeks }, (_, i) => {
|
|
const start = new Date(startOfWeek.getTime() - (weeks - 1 - i) * msWeek);
|
|
return { start: start.getTime(), label: `${start.getMonth() + 1}/${start.getDate()}`, count: 0 };
|
|
});
|
|
|
|
const first = buckets[0]?.start ?? 0;
|
|
for (const date of dates) {
|
|
const time = date.getTime();
|
|
if (time < first) continue;
|
|
const index = Math.min(weeks - 1, Math.floor((time - first) / msWeek));
|
|
const bucket = buckets[index];
|
|
if (bucket) bucket.count++;
|
|
}
|
|
|
|
return buckets.map(({ label, count }) => ({ label, count }));
|
|
}
|