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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/3238-the-sparkline-helper-draws-a-day-with-no → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/3238-the-sparkline-helper-draws-a-day-with-no was merged into main as fa885ef.

Review

Sign in to review this change.
2 files changed +104 −8
src/web/studio/kpi_row.ts +42 −8
@@ -11,7 +11,7 @@ import type { Database } from "bun:sqlite";
1111 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue } from "../../flow/kpi_words.ts";
1212 import type { Kpis } from "../../flow/kpis.ts";
1313 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";
1515 import { BOOTED } from "../../release/gate.ts";
1616 import { running } from "../../kernel/version.ts";
1717 import { sha, time } from "../ui.ts";
@@ -19,16 +19,50 @@ import { sha, time } from "../ui.ts";
1919 const W = 96;
2020 const H = 24;
2121
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

svg() interpolates label raw; daySpark escapes before calling, spark does not. Escape inside svg() so every caller is safe by construction.

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
2232 /** A series oldest first as a fixed-size SVG polyline; the numbers are the only input. */
2333 function spark(series: readonly number[], label: string): Html {
2434 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);
3266 }
3367
3468 /** 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&quot;&lt;b&gt;"');
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs