Public: anyone can read this; the crew can change it.
The sparkline helper draws a day with no data as a gap, not a zero #3244
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3238-the-sparkline-helper-draws-a-day-with-no → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +104
−8
src/web/studio/kpi_row.ts +42 −8
| @@ -11,7 +11,7 @@ import type { Database } from "bun:sqlite"; | ||
| 11 | 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue } from "../../flow/kpi_words.ts"; |
| 12 | 12 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | 13 | import { cachedKpis } from "./home_cache.ts"; |
| 14 | import { html, raw, type Html } from "../html.ts"; | |
| 14 | import { escapeHtml, html, raw, type Html } from "../html.ts"; | |
| 15 | 15 | import { BOOTED } from "../../release/gate.ts"; |
| 16 | 16 | import { running } from "../../kernel/version.ts"; |
| 17 | 17 | import { sha, time } from "../ui.ts"; |
| @@ -19,16 +19,50 @@ import { sha, time } from "../ui.ts"; | ||
| 19 | 19 | const W = 96; |
| 20 | 20 | const H = 24; |
| 21 | 21 | |
| 22 | /** Day `i` of `n` at value `v` on a scale whose top is `top`: x spreads the days across W, y keeps 2 px clear at each edge. */ | |
| 23 | function at(i: number, n: number, v: number, top: number): [string, string] { | |
| 24 | const step = W / Math.max(1, n - 1); | |
| 25 | return [(i * step).toFixed(1), (H - 2 - (v / top) * (H - 4)).toFixed(1)]; | |
| 26 | } | |
| 27 | ||
| 28 | function svg(label: string, body: string): Html { | |
| 29 | return raw(`<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}">${body}</svg>`); | |
| 30 | } | |
| 31 | ||
| 22 | 32 | /** A series oldest first as a fixed-size SVG polyline; the numbers are the only input. */ |
| 23 | 33 | function spark(series: readonly number[], label: string): Html { |
| 24 | 34 | const top = Math.max(1, ...series); |
| 25 | const step = W / Math.max(1, series.length - 1); | |
| 26 | const points = series | |
| 27 | .map((v, i) => `${(i * step).toFixed(1)},${(H - 2 - (v / top) * (H - 4)).toFixed(1)}`) | |
| 28 | .join(" "); | |
| 29 | return raw( | |
| 30 | `<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`, | |
| 31 | ); | |
| 35 | const points = series.map((v, i) => at(i, series.length, v, top).join(",")).join(" "); | |
| 36 | return svg(label, `<polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/>`); | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Days oldest first where `null` is a day with no data: the line breaks there | |
| 41 | * rather than dropping to zero. Each run of numbers is its own polyline on | |
| 42 | * the one shared scale, a run of one day is a dot, and no number at all is no | |
| 43 | * sparkline. Non-finite values count as missing. | |
| 44 | */ | |
| 45 | export function daySpark(days: readonly (number | null)[], label: string): Html | null { | |
| 46 | const known = days.filter((v): v is number => v !== null && Number.isFinite(v)); | |
| 47 | if (known.length === 0) return null; | |
| 48 | const top = Math.max(1, ...known); | |
| 49 | const runs: [string, string][][] = []; | |
| 50 | let run: [string, string][] = []; | |
| 51 | days.forEach((v, i) => { | |
| 52 | if (v === null || !Number.isFinite(v)) { | |
| 53 | if (run.length > 0) runs.push(run); | |
| 54 | run = []; | |
| 55 | } else run.push(at(i, days.length, v, top)); | |
| 56 | }); | |
| 57 | if (run.length > 0) runs.push(run); | |
| 58 | const body = runs | |
| 59 | .map((r) => | |
| 60 | r.length === 1 | |
| 61 | ? `<circle cx="${r[0]![0]}" cy="${r[0]![1]}" r="1.5" fill="currentColor"/>` | |
| 62 | : `<polyline points="${r.map((p) => p.join(",")).join(" ")}" fill="none" stroke="currentColor" stroke-width="1.5"/>`, | |
| 63 | ) | |
| 64 | .join(""); | |
| 65 | return svg(escapeHtml(label), body); | |
| 32 | 66 | } |
| 33 | 67 | |
| 34 | 68 | /** The last 24 hours, newest first in, oldest on the left out. */ |
test/kpi-spark.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The KPI sparklines (#3238): `daySpark` breaks the line at a day with no | |
| 3 | * data, and the two numbers-only helpers draw exactly what they did before. | |
| 4 | * Expected points are worked by hand on the 96 × 24 box: x = i · 96 / (n − 1), | |
| 5 | * y = 22 − (v / top) · 20 with top = max(1, …values). | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { daySpark, sparkline, visitorsSpark } from "../src/web/studio/kpi_row.ts"; | |
| 10 | ||
| 11 | const LINE = 'fill="none" stroke="currentColor" stroke-width="1.5"'; | |
| 12 | ||
| 13 | describe("daySpark", () => { | |
| 14 | test("a null between two runs of two draws two polylines in one svg", () => { | |
| 15 | const out = daySpark([1, 2, null, 3, 4], "Days")!.toString(); | |
| 16 | // top 4, step 24: 1 → 17.0, 2 → 12.0, 3 → 7.0, 4 → 2.0 | |
| 17 | expect(out.match(/<svg/g)).toHaveLength(1); | |
| 18 | expect(out).toContain(`<polyline points="0.0,17.0 24.0,12.0" ${LINE}/>`); | |
| 19 | expect(out).toContain(`<polyline points="72.0,7.0 96.0,2.0" ${LINE}/>`); | |
| 20 | expect(out).not.toContain(",22.0"); // the missing day is not drawn as a zero | |
| 21 | }); | |
| 22 | ||
| 23 | test("[1, null, 3, 4]: the lone first day is a dot, the rest one polyline, nothing at zero", () => { | |
| 24 | const out = daySpark([1, null, 3, 4], "Days")!.toString(); | |
| 25 | // top 4, step 32: 1 → (0, 17.0), 3 → (64, 7.0), 4 → (96, 2.0) | |
| 26 | expect(out.match(/<polyline/g)).toHaveLength(1); | |
| 27 | expect(out.match(/<circle/g)).toHaveLength(1); | |
| 28 | expect(out).toContain('<circle cx="0.0" cy="17.0" r="1.5" fill="currentColor"/>'); | |
| 29 | expect(out).toContain(`<polyline points="64.0,7.0 96.0,2.0" ${LINE}/>`); | |
| 30 | expect(out).not.toContain(",22.0"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("all null is no sparkline at all", () => { | |
| 34 | expect(daySpark([null, null], "Days")).toBeNull(); | |
| 35 | expect(daySpark([], "Days")).toBeNull(); | |
| 36 | }); | |
| 37 | ||
| 38 | test("it keeps the class, the box and currentColor, and adds no style or colour", () => { | |
| 39 | const out = daySpark([0, null, 5], "Days")!.toString(); | |
| 40 | expect(out).toStartWith('<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Days">'); | |
| 41 | expect(out).not.toContain("style="); | |
| 42 | expect(out).not.toMatch(/#[0-9a-f]{3,8}\b|rgb|hsl/i); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the label is escaped", () => { | |
| 46 | expect(daySpark([1], 'a"<b>')!.toString()).toContain('aria-label="a"<b>"'); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("the numbers-only sparklines are unchanged", () => { | |
| 51 | test("visitorsSpark", () => { | |
| 52 | expect(visitorsSpark([0, 2, 4]).toString()).toBe( | |
| 53 | `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`, | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | test("sparkline, newest first in, oldest on the left", () => { | |
| 58 | expect(sparkline([2, 0]).toString()).toBe( | |
| 59 | `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`, | |
| 60 | ); | |
| 61 | }); | |
| 62 | }); |
new above
offline
svg() interpolates label raw; daySpark escapes before calling, spark does not. Escape inside svg() so every caller is safe by construction.