Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
37 changes: 22 additions & 15 deletions frontend/src/app/applications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -190,22 +191,26 @@ export default function ApplicationsPage() {
</div>
<div className="flex flex-wrap gap-2 items-center">
<Segmented label="View" value={view} onChange={setView}
items={[{ key: "board", label: "Board" }, { key: "table", label: "Table" }]} />
<Segmented label="Applied within" value={range} onChange={setRange} items={RANGES} />
<span className="flex items-center gap-2 h-9 px-3 border border-border rounded-control bg-surface text-ink-3 focus-within:border-teal">
<Icon name="search" size={15} />
<label htmlFor="app-search" className="sr-only">Search applications</label>
<input id="app-search" type="search" placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)}
className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" />
</span>
<button type="button" aria-pressed={showClosed} onClick={() => setShowClosed((s) => !s)}
className={cx(buttonClass("secondary", "sm", "h-9 font-normal"), showClosed && "bg-muted font-semibold")}>
{showClosed ? "Hide closed" : `Show closed (${closedCount})`}
</button>
items={[{ key: "board", label: "Board" }, { key: "table", label: "Table" }, { key: "flow", label: "How they went" }]} />
{view !== "flow" && <Segmented label="Applied within" value={range} onChange={setRange} items={RANGES} />}
{view !== "flow" && (
<>
<span className="flex items-center gap-2 h-9 px-3 border border-border rounded-control bg-surface text-ink-3 focus-within:border-teal">
<Icon name="search" size={15} />
<label htmlFor="app-search" className="sr-only">Search applications</label>
<input id="app-search" type="search" placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)}
className="w-[140px] border-0 bg-transparent text-[13px] outline-none text-ink placeholder:text-ink-3" />
</span>
<button type="button" aria-pressed={showClosed} onClick={() => setShowClosed((s) => !s)}
className={cx(buttonClass("secondary", "sm", "h-9 font-normal"), showClosed && "bg-muted font-semibold")}>
{showClosed ? "Hide closed" : `Show closed (${closedCount})`}
</button>
</>
)}
</div>
</div>

<div role="group" aria-label="Quick filters" className="flex flex-wrap items-center gap-2">
{view !== "flow" && <div role="group" aria-label="Quick filters" className="flex flex-wrap items-center gap-2">
<span className="text-[13px] text-ink-3 mr-[2px]">Needs attention</span>
{QUICK.map((q) => {
const on = quick === q.key;
Expand All @@ -220,7 +225,7 @@ export default function ApplicationsPage() {
</button>
);
})}
</div>
</div>}

{error && <Callout>{error}</Callout>}

Expand All @@ -234,6 +239,8 @@ export default function ApplicationsPage() {
<EmptyState action={<Link href="/jobs" className={buttonClass("primary", "sm")}>Browse matches</Link>}>
No applications yet. Save a job from the feed or let the agent apply, and it shows up here.
</EmptyState>
) : view === "flow" ? (
<ApplicationFlow apps={everything} />
) : view === "board" ? (
<div className="relative overflow-x-auto pb-[6px] -mx-1 px-1">
<div className="grid gap-[10px] items-start"
Expand Down
119 changes: 119 additions & 0 deletions frontend/src/components/ApplicationFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import { useMemo, useState } from "react";
import { Segmented } from "@/components/ui/Tabs";
import { Card, cx, EmptyState } from "@/components/ui/kit";
import { buildFlow, FLOW_WIDTH, layoutFlow, NO_REPLY_DAYS, NODE_WIDTH, type FlowLink, type FlowNode } from "@/lib/flow";
import type { Application } from "@/lib/types";

type Span = "3m" | "6m" | "1y" | "2y";

const SPANS: { key: Span; label: string; days: number }[] = [
{ key: "3m", label: "3 months", days: 91 }, { key: "6m", label: "6 months", days: 182 },
{ key: "1y", label: "1 year", days: 365 }, { key: "2y", label: "2 years", days: 730 },
];

/* Three meanings, not one colour per node: moving forward, turned down, silence. Every node also says its name. */
const COLOUR: Record<FlowNode, string> = {
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<Span>("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 (
<Card className="p-5 flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1 min-w-0">
<h2 className="m-0 text-[16px] font-semibold">How your applications went</h2>
{flow.total > 0 && (
<span className="text-[13px] text-ink-2">
{flow.total} applied, {flow.heardBack} heard back ({share}%), {flow.nodes.Offer ?? 0} {flow.nodes.Offer === 1 ? "offer" : "offers"}.
</span>
)}
</div>
<Segmented label="Chart range" value={span} onChange={(s) => { setSpan(s); setHover(null); }} items={SPANS} />
</div>

{flow.total === 0 ? (
<EmptyState>No applications sent in this period.</EmptyState>
) : (
<>
<div className="relative overflow-x-auto" onMouseLeave={() => setHover(null)}>
<svg role="img" aria-label={`Sankey chart: ${flow.total} applications from Applied to where they ended up. The same figures are in the table below.`}
viewBox={`0 0 ${FLOW_WIDTH} ${placed.height}`} className="block w-full min-w-[640px]">
{placed.links.map((l) => {
const on = hover?.link.from === l.from && hover.link.to === l.to;
return (
<g key={`${l.from}-${l.to}`}>
<path d={l.path} fill="none" stroke={COLOUR[l.to]} strokeWidth={l.width} opacity={on ? 0.6 : 0.3} />
{/* A thicker invisible line, so a thin flow is still easy to point at. */}
<path d={l.path} fill="none" stroke="transparent" strokeWidth={Math.max(l.width, 12)} tabIndex={0} aria-label={linkText(l)}
className="outline-none"
onMouseMove={(e) => {
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)} />
</g>
);
})}
{placed.nodes.map((n) => {
const outcome = n.column === 3;
return (
<g key={n.name}>
<rect x={n.x} y={n.y} width={NODE_WIDTH} height={n.height} rx={3} fill={COLOUR[n.name]} />
<text x={outcome ? n.x + NODE_WIDTH + 8 : n.x} y={outcome ? n.y + Math.min(n.height, 20) / 2 + 4 : n.y - 8}
className="fill-ink text-[13px]">
<tspan className="font-semibold">{n.name}</tspan>
<tspan className="fill-ink-2" dx={6}>{n.value}</tspan>
</text>
</g>
);
})}
</svg>
{hover && (
<div role="status" style={{ left: hover.x + 12, top: hover.y + 12 }}
className={cx("absolute pointer-events-none z-10 px-[10px] py-[6px] rounded-[8px] bg-ink text-on-ink text-[12.5px] whitespace-nowrap shadow-lift")}>
{linkText(hover.link)}
</div>
)}
</div>

<details className="text-[13px]">
<summary className="cursor-pointer text-ink-2">Show as a table</summary>
<table className="mt-2 border-collapse text-[13px]">
<thead>
<tr className="text-left">
{["From", "To", "Applications"].map((h) => <th key={h} scope="col" className="micro font-normal pr-6 py-1">{h}</th>)}
</tr>
</thead>
<tbody>
{placed.links.map((l) => (
<tr key={`${l.from}-${l.to}`} className="border-t border-divider">
<td className="pr-6 py-1">{l.from}</td>
<td className="pr-6 py-1">{l.to}</td>
<td className="pr-6 py-1 font-mono">{l.value}</td>
</tr>
))}
</tbody>
</table>
</details>

<span className="text-[12px] leading-[1.5] text-ink-3">
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.
</span>
</>
)}
</Card>
);
}
112 changes: 112 additions & 0 deletions frontend/src/lib/flow.ts
Original file line number Diff line number Diff line change
@@ -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<Record<FlowNode, number>>;
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<string, FlowLink>();
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<FlowNode, PlacedNode>();
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<FlowNode, number>();
const into = new Map<FlowNode, number>();
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 };
}
Loading