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() {
-
-
-
- Search applications
- setQuery(e.target.value)}
- className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" />
-
- setShowClosed((s) => !s)}
- className={cx(buttonClass("secondary", "sm", "h-9 font-normal"), showClosed && "bg-muted font-semibold")}>
- {showClosed ? "Hide closed" : `Show closed (${closedCount})`}
-
+ items={[{ key: "board", label: "Board" }, { key: "table", label: "Table" }, { key: "flow", label: "How they went" }]} />
+ {view !== "flow" && }
+ {view !== "flow" && (
+ <>
+
+
+ Search applications
+ setQuery(e.target.value)}
+ className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" />
+
+ setShowClosed((s) => !s)}
+ className={cx(buttonClass("secondary", "sm", "h-9 font-normal"), showClosed && "bg-muted font-semibold")}>
+ {showClosed ? "Hide closed" : `Show closed (${closedCount})`}
+
+ >
+ )}
-
+ {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) => {h} )}
+
+
+
+ {placed.links.map((l) => (
+
+ {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 };
+}