feat(timeline): keep gaps in value lines, mark thin means, allow six lines

Every bucket with a reading gets its point; one with fewer than three
readings is drawn hollow. A short gap (one quarter, two months, four
weeks) is bridged with a dashed stroke between the two real points; a
longer one leaves the line open. The selection bar and the value table
for screen readers name each bucket's readings and say "no value" where
there is none. The phone chronicle names each bucket once, at its newest
month. The value menu takes up to six lines. The legend's causality line
is gone with its keys in all seven bundles.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:03:42 +02:00
1 parent af982e3812
commit fcc6f40410
21 files changed
+1011 -203

No files matched your search

+80 -6
View File
@@ -10,8 +10,11 @@
* day.
* 4. A life event is entered through the sheet and posted in the
* contract's shape.
* 5. On a phone the chronicle replaces the chart, says its empty months
* out loud, and a row opens its day.
* 5. The value lines: quarterly means over the years, a missing quarter
* left out, a short gap bridged dashed, a thin quarter hollow, and up
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
@@ -22,7 +25,7 @@ import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -119,11 +122,10 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
await expect(
page.locator('[data-slot="timeline-legend-causality"]'),
).toBeVisible();
// At most three value lines are asked for.
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
expect(asked.length).toBeLessThanOrEqual(3);
expect(asked.length).toBeLessThanOrEqual(6);
// The keyboard: one step back and Enter opens that day.
const before = await page
@@ -157,6 +159,69 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
await expect(sys).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "quarter");
const point = (t: string) =>
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
// A missing quarter stays missing: no point is drawn for it.
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
// The quarters around Q2 2023 are joined by a dashed bridge; the three
// missing quarters of 2021 are not (one bridge in the whole line).
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
1,
);
await expect(point("2023-01-01")).toHaveCount(1);
await expect(point("2023-07-01")).toHaveCount(1);
// Two readings: drawn hollow; four and more: filled.
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
// The value table keeps the missing quarter as a row of its own.
await expect(
page.locator(
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
),
).toBeAttached();
// Six value lines can be chosen; a seventh cannot.
await page.locator('[data-slot="timeline-values-trigger"]').click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
for (let i = 0; i < count; i++) {
const checked = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"]',
);
if ((await checked.count()) >= 6) break;
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(6);
await expect(
page.locator(
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
),
).toHaveCount(count - 6);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(6);
await expect(page.locator("svg [data-series]")).toHaveCount(6);
});
async function setTimelineModuleOn(page: Page) {
await page.goto("/settings/modules");
await setTimelineModule(page, true);
@@ -218,6 +283,15 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
await expect(
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
).toBeVisible();
// Quarterly means, each quarter named once, at its newest listed month.
const means = page.locator('[data-slot="timeline-chronicle-means"]');
await expect(means.first()).toBeVisible();
const buckets = await means.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-bucket") ?? ""),
);
expect(buckets.length).toBeGreaterThan(0);
expect(new Set(buckets).size).toBe(buckets.length);
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
await page
.locator(
+115 -33
View File
@@ -46,76 +46,142 @@ function rng(seed: number) {
};
}
function monthly(
from: string,
to: string,
value: (i: number, monthKey: string) => number,
) {
const out: Array<{ t: string; mean: number }> = [];
type Bucket = "quarter" | "month" | "week";
/** The bucket the server picks for a zoom (`timelineBucket`). */
export function bucketFor(zoom: string): Bucket {
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
}
function dayNum(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
}
function dayStr(n: number): string {
return new Date(n * 86_400_000).toISOString().slice(0, 10);
}
/** Bucket starts from the one holding `from` to the one holding `to`. */
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
const out: string[] = [];
if (bucket === "week") {
const n = dayNum(from);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
out.push(dayStr(d));
}
return out;
}
const step = bucket === "quarter" ? 3 : 1;
let [y, m] = from.split("-").map(Number);
m -= (m - 1) % step;
const [ty, tm] = to.split("-").map(Number);
let i = 0;
while (y < ty || (y === ty && m <= tm)) {
const key = `${y}-${String(m).padStart(2, "0")}-01`;
out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 });
i++;
m++;
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
m += step;
if (m > 12) {
m = 1;
m -= 12;
y++;
}
}
return out;
}
function series(dataFrom: string, today: string) {
/**
* Systolic pressure leaves gaps the chart has to show honestly: in the
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
*/
export const SYS_MISSING = new Set([
"2021-01-01",
"2021-04-01",
"2021-07-01",
"2023-04-01",
]);
export const SYS_THIN = "2022-01-01";
function points(
from: string,
to: string,
bucket: Bucket,
value: (i: number, key: string) => number,
skip: (key: string) => boolean = () => false,
) {
const starts = bucketStarts(from, to, bucket);
return starts
.map((key, i) => ({
t: key,
mean: Math.round(value(i / starts.length, key) * 10) / 10,
count: key === SYS_THIN ? 2 : 4 + (i % 9),
}))
.filter((p) => !skip(p.t));
}
function series(dataFrom: string, today: string, bucket: Bucket) {
const r = rng(11);
const months = monthly(dataFrom, today, () => 0).length;
return [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 147 - (18 * i) / months + (r() - 0.5) * 4,
bucket,
(f) => 147 - 18 * f + (r() - 0.5) * 4,
(t) => bucket === "quarter" && SYS_MISSING.has(t),
),
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 92 - (10 * i) / months + (r() - 0.5) * 3,
bucket,
(f) => 92 - 10 * f + (r() - 0.5) * 3,
),
},
{
key: "WEIGHT",
unit: "kg",
granularity: "month",
points: monthly(
points: points(
dataFrom,
today,
(i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6,
bucket,
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
),
},
{
key: "RESTING_HEART_RATE",
unit: "bpm",
granularity: "month",
points: monthly(dataFrom, today, (i, k) => {
points: points(dataFrom, today, bucket, (f, k) => {
const month = Number(k.slice(5, 7));
return (
68 -
(5 * i) / months +
Math.sin((month / 12) * 6.28) * 1.2 +
(r() - 0.5) * 1.2
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
);
}),
},
{
key: "PULSE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
},
{
key: "BODY_FAT",
unit: "%",
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
},
{
key: "ACTIVITY_STEPS",
unit: "steps",
points: points(
dataFrom,
today,
bucket,
(f) => 7200 + 900 * f + r() * 400,
),
},
];
}
@@ -350,7 +416,8 @@ export function fullTimeline(today: string) {
},
],
standing: [],
series: series("2019-01-01", today),
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -468,7 +535,8 @@ export function readyTimeline(today: string) {
},
],
standing: [],
series: series("2024-05-01", today).filter(
bucket: "quarter" as Bucket,
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
notable: [],
@@ -673,10 +741,24 @@ export async function mockTimeline(
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
const zoom = url.searchParams.get("zoom") ?? "all";
const bucket = bucketFor(zoom);
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
const all =
bucket === body.bucket ? body.series : series(dataFrom, today, bucket);
return json(route, 200, {
...body,
zoom: url.searchParams.get("zoom") ?? "all",
series: body.series.filter((s) => values.includes(s.key)),
zoom,
bucket,
// Any other value the menu offers answers a flat line of its own.
series: values.map(
(key) =>
all.find((s) => s.key === key) ?? {
key,
unit: null,
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
},
),
});
});
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {