┌─ TS ───────────────────────────────────────────────────────────────────────┐
│ // Renders a simple line+area chart into each .activity-chart on the page. │
│ // Reads its data from the data-points attribute (JSON-encoded array of │
│ // { week_start, run_count }). Hover anywhere on the chart to see exact │
│ // values for the nearest week. │
│ │
│ interface Point { │
│ week_start: string; │
│ run_count: number; │
│ } │
│ │
│ const SVG_NS = "http://www.w3.org/2000/svg"; │
│ │
│ function parseData(el: HTMLElement): Point[] { │
│ const raw = el.getAttribute("data-points"); │
│ if (!raw) return []; │
│ try { │
│ return JSON.parse(raw); │
│ } catch { │
│ return []; │
│ } │
│ } │
│ │
│ function formatDateLabel(iso: string): string { │
│ const d = new Date(iso); │
│ return d.toLocaleDateString("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ year: "numeric", │
│ }); │
│ } │
│ │
│ function renderChart(container: HTMLElement, points: Point[]) { │
│ const canvas = container.querySelector( │
│ ".activity-chart__canvas", │
│ ) as HTMLElement | null; │
│ if (!canvas) return; │
│ │
│ // measure container, fall back to 600 with a 320 floor │
│ const width = Math.max( │
│ canvas.clientWidth || container.clientWidth || 600, │
│ 320, │
│ ); │
│ const height = 220; │
│ const padding = { top: 16, right: 16, bottom: 32, left: 56 }; │
│ const innerW = width - padding.left - padding.right; │
│ const innerH = height - padding.top - padding.bottom; │
│ │
│ if (points.length === 0) { │
│ canvas.innerHTML = '<p class="activity-chart__empty">No activity data.</p>'; │
│ return; │
│ } │
│ │
│ const maxCount = Math.max(...points.map((p) => p.run_count), 1); │
│ │
│ // Map each point to (x, y) inside the chart area │
│ const xStep = points.length > 1 ? innerW / (points.length - 1) : innerW; │
│ const xy = points.map((p, i) => ({ │
│ x: padding.left + i * xStep, │
│ y: padding.top + innerH - (p.run_count / maxCount) * innerH, │
│ point: p, │
│ })); │
│ │
│ // Build path strings for the line and the filled area │
│ const linePath = xy │
│ .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`) │
│ .join(" "); │
│ const areaPath = │
│ `${linePath} L ${xy[xy.length - 1].x.toFixed(2)} ${(padding.top + innerH).toFi │
│ xed(2)} ` + │
│ `L ${xy[0].x.toFixed(2)} ${(padding.top + innerH).toFixed(2)} Z`; │
│ │
│ // Y-axis tick values (5 ticks: 0, 25%, 50%, 75%, 100%) │
│ const yTicks = [0, 0.25, 0.5, 0.75, 1].map((frac) => ({ │
│ y: padding.top + innerH - frac * innerH, │
│ value: Math.round(maxCount * frac), │
│ })); │
│ │
│ // first/middle/last only - more would clutter │
│ const xTickIdxs = │
│ points.length <= 2 │
│ ? points.map((_, i) => i) │
│ : [0, Math.floor(points.length / 2), points.length - 1]; │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_NS, "svg"); │
│ svg.setAttribute("viewBox", `0 0 ${width} ${height}`); │
│ svg.setAttribute("width", String(width)); │
│ svg.setAttribute("height", String(height)); │
│ svg.setAttribute("class", "activity-chart__svg"); │
│ │
│ // Y-axis gridlines + labels │
│ for (const tick of yTicks) { │
│ const line = document.createElementNS(SVG_NS, "line"); │
│ line.setAttribute("x1", String(padding.left)); │
│ line.setAttribute("x2", String(padding.left + innerW)); │
│ line.setAttribute("y1", String(tick.y)); │
│ line.setAttribute("y2", String(tick.y)); │
│ line.setAttribute("class", "activity-chart__gridline"); │
│ svg.appendChild(line); │
│ │
│ const label = document.createElementNS(SVG_NS, "text"); │
│ label.setAttribute("x", String(padding.left - 8)); │
│ label.setAttribute("y", String(tick.y + 4)); │
│ label.setAttribute("text-anchor", "end"); │
│ label.setAttribute("class", "activity-chart__axis-label"); │
│ label.textContent = tick.value.toLocaleString("en-US"); │
│ svg.appendChild(label); │
│ } │
│ │
│ // X-axis labels │
│ for (const i of xTickIdxs) { │
│ const label = document.createElementNS(SVG_NS, "text"); │
│ label.setAttribute("x", String(xy[i].x)); │
│ label.setAttribute("y", String(padding.top + innerH + 20)); │
│ label.setAttribute("text-anchor", "middle"); │
│ label.setAttribute("class", "activity-chart__axis-label"); │
│ label.textContent = new Date(points[i].week_start).toLocaleDateString( │
│ "en-US", │
│ { │
│ month: "short", │
│ year: "2-digit", │
│ }, │
│ ); │
│ svg.appendChild(label); │
│ } │
│ │
│ // Filled area under line │
│ const area = document.createElementNS(SVG_NS, "path"); │
│ area.setAttribute("d", areaPath); │
│ area.setAttribute("class", "activity-chart__area"); │
│ svg.appendChild(area); │
│ │
│ // Line on top of area │
│ const line = document.createElementNS(SVG_NS, "path"); │
│ line.setAttribute("d", linePath); │
│ line.setAttribute("class", "activity-chart__line"); │
│ svg.appendChild(line); │
│ │
│ // Hover crosshair + dot (initially hidden) │
│ const crosshair = document.createElementNS(SVG_NS, "line"); │
│ crosshair.setAttribute("y1", String(padding.top)); │
│ crosshair.setAttribute("y2", String(padding.top + innerH)); │
│ crosshair.setAttribute("class", "activity-chart__crosshair"); │
│ crosshair.setAttribute("opacity", "0"); │
│ svg.appendChild(crosshair); │
│ │
│ const hoverDot = document.createElementNS(SVG_NS, "circle"); │
│ hoverDot.setAttribute("r", "4"); │
│ hoverDot.setAttribute("class", "activity-chart__dot"); │
│ hoverDot.setAttribute("opacity", "0"); │
│ svg.appendChild(hoverDot); │
│ │
│ // Invisible overlay rect that captures pointer events across the chart area │
│ const overlay = document.createElementNS(SVG_NS, "rect"); │
│ overlay.setAttribute("x", String(padding.left)); │
│ overlay.setAttribute("y", String(padding.top)); │
│ overlay.setAttribute("width", String(innerW)); │
│ overlay.setAttribute("height", String(innerH)); │
│ overlay.setAttribute("fill", "transparent"); │
│ overlay.setAttribute("class", "activity-chart__overlay"); │
│ svg.appendChild(overlay); │
│ │
│ // Tooltip (DOM, positioned absolutely) │
│ const tooltip = document.createElement("div"); │
│ tooltip.className = "activity-chart__tooltip"; │
│ tooltip.style.opacity = "0"; │
│ canvas.style.position = "relative"; │
│ │
│ function nearestIndex(svgX: number): number { │
│ // Convert pointer x in SVG units to nearest point index │
│ const idx = Math.round((svgX - padding.left) / xStep); │
│ return Math.max(0, Math.min(points.length - 1, idx)); │
│ } │
│ │
│ function showHover(idx: number, clientX: number) { │
│ const p = xy[idx]; │
│ crosshair.setAttribute("x1", String(p.x)); │
│ crosshair.setAttribute("x2", String(p.x)); │
│ crosshair.setAttribute("opacity", "1"); │
│ hoverDot.setAttribute("cx", String(p.x)); │
│ hoverDot.setAttribute("cy", String(p.y)); │
│ hoverDot.setAttribute("opacity", "1"); │
│ │
│ tooltip.innerHTML = │
│ `<div class="activity-chart__tooltip-date">${formatDateLabel(p.point.week_st │
│ art)}</div>` + │
│ `<div class="activity-chart__tooltip-value">${p.point.run_count.toLocaleStri │
│ ng("en-US")} runs</div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ // Position tooltip relative to canvas: convert SVG x back to pixels │
│ const canvasRect = canvas.getBoundingClientRect(); │
│ const ratio = canvasRect.width / width; │
│ let left = p.x * ratio + 8; │
│ const tipW = tooltip.offsetWidth; │
│ if (left + tipW > canvasRect.width) { │
│ left = p.x * ratio - tipW - 8; │
│ } │
│ tooltip.style.left = `${Math.max(0, left)}px`; │
│ tooltip.style.top = `${Math.max(0, p.y * ratio - 30)}px`; │
│ } │
│ │
│ function hideHover() { │
│ crosshair.setAttribute("opacity", "0"); │
│ hoverDot.setAttribute("opacity", "0"); │
│ tooltip.style.opacity = "0"; │
│ } │
│ │
│ overlay.addEventListener("pointermove", (ev: PointerEvent) => { │
│ const rect = svg.getBoundingClientRect(); │
│ const ratioX = width / rect.width; │
│ const svgX = (ev.clientX - rect.left) * ratioX; │
│ showHover(nearestIndex(svgX), ev.clientX); │
│ }); │
│ overlay.addEventListener("pointerleave", hideHover); │
│ │
│ canvas.replaceChildren(svg, tooltip); │
│ } │
│ │
│ export function initActivityCharts() { │
│ const charts = document.querySelectorAll<HTMLElement>(".activity-chart"); │
│ charts.forEach((c) => { │
│ const points = parseData(c); │
│ renderChart(c, points); │
│ }); │
│ │
│ // Re-render on resize so the chart adapts to width changes (debounced) │
│ let resizeTimer: number | undefined; │
│ window.addEventListener( │
│ "resize", │
│ () => { │
│ if (resizeTimer) window.clearTimeout(resizeTimer); │
│ resizeTimer = window.setTimeout(() => { │
│ document │
│ .querySelectorAll<HTMLElement>(".activity-chart") │
│ .forEach((c) => { │
│ renderChart(c, parseData(c)); │
│ }); │
│ }, 150); │
│ }, │
│ { passive: true }, │
│ ); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ TS ─────────────────────────────────┐
│ // Renders a simple line+area chart into eac │
│ h .activity-chart on the page. │
│ // Reads its data from the data-points attri │
│ bute (JSON-encoded array of │
│ // { week_start, run_count }). Hover anywher │
│ e on the chart to see exact │
│ // values for the nearest week. │
│ │
│ interface Point { │
│ week_start: string; │
│ run_count: number; │
│ } │
│ │
│ const SVG_NS = "http://www.w3.org/2000/svg"; │
│ │
│ function parseData(el: HTMLElement): Point[] │
│ { │
│ const raw = el.getAttribute("data-points") │
│ ; │
│ if (!raw) return []; │
│ try { │
│ return JSON.parse(raw); │
│ } catch { │
│ return []; │
│ } │
│ } │
│ │
│ function formatDateLabel(iso: string): strin │
│ g { │
│ const d = new Date(iso); │
│ return d.toLocaleDateString("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ year: "numeric", │
│ }); │
│ } │
│ │
│ function renderChart(container: HTMLElement, │
│ points: Point[]) { │
│ const canvas = container.querySelector( │
│ ".activity-chart__canvas", │
│ ) as HTMLElement | null; │
│ if (!canvas) return; │
│ │
│ // measure container, fall back to 600 wit │
│ h a 320 floor │
│ const width = Math.max( │
│ canvas.clientWidth || container.clientWi │
│ dth || 600, │
│ 320, │
│ ); │
│ const height = 220; │
│ const padding = { top: 16, right: 16, bott │
│ om: 32, left: 56 }; │
│ const innerW = width - padding.left - padd │
│ ing.right; │
│ const innerH = height - padding.top - padd │
│ ing.bottom; │
│ │
│ if (points.length === 0) { │
│ canvas.innerHTML = '<p class="activity-c │
│ hart__empty">No activity data.</p>'; │
│ return; │
│ } │
│ │
│ const maxCount = Math.max(...points.map((p │
│ ) => p.run_count), 1); │
│ │
│ // Map each point to (x, y) inside the cha │
│ rt area │
│ const xStep = points.length > 1 ? innerW / │
│ (points.length - 1) : innerW; │
│ const xy = points.map((p, i) => ({ │
│ x: padding.left + i * xStep, │
│ y: padding.top + innerH - (p.run_count / │
│ maxCount) * innerH, │
│ point: p, │
│ })); │
│ │
│ // Build path strings for the line and the │
│ filled area │
│ const linePath = xy │
│ .map((p, i) => `${i === 0 ? "M" : "L"} $ │
│ {p.x.toFixed(2)} ${p.y.toFixed(2)}`) │
│ .join(" "); │
│ const areaPath = │
│ `${linePath} L ${xy[xy.length - 1].x.toF │
│ ixed(2)} ${(padding.top + innerH).toFixed(2) │
│ } ` + │
│ `L ${xy[0].x.toFixed(2)} ${(padding.top │
│ + innerH).toFixed(2)} Z`; │
│ │
│ // Y-axis tick values (5 ticks: 0, 25%, 50 │
│ %, 75%, 100%) │
│ const yTicks = [0, 0.25, 0.5, 0.75, 1].map │
│ ((frac) => ({ │
│ y: padding.top + innerH - frac * innerH, │
│ value: Math.round(maxCount * frac), │
│ })); │
│ │
│ // first/middle/last only - more would clu │
│ tter │
│ const xTickIdxs = │
│ points.length <= 2 │
│ ? points.map((_, i) => i) │
│ : [0, Math.floor(points.length / 2), p │
│ oints.length - 1]; │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_N │
│ S, "svg"); │
│ svg.setAttribute("viewBox", `0 0 ${width} │
│ ${height}`); │
│ svg.setAttribute("width", String(width)); │
│ svg.setAttribute("height", String(height)) │
│ ; │
│ svg.setAttribute("class", "activity-chart_ │
│ _svg"); │
│ │
│ // Y-axis gridlines + labels │
│ for (const tick of yTicks) { │
│ const line = document.createElementNS(SV │
│ G_NS, "line"); │
│ line.setAttribute("x1", String(padding.l │
│ eft)); │
│ line.setAttribute("x2", String(padding.l │
│ eft + innerW)); │
│ line.setAttribute("y1", String(tick.y)); │
│ line.setAttribute("y2", String(tick.y)); │
│ line.setAttribute("class", "activity-cha │
│ rt__gridline"); │
│ svg.appendChild(line); │
│ │
│ const label = document.createElementNS(S │
│ VG_NS, "text"); │
│ label.setAttribute("x", String(padding.l │
│ eft - 8)); │
│ label.setAttribute("y", String(tick.y + │
│ 4)); │
│ label.setAttribute("text-anchor", "end") │
│ ; │
│ label.setAttribute("class", "activity-ch │
│ art__axis-label"); │
│ label.textContent = tick.value.toLocaleS │
│ tring("en-US"); │
│ svg.appendChild(label); │
│ } │
│ │
│ // X-axis labels │
│ for (const i of xTickIdxs) { │
│ const label = document.createElementNS(S │
│ VG_NS, "text"); │
│ label.setAttribute("x", String(xy[i].x)) │
│ ; │
│ label.setAttribute("y", String(padding.t │
│ op + innerH + 20)); │
│ label.setAttribute("text-anchor", "middl │
│ e"); │
│ label.setAttribute("class", "activity-ch │
│ art__axis-label"); │
│ label.textContent = new Date(points[i].w │
│ eek_start).toLocaleDateString( │
│ "en-US", │
│ { │
│ month: "short", │
│ year: "2-digit", │
│ }, │
│ ); │
│ svg.appendChild(label); │
│ } │
│ │
│ // Filled area under line │
│ const area = document.createElementNS(SVG_ │
│ NS, "path"); │
│ area.setAttribute("d", areaPath); │
│ area.setAttribute("class", "activity-chart │
│ __area"); │
│ svg.appendChild(area); │
│ │
│ // Line on top of area │
│ const line = document.createElementNS(SVG_ │
│ NS, "path"); │
│ line.setAttribute("d", linePath); │
│ line.setAttribute("class", "activity-chart │
│ __line"); │
│ svg.appendChild(line); │
│ │
│ // Hover crosshair + dot (initially hidden │
│ ) │
│ const crosshair = document.createElementNS │
│ (SVG_NS, "line"); │
│ crosshair.setAttribute("y1", String(paddin │
│ g.top)); │
│ crosshair.setAttribute("y2", String(paddin │
│ g.top + innerH)); │
│ crosshair.setAttribute("class", "activity- │
│ chart__crosshair"); │
│ crosshair.setAttribute("opacity", "0"); │
│ svg.appendChild(crosshair); │
│ │
│ const hoverDot = document.createElementNS( │
│ SVG_NS, "circle"); │
│ hoverDot.setAttribute("r", "4"); │
│ hoverDot.setAttribute("class", "activity-c │
│ hart__dot"); │
│ hoverDot.setAttribute("opacity", "0"); │
│ svg.appendChild(hoverDot); │
│ │
│ // Invisible overlay rect that captures po │
│ inter events across the chart area │
│ const overlay = document.createElementNS(S │
│ VG_NS, "rect"); │
│ overlay.setAttribute("x", String(padding.l │
│ eft)); │
│ overlay.setAttribute("y", String(padding.t │
│ op)); │
│ overlay.setAttribute("width", String(inner │
│ W)); │
│ overlay.setAttribute("height", String(inne │
│ rH)); │
│ overlay.setAttribute("fill", "transparent" │
│ ); │
│ overlay.setAttribute("class", "activity-ch │
│ art__overlay"); │
│ svg.appendChild(overlay); │
│ │
│ // Tooltip (DOM, positioned absolutely) │
│ const tooltip = document.createElement("di │
│ v"); │
│ tooltip.className = "activity-chart__toolt │
│ ip"; │
│ tooltip.style.opacity = "0"; │
│ canvas.style.position = "relative"; │
│ │
│ function nearestIndex(svgX: number): numbe │
│ r { │
│ // Convert pointer x in SVG units to nea │
│ rest point index │
│ const idx = Math.round((svgX - padding.l │
│ eft) / xStep); │
│ return Math.max(0, Math.min(points.lengt │
│ h - 1, idx)); │
│ } │
│ │
│ function showHover(idx: number, clientX: n │
│ umber) { │
│ const p = xy[idx]; │
│ crosshair.setAttribute("x1", String(p.x) │
│ ); │
│ crosshair.setAttribute("x2", String(p.x) │
│ ); │
│ crosshair.setAttribute("opacity", "1"); │
│ hoverDot.setAttribute("cx", String(p.x)) │
│ ; │
│ hoverDot.setAttribute("cy", String(p.y)) │
│ ; │
│ hoverDot.setAttribute("opacity", "1"); │
│ │
│ tooltip.innerHTML = │
│ `<div class="activity-chart__tooltip-d │
│ ate">${formatDateLabel(p.point.week_start)}< │
│ /div>` + │
│ `<div class="activity-chart__tooltip-v │
│ alue">${p.point.run_count.toLocaleString("en │
│ -US")} runs</div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ // Position tooltip relative to canvas: │
│ convert SVG x back to pixels │
│ const canvasRect = canvas.getBoundingCli │
│ entRect(); │
│ const ratio = canvasRect.width / width; │
│ let left = p.x * ratio + 8; │
│ const tipW = tooltip.offsetWidth; │
│ if (left + tipW > canvasRect.width) { │
│ left = p.x * ratio - tipW - 8; │
│ } │
│ tooltip.style.left = `${Math.max(0, left │
│ )}px`; │
│ tooltip.style.top = `${Math.max(0, p.y * │
│ ratio - 30)}px`; │
│ } │
│ │
│ function hideHover() { │
│ crosshair.setAttribute("opacity", "0"); │
│ hoverDot.setAttribute("opacity", "0"); │
│ tooltip.style.opacity = "0"; │
│ } │
│ │
│ overlay.addEventListener("pointermove", (e │
│ v: PointerEvent) => { │
│ const rect = svg.getBoundingClientRect() │
│ ; │
│ const ratioX = width / rect.width; │
│ const svgX = (ev.clientX - rect.left) * │
│ ratioX; │
│ showHover(nearestIndex(svgX), ev.clientX │
│ ); │
│ }); │
│ overlay.addEventListener("pointerleave", h │
│ ideHover); │
│ │
│ canvas.replaceChildren(svg, tooltip); │
│ } │
│ │
│ export function initActivityCharts() { │
│ const charts = document.querySelectorAll<H │
│ TMLElement>(".activity-chart"); │
│ charts.forEach((c) => { │
│ const points = parseData(c); │
│ renderChart(c, points); │
│ }); │
│ │
│ // Re-render on resize so the chart adapts │
│ to width changes (debounced) │
│ let resizeTimer: number | undefined; │
│ window.addEventListener( │
│ "resize", │
│ () => { │
│ if (resizeTimer) window.clearTimeout(r │
│ esizeTimer); │
│ resizeTimer = window.setTimeout(() => │
│ { │
│ document │
│ .querySelectorAll<HTMLElement>(".a │
│ ctivity-chart") │
│ .forEach((c) => { │
│ renderChart(c, parseData(c)); │
│ }); │
│ }, 150); │
│ }, │
│ { passive: true }, │
│ ); │
│ } │
└──────────────────────────────────────────────┘