diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index c20325a..1b5b16c 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -272,6 +272,43 @@ test("older job emails are counted before they're read, and the run can be stopp api.expectAllMocked(); }); +test("the chart shows how applications went, with no reply only after 4 weeks of silence", async ({ page }) => { + await signIn(page); + const daysAgo = (n: number) => new Date(Date.now() - n * 86400000).toISOString(); + const sent = (id: string, status: string, applied: number, steps: [string, number][] = []) => app(id, status, { + date_applied: daysAgo(applied).slice(0, 10), + history: [["Applied", applied] as [string, number], ...steps].map(([s, d]) => ({ status: s, source: "email", changed_at: daysAgo(d) })), + }); + const api = await mockApi(page, { + ...SHELL, + "GET /api/applications": [ + sent("waiting", "Applied", 10), + sent("silent", "Applied", 60), + sent("no", "Rejected", 50, [["Rejected", 40]]), + sent("far", "Rejected", 90, [["Responded", 80], ["Interview", 70], ["Rejected", 60]]), + sent("yes", "Offer", 40, [["Interview", 30], ["Offer", 20]]), + sent("old", "Rejected", 300, [["Rejected", 280]]), + app("unsent", "Saved"), + ], + }); + await page.goto("/applications"); + await page.getByRole("group", { name: "View" }).getByRole("button", { name: "How they went" }).click(); + await expect(page.getByText("5 applied, 3 heard back (60%), 1 offer.")).toBeVisible(); + + await page.getByText("Show as a table").click(); + const row = (from: string, to: string, n: string) => page.getByRole("row", { name: `${from} ${to} ${n}`, exact: true }); + for (const [from, to, n] of [["Applied", "No reply", "1"], ["Applied", "Still waiting", "1"], ["Applied", "Rejected", "1"], + ["Applied", "Responded", "1"], ["Responded", "Interview", "1"], ["Interview", "Rejected", "1"], ["Applied", "Interview", "1"], + ["Interview", "Offer", "1"]]) { + await expect(row(from, to, n)).toBeVisible(); + } + + await page.getByRole("group", { name: "Chart range" }).getByRole("button", { name: "1 year" }).click(); + await expect(page.getByText("6 applied, 4 heard back (67%), 1 offer.")).toBeVisible(); + await page.getByRole("img", { name: /Sankey chart/ }).screenshot({ path: "test-results/sankey.png" }); + api.expectAllMocked(); +}); + test("the review queue lists paused applications and records one you sent yourself", async ({ page }) => { await signIn(page); const patches: unknown[] = []; diff --git a/frontend/src/app/applications/page.tsx b/frontend/src/app/applications/page.tsx index 7fbaee0..266f225 100644 --- a/frontend/src/app/applications/page.tsx +++ b/frontend/src/app/applications/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import ApplicationFlow from "@/components/ApplicationFlow"; import Icon from "@/components/ui/Icon"; import TopBar, { Page } from "@/components/ui/TopBar"; import SlideOver from "@/components/ui/SlideOver"; @@ -16,7 +17,7 @@ import { applicationLink } from "@/lib/jobs"; import { CLOSED, CLOSED_STAGES, OPEN_STAGES, STATUS_CHOICES, stageOf, statusMeta } from "@/lib/status"; import type { Application } from "@/lib/types"; -type View = "board" | "table"; +type View = "board" | "table" | "flow"; type Quick = "review" | "follow" | "quiet"; type Range = "6m" | "1y" | "2y" | "all"; @@ -190,22 +191,26 @@ export default function ApplicationsPage() {
- - - - - setQuery(e.target.value)} - className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" /> - - + items={[{ key: "board", label: "Board" }, { key: "table", label: "Table" }, { key: "flow", label: "How they went" }]} /> + {view !== "flow" && } + {view !== "flow" && ( + <> + + + + setQuery(e.target.value)} + className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" /> + + + + )}
-
+ {view !== "flow" &&
Needs attention {QUICK.map((q) => { const on = quick === q.key; @@ -220,7 +225,7 @@ export default function ApplicationsPage() { ); })} -
+
} {error && {error}} @@ -234,6 +239,8 @@ export default function ApplicationsPage() { Browse matches}> No applications yet. Save a job from the feed or let the agent apply, and it shows up here. + ) : view === "flow" ? ( + ) : view === "board" ? (
= { + Applied: "var(--blue)", Responded: "var(--blue)", Interview: "var(--blue)", Offer: "var(--blue)", + Rejected: "var(--orange)", "No reply": "var(--ink-3)", "Still waiting": "var(--ink-3)", "Closed by you": "var(--ink-3)", +}; + +const linkText = (l: FlowLink) => `${l.from} to ${l.to}: ${l.value} ${l.value === 1 ? "application" : "applications"}`; + +export default function ApplicationFlow({ apps }: { apps: Application[] }) { + const [span, setSpan] = useState("6m"); + const [hover, setHover] = useState<{ link: FlowLink; x: number; y: number } | null>(null); + const days = SPANS.find((s) => s.key === span)!.days; + const flow = useMemo(() => buildFlow(apps, days), [apps, days]); + const placed = useMemo(() => layoutFlow(flow), [flow]); + const share = flow.total ? Math.round((flow.heardBack / flow.total) * 100) : 0; + + return ( + +
+
+

How your applications went

+ {flow.total > 0 && ( + + {flow.total} applied, {flow.heardBack} heard back ({share}%), {flow.nodes.Offer ?? 0} {flow.nodes.Offer === 1 ? "offer" : "offers"}. + + )} +
+ { setSpan(s); setHover(null); }} items={SPANS} /> +
+ + {flow.total === 0 ? ( + No applications sent in this period. + ) : ( + <> +
setHover(null)}> + + {placed.links.map((l) => { + const on = hover?.link.from === l.from && hover.link.to === l.to; + return ( + + + {/* A thicker invisible line, so a thin flow is still easy to point at. */} + { + const box = e.currentTarget.ownerSVGElement!.parentElement!.getBoundingClientRect(); + setHover({ link: l, x: e.clientX - box.left, y: e.clientY - box.top }); + }} + onFocus={() => setHover({ link: l, x: 16, y: 16 })} onBlur={() => setHover(null)} /> + + ); + })} + {placed.nodes.map((n) => { + const outcome = n.column === 3; + return ( + + + + {n.name} + {n.value} + + + ); + })} + + {hover && ( +
+ {linkText(hover.link)} +
+ )} +
+ +
+ Show as a table + + + + {["From", "To", "Applications"].map((h) => )} + + + + {placed.links.map((l) => ( + + + + + + ))} + +
{h}
{l.from}{l.to}{l.value}
+
+ + + No reply means nothing has happened for {NO_REPLY_DAYS / 7} weeks. Built from your tracker and job emails, so it + misses applications with no confirmation email and steps that happened by phone. + + + )} +
+ ); +} diff --git a/frontend/src/lib/flow.ts b/frontend/src/lib/flow.ts new file mode 100644 index 0000000..a24943a --- /dev/null +++ b/frontend/src/lib/flow.ts @@ -0,0 +1,112 @@ +import { daysSince } from "./format"; +import { SENT } from "./status"; +import type { Application } from "./types"; + +/* How applications went, for the Sankey on the Applications page: each one's path from Applied through the steps its + history shows to where it ended up. */ + +export const NO_REPLY_DAYS = 28; + +export type FlowNode = "Applied" | "Responded" | "Interview" | "Offer" | "Rejected" | "No reply" | "Still waiting" | "Closed by you"; +export interface FlowLink { from: FlowNode; to: FlowNode; value: number } +export interface Flow { + total: number; + /** Got any reply: went past Applied, or was turned down. */ + heardBack: number; + nodes: Partial>; + links: FlowLink[]; +} + +const appliedOn = (a: Application): string | null => + a.date_applied ?? a.history?.find((s) => SENT.has(s.status))?.changed_at ?? (SENT.has(a.status) ? a.created_at : null); + +/** Where it ended up. With nothing final, what counts is how long since anything happened to it. */ +function outcome(a: Application, applied: string): FlowNode { + if (a.status === "Offer") return "Offer"; + if (a.status === "Rejected") return "Rejected"; + if (a.status === "Discarded") return "Closed by you"; + const last = a.history?.length ? a.history[a.history.length - 1].changed_at : applied; + return (daysSince(last) ?? 0) >= NO_REPLY_DAYS ? "No reply" : "Still waiting"; +} + +/** Applications applied to within the last `days` days, as flows between steps. Ones never sent aren't in it. */ +export function buildFlow(apps: Application[], days: number): Flow { + const links = new Map(); + const nodes: Flow["nodes"] = {}; + let total = 0; + let heardBack = 0; + for (const a of apps) { + const applied = appliedOn(a); + if (!applied || (daysSince(applied) ?? 0) > days) continue; + const reached = new Set([a.status, ...(a.history ?? []).map((s) => s.status)]); + const end = outcome(a, applied); + const path: FlowNode[] = ["Applied", ...(["Responded", "Interview"] as const).filter((s) => reached.has(s)), end]; + total += 1; + if (path.length > 2 || end === "Rejected" || end === "Offer") heardBack += 1; + path.forEach((node, i) => { + nodes[node] = (nodes[node] ?? 0) + 1; + if (i === 0) return; + const key = `${path[i - 1]}>${node}`; + const link = links.get(key) ?? { from: path[i - 1], to: node, value: 0 }; + link.value += 1; + links.set(key, link); + }); + } + return { total, heardBack, nodes, links: Array.from(links.values()) }; +} + +/* Where everything goes on the page: fixed columns in reading order, heights to one scale. */ + +const COLUMNS: FlowNode[][] = [["Applied"], ["Responded"], ["Interview"], + ["Offer", "Rejected", "No reply", "Still waiting", "Closed by you"]]; +const ORDER: FlowNode[] = COLUMNS.flat(); +export const FLOW_WIDTH = 900; +export const NODE_WIDTH = 12; +const LEFT = 8; +const LABELS_RIGHT = 150; // room for the outcomes' labels +const TOP = 30; +const GAP = 30; // keeps neighbouring labels apart, however small the nodes +const BODY = 280; +const MIN_NODE = 4; +const MIN_LINK = 1.5; + +export interface PlacedNode { name: FlowNode; value: number; x: number; y: number; height: number; column: number } +export interface PlacedLink extends FlowLink { width: number; path: string } + +export function layoutFlow(flow: Flow): { nodes: PlacedNode[]; links: PlacedLink[]; height: number } { + const scale = flow.total ? BODY / flow.total : 0; + const step = (FLOW_WIDTH - LEFT - LABELS_RIGHT - NODE_WIDTH) / (COLUMNS.length - 1); + const placed = new Map(); + let height = TOP; + COLUMNS.forEach((names, column) => { + let y = TOP; + for (const name of names) { + const value = flow.nodes[name]; + if (!value) continue; + const nodeHeight = Math.max(MIN_NODE, value * scale); + placed.set(name, { name, value, x: LEFT + column * step, y, height: nodeHeight, column }); + y += nodeHeight + GAP; + } + height = Math.max(height, y - GAP); + }); + // Links leave and arrive stacked, in the order of the nodes at their other end, so fewer of them cross. + const out = new Map(); + const into = new Map(); + const sorted = [...flow.links].sort((a, b) => ORDER.indexOf(a.from) - ORDER.indexOf(b.from) || ORDER.indexOf(a.to) - ORDER.indexOf(b.to)); + const links = sorted.map((link) => { + const from = placed.get(link.from)!; + const to = placed.get(link.to)!; + const share = (node: PlacedNode) => Math.max(MIN_LINK, link.value * (node.height / node.value)); + const fromShare = share(from); + const toShare = share(to); + const y0 = from.y + (out.get(link.from) ?? 0) + fromShare / 2; + const y1 = to.y + (into.get(link.to) ?? 0) + toShare / 2; + out.set(link.from, (out.get(link.from) ?? 0) + fromShare); + into.set(link.to, (into.get(link.to) ?? 0) + toShare); + const x0 = from.x + NODE_WIDTH; + const x1 = to.x; + const mid = (x0 + x1) / 2; + return { ...link, width: Math.max(fromShare, toShare), path: `M${x0},${y0} C${mid},${y0} ${mid},${y1} ${x1},${y1}` }; + }); + return { nodes: Array.from(placed.values()), links, height: height + 8 }; +}