/**
* Hand-rolled SVG chart generation for the SRS Worker.
*
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
* every major email client blocks SVG, so the digest cannot embed the vector.
*
* The SVG is string-built: no chart library, no dependencies, and the one
* import is the hand-rolled PNG encoder next door. The only dynamic values
* entering the markup are numbers, percentages, and dates the Worker itself
* computes, plus the fixed phase/stage labels — so nothing here needs XML
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
* green ramp. Every function renders on a zero-row DB.
*/
import { STAGES } from "./srs.ts";
import { Canvas, textWidth } from "./png.ts";
// D1Database comes from the generated worker-configuration.d.ts runtime types.
const FONT = "Verdana,DejaVu Sans,sans-serif";
// shadcn dark-zinc palette, matching the README's shieldcn badges.
const BG = "#09090b"; // zinc-950 card
const FG = "#fafafa"; // zinc-50 text
const MUTED = "#a1a1aa"; // zinc-400 secondary text
const LINE = "#27272a"; // zinc-800 structure / empty
const GREEN = "#16a34a"; // green-600 (core / pass)
const BLUE = "#2563eb"; // blue-600 (optional)
const RED = "#f87171"; // red-400 (fail, on dark)
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
// contribution hues; level 0 is the empty zinc cell).
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
// strings go straight into markup and nothing else here needs escaping.
const PHASES = [
{ milestone: 1, name: "I — Linear" },
{ milestone: 2, name: "II — Nodal & Grid" },
{ milestone: 3, name: "III — Hierarchical" },
{ milestone: 4, name: "IV — Relational" },
{ milestone: 5, name: "V — Decision Space" },
];
// ── svg helpers ──────────────────────────────────────────────────
/** Opening tag plus the rounded dark card every chart starts with. */
function svgOpen(width: number, height: number): string {
return (
`");
return parts.join("");
}
// ── ladder: bar per stage ────────────────────────────────────────
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
// ladder can never leave a stale bar here: the slot width is derived from the
// rung count, only the bar width inside a slot is fixed.
export async function ladderChart(db: D1Database): Promise {
const { results } = await db
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
.all<{ stage: string; count: number }>();
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
const width = 640;
const height = 220;
const plotTop = 26;
const plotBottom = height - 32;
const plotH = plotBottom - plotTop;
const slotW = (width - 80) / STAGES.length;
const barW = 64;
const scale = Math.max(1, ...counts);
const parts = [svgOpen(width, height)];
counts.forEach((count, i) => {
const cx = 40 + slotW * i + slotW / 2;
const barH = (count / scale) * plotH;
const y = plotBottom - barH;
if (barH > 0) {
parts.push(
``,
);
}
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
});
parts.push(``);
parts.push("");
return parts.join("");
}
// ── heatmap: attempts per campaign day ───────────────────────────
const DAY_MS = 86_400_000;
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
// scale, so the vector and raster pictures cannot drift apart.
const HEAT_W = 560;
const HEAT_H = 160;
const HEAT_GRID_X = 34;
const HEAT_GRID_Y = 24;
const HEAT_CELL = 16;
const HEAT_STEP = 19; // cell + 3px gap
// Grid row → left-hand label; both renderers print only these three.
const WEEKDAYS: [number, string][] = [
[0, "Mon"],
[2, "Wed"],
[4, "Fri"],
];
/** One grid square: column, Mon-based row, and index into GREENS. */
interface HeatmapCell {
col: number;
row: number;
level: number;
}
/**
* The heatmap's whole data model — one cell per campaign day, plus the week
* count that positions the legend — shared by both renderers.
*
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
* column i/7; the row comes from the real weekday, so an off-Monday start still
* lands correctly.
*/
async function heatmapCells(
db: D1Database,
start: string,
days: number,
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
const [sy, sm, sd] = start.split("-").map(Number);
const base = Date.UTC(sy!, sm! - 1, sd!);
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
const { results } = await db
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
.bind(start, end)
.all<{ date: string; attempts: number }>();
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
const cells: HeatmapCell[] = [];
for (let i = 0; i < days; i++) {
const day = new Date(base + i * DAY_MS);
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
cells.push({
col: Math.floor(i / 7),
row: (day.getUTCDay() + 6) % 7, // Mon = 0
level: Math.min(attempts, 4),
});
}
return { cells, weeks: Math.ceil(days / 7) };
}
export async function heatmapChart(db: D1Database, start: string, days: number): Promise {
const { cells, weeks } = await heatmapCells(db, start, days);
const parts = [svgOpen(HEAT_W, HEAT_H)];
// Week numbers across the top, Mon/Wed/Fri down the left.
for (let w = 0; w < weeks; w++) {
parts.push(
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
size: 10,
fill: MUTED,
anchor: "middle",
}),
);
}
for (const [row, label] of WEEKDAYS) {
parts.push(
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
size: 10,
fill: MUTED,
anchor: "end",
}),
);
}
for (const { col, row, level } of cells) {
parts.push(
``,
);
}
// Less → More ramp fills the space right of the grid.
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
GREENS.forEach((color, i) => {
parts.push(
``,
);
});
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
parts.push("");
return parts.join("");
}
// The PNG carries no alpha channel, so whatever the rounded card does not cover
// has to be painted with the colour sitting behind the image: the digest
// email's GitHub-dark card.
const EMAIL_CARD = "#0d1117";
// Device-pixel multiplier. The email hands the image the full 552px card
// width, so 3x lands a little over 2x density on a retina screen; the labels
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
// either way, so the extra resolution is close to free.
const HEAT_SCALE = 3;
/**
* The same picture as heatmapChart, rastered for email.
*
* Two deliberate departures from the SVG. The canvas is trimmed to the width
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
* narrow table cell and scales up, whereas the email gives the image the full
* 552px card, and the SVG's slack right margin would otherwise shrink the
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
*/
export async function heatmapPng(db: D1Database, start: string, days: number): Promise {
const { cells, weeks } = await heatmapCells(db, start, days);
const s = HEAT_SCALE;
const gridX = HEAT_GRID_X * s;
const gridY = HEAT_GRID_Y * s;
const cell = HEAT_CELL * s;
const step = HEAT_STEP * s;
const capH = 7 * s;
const legendX = gridX + weeks * step + 40 * s;
const legendY = gridY + 3 * step;
const moreX = legendX + GREENS.length * step + 3 * s;
const width = moreX + textWidth("More", s) + gridX;
const height = HEAT_H * s;
const canvas = new Canvas(width, height, EMAIL_CARD);
canvas.rect(0, 0, width, height, BG, 6 * s);
for (let w = 0; w < weeks; w++) {
const label = `W${w + 1}`;
const middle = gridX + w * step + cell / 2;
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
}
for (const [row, label] of WEEKDAYS) {
canvas.text(
gridX - 6 * s - textWidth(label, s),
gridY + row * step + cell - 4 * s - capH,
label,
MUTED,
s,
);
}
for (const { col, row, level } of cells) {
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
}
const legendBase = legendY + cell - 4 * s - capH;
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
GREENS.forEach((color, i) => {
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
});
canvas.text(moreX, legendBase, "More", MUTED, s);
return await canvas.encode();
}
// ── gate badge: shieldcn-style dark pill ─────────────────────────
export async function gateBadge(db: D1Database): Promise {
const row = await db
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
.first<{ pass_rate: number }>();
const label = "gate";
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
const labelW = Math.round(label.length * 7.5);
const valueW = Math.round(value.length * 7.5);
const total = 12 + labelW + 8 + valueW + 12;
return (
``
);
}