From f788e2892d97a16eebe2c772ea3fb4038512bd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 09:48:46 +0200 Subject: [PATCH 1/5] fix(coach): put New chat in the conversations panel header The round button floated over the list's bottom right and covered the last conversation, which the list offset with a pb-20 reserve. It is now the first control of the panel header, left of the title, in the docked panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px floor on touch, with its accessible name and a tooltip. The reserve goes with it, and the header packs its touch-sized buttons tighter so the title keeps its word at 390 px. --- e2e/coach-layout.spec.ts | 92 +++++++++++-------- .../coach-panel/conversations-panel.tsx | 57 +++++++----- .../insights/coach-panel/history-rail.tsx | 6 +- 3 files changed, 85 insertions(+), 70 deletions(-) diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index 67b455d96..274702f7a 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -7,8 +7,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * The Coach page frame: the conversations panel on the right, its toggle in - * the top bar, the settings gear in the panel header, and New chat at the - * panel's bottom right. + * the top bar, the settings gear in the panel header, and New chat opening + * that header row. * * Contracts under test, at 1440, 1280 and 390 px: * @@ -20,7 +20,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * top bar ends at its left edge and the panel's header border is the * top bar's. The toggle is the bar's last item, within 16 px of its * right edge, open and shut; shut, no pixel of the panel shows and the - * bar runs to the window's edge. New chat is a round 48 px plus. + * bar runs to the window's edge. New chat is the header row's first + * control, left of the title, and never overlaps the list. * 2. Escape inside the docked panel closes it and returns focus to the * toggle. * 3. A row's menu renames by keyboard (Escape cancels without closing the @@ -31,8 +32,8 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * can see" and drops the param from the URL. * 5. axe finds nothing on the open panel or the open settings, in the * light and the dark theme. - * 6. At 390 px the panel is a dialog sheet with New chat 16 px from the - * right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear + * 6. At 390 px the panel is a dialog sheet with New chat first in its + * header and the title beside it; Escape closes it and focus returns to the toggle. The gear * opens a bottom sheet that closes on Escape back to the gear. Nothing * scrolls sideways. * @@ -316,19 +317,38 @@ async function expectToggleAtBarEnd(page: Page) { expect(at.gap).toBeLessThanOrEqual(16); } -/** New chat: a round 48 px button with the plus glyph. */ -async function expectFab(page: Page) { - const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]'); - const box = (await fab.boundingBox())!; - expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]); +/** + * New chat opens the panel header: the row's first control, left of the + * title, a plus glyph with an accessible name. It sits above the list, so no + * row ever slides under it. + */ +async function expectNewChatInHeader(scope: Locator) { + const header = scope.locator( + '[data-slot="coach-conversations-panel-header"]', + ); + const button = header.locator('[data-slot="coach-panel-new-chat"]'); + await expect(button).toHaveCount(1); + await expect(button.locator("svg.lucide-plus")).toHaveCount(1); + await expect(button).toHaveAttribute("aria-label", "New chat"); + const order = await header.evaluate((el) => { + const first = el.firstElementChild as HTMLElement; + const heading = el.querySelector("h2") as HTMLElement; + return { + first: first.dataset.slot, + buttonRight: first.getBoundingClientRect().right, + headingLeft: heading.getBoundingClientRect().left, + }; + }); + expect(order.first).toBe("coach-panel-new-chat"); + expect(order.buttonRight).toBeLessThanOrEqual(order.headingLeft); + const buttonBox = (await button.boundingBox())!; + const listBox = (await scope + .locator('[data-slot="coach-history-list"]') + .boundingBox())!; expect( - await fab.evaluate((el) => - parseFloat(getComputedStyle(el).borderTopLeftRadius), - ), - "round", - ).toBeGreaterThanOrEqual(24); - await expect(fab.locator("svg.lucide-plus")).toHaveCount(1); - await expect(fab).toHaveAttribute("aria-label", "New chat"); + buttonBox.y + buttonBox.height, + "the button ends above the list", + ).toBeLessThanOrEqual(listBox.y); } /** Rendered heights of the docked panel's header buttons and first row. */ @@ -341,6 +361,7 @@ async function panelControlHeights(page: Page) { .boundingBox())!.height, ); return { + newChat: await height("coach-panel-new-chat"), plans: await height("coach-panel-plans"), gear: await height("coach-settings"), row: await height("coach-history-select"), @@ -439,7 +460,7 @@ test.describe("Coach page frame", () => { await expectPanelFlush(page, width, 900); // Open, the toggle is the top bar's last item, against the panel. await expectToggleAtBarEnd(page); - await expectFab(page); + await expectNewChatInHeader(panel(page)); // The bar says where the reader is: the page, then the conversation. await expect( page.locator('[data-slot="coach-top-bar-trail"]'), @@ -505,6 +526,7 @@ test.describe("Coach page frame", () => { await mockCoach(page); await openCoach(page); expect(await panelControlHeights(page)).toEqual({ + newChat: 28, plans: 28, gear: 28, row: 36, @@ -524,6 +546,7 @@ test.describe("Coach page frame", () => { await openCoach(page); await expect(panel(page)).toHaveAttribute("data-state", "open"); expect(await panelControlHeights(page)).toEqual({ + newChat: 44, plans: 44, gear: 44, row: 44, @@ -552,18 +575,8 @@ test.describe("Coach page frame", () => { await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/); await expect(rows.nth(1)).toHaveAttribute("aria-current", "true"); - const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]'); - const fabBox = (await fab.boundingBox())!; - const asideBox = (await panel(page).boundingBox())!; - // 24 px in from the panel's corner from `md`, 16 px on a phone. - expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo( - 24, - 0, - ); - expect( - asideBox.y + asideBox.height - (fabBox.y + fabBox.height), - ).toBeCloseTo(24, 0); - await fab.click(); + const newChat = panel(page).locator('[data-slot="coach-panel-new-chat"]'); + await newChat.click(); await expect(page).toHaveURL(/\/coach$/); await expect( page.locator('[data-slot="coach-input-textarea"]'), @@ -788,16 +801,15 @@ test.describe("Coach page frame", () => { const sheet = page.getByRole("dialog", { name: "Conversations" }); await expect(sheet).toBeVisible(); await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); - const fab = sheet.locator('[data-slot="coach-panel-new-chat"]'); - // Measured once the sheet has slid in. - await expect - .poll(async () => { - const box = (await fab.boundingBox())!; - return Math.round(390 - (box.x + box.width)); - }) - .toBe(16); - expect((await fab.boundingBox())!.width).toBe(48); - await expect(fab.locator("svg.lucide-plus")).toHaveCount(1); + await expectNewChatInHeader(sheet); + // The title keeps its whole word beside four header controls. + const title = sheet.locator( + '[data-slot="coach-conversations-panel-header"] h2', + ); + expect( + await title.evaluate((el) => el.scrollWidth <= el.clientWidth), + "the sheet title is not truncated", + ).toBe(true); await shot(page, testInfo, "coach-frame-390-panel"); await page.keyboard.press("Escape"); diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index ec10a47c5..c8bb33cd3 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -46,8 +46,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach"; * and the top bar runs to the window's edge. The toggle is the last item of * the top bar, so it sits against the open panel and at the window's edge * when the panel is shut. The panel header carries the title, a link to - * Plans, the settings gear and, in the sheet, a close button. The round New - * chat button sits at the panel's bottom right. + * Plans, the settings gear and, in the sheet, a close button. New chat + * opens the header row, left of the title: a fixed control that never sits + * over the list, so the last conversation needs no reserve below it. * * Keyboard: Escape closes the docked panel when focus is inside it and * nothing inside (a row menu, the rename field, the settings popover) has @@ -214,13 +215,41 @@ export function ConversationsPanel({ // app (`shell-safe-area` in `auth-shell.tsx`). // Docked, the row is the top bar's band (height and bottom border // from `SHELL_HEADER_BAND`), so the two borders draw one line. + // Touch: 44 px buttons already carry their own air, so the row packs + // them tighter to leave the title room at 390 px. className={cn( - "flex shrink-0 items-center gap-2", + "flex shrink-0 items-center gap-1 pointer-fine:gap-2", inSheet ? "border-border border-b p-3" : cn("border-sidebar-border px-3", SHELL_HEADER_BAND), )} > + + + + + + {t("insights.coach.newChat")} + + {inSheet ? ( {t("insights.coach.historyTitle")} @@ -287,28 +316,6 @@ export function ConversationsPanel({ onUndoDeleteActive={(id) => onSelect(id)} className="min-h-0 flex-1" /> - {/* New chat: a round filled button floating at the panel's bottom - right; the list pads its own scroll area so the last row clears it. */} - - - - - - {t("insights.coach.newChat")} - - ); diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 1590b56cc..3ecaa120f 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -149,11 +149,7 @@ export function HistoryRail({
{isLoading && visible.length === 0 ? (

Date: Fri, 9 Oct 2026 09:48:53 +0200 Subject: [PATCH 2/5] feat(timeline): colour each value line and name the selection's bucket Each value line takes the colour its type carries in the measurement list (a --chart-N token); where two chosen lines would share one, or a type has none, the later line in the menu's order takes the first free data token, and a sixth line with none left falls back to the foreground. The swatch beside the name, the line, the dashed bridge, filled and hollow points and a ring on the selected bucket all inherit one colour from their group. The menu, the selection bar and the phone chronicle mark each value with the same dot. The colour map moves to an icon-free module so the timeline does not pull the list's icons. The selection bar is a cross-section of one bucket and now says which: its head names the quarter, month or week in full, its entries are the ones that touch that bucket, and each mean keeps its readings count. Values go through the day view's formatter, so sleep reads as a duration and the unit preference applies; the server sends canonical units, which the old formatter took for display units. Before anyone picks a day the bar opens on the newest bucket it has something for in the requested window (documents, which it never lists, no longer count), and a short line says how to pick another. --- e2e/timeline.spec.ts | 52 +++ messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/ko.json | 7 +- messages/pl.json | 7 +- src/components/day/use-day-value-format.ts | 2 +- .../measurements/measurement-list-meta.ts | 129 +------ .../measurements/measurement-type-colors.ts | 132 ++++++++ .../timeline/__tests__/series-colors.test.ts | 74 ++++ .../__tests__/timeline-components.test.tsx | 315 +++++++++++++++--- .../__tests__/timeline-geometry.test.ts | 55 ++- src/components/timeline/selection-bar.tsx | 142 ++++++-- src/components/timeline/series-colors.ts | 70 ++++ src/components/timeline/series-format.ts | 142 ++++---- src/components/timeline/timeline-chart.tsx | 301 ++++++++++++----- .../timeline/timeline-chronicle.tsx | 30 +- src/components/timeline/timeline-dates.ts | 12 + src/components/timeline/timeline-geometry.ts | 62 +++- src/components/timeline/timeline-menus.tsx | 11 + src/components/timeline/timeline-view.tsx | 48 +-- .../timeline/use-series-value-format.ts | 35 ++ 24 files changed, 1244 insertions(+), 417 deletions(-) create mode 100644 src/components/measurements/measurement-type-colors.ts create mode 100644 src/components/timeline/__tests__/series-colors.test.ts create mode 100644 src/components/timeline/series-colors.ts create mode 100644 src/components/timeline/use-series-value-format.ts diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8fcdc3bb9..548c21f33 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -127,6 +127,21 @@ test("the timeline hands the selected day to the day view through ?day=", async expect(asked.length).toBeGreaterThan(0); expect(asked.length).toBeLessThanOrEqual(6); + // Before anyone picks a day the bar opens on the newest bucket it has + // something for, says so in its head, and says how to pick another. + const bar = page.locator('[data-slot="timeline-selection-bar"]'); + await expect(bar).toHaveAttribute("data-bucket", "quarter"); + await expect(bar).toHaveAttribute( + "data-period-from", + /^\d{4}-(01|04|07|10)-01$/, + ); + await expect( + page.locator('[data-slot="timeline-selection-empty"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="timeline-selection-hint"]'), + ).toBeVisible(); + // The keyboard: one step back and Enter opens that day. const before = await page .locator('[data-slot="timeline-selection"]') @@ -137,6 +152,10 @@ test("the timeline hands the selected day to the day view through ?day=", async .locator('[data-slot="timeline-selection"]') .getAttribute("data-date"); expect(after).not.toBe(before); + // A day is picked now: the hint has done its job. + await expect( + page.locator('[data-slot="timeline-selection-hint"]'), + ).toHaveCount(0); await page.keyboard.press("Enter"); await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`)); @@ -220,6 +239,35 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin .poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length) .toBe(6); await expect(page.locator("svg [data-series]")).toHaveCount(6); + // Each line in its own colour, and every mark of a line in that colour. + const colours = await page + .locator("svg [data-series]") + .evaluateAll((groups) => + groups.map((g) => ({ + colour: getComputedStyle(g).color, + marks: [ + ...g.querySelectorAll( + '[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]', + ), + ].map((m) => getComputedStyle(m).stroke), + })), + ); + expect(new Set(colours.map((c) => c.colour)).size).toBe(6); + for (const { colour, marks } of colours) { + expect(marks.length).toBeGreaterThan(0); + for (const stroke of marks) expect(stroke).toBe(colour); + } + // The menu marks each chosen line with the same colour. + await page.locator('[data-slot="timeline-values-trigger"]').click(); + const dots = await page + .locator( + '[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]', + ) + .evaluateAll((els) => + els.map((el) => getComputedStyle(el).backgroundColor), + ); + expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour))); + await page.keyboard.press("Escape"); }); async function setTimelineModuleOn(page: Page) { @@ -292,6 +340,10 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn 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$/); + // Each mean carries its line's colour dot. + await expect( + means.first().locator('[data-slot="timeline-series-dot"]').first(), + ).toBeVisible(); await page .locator( diff --git a/messages/de.json b/messages/de.json index 85f28b659..bf76a35a6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12448,10 +12448,13 @@ "period": "Zeitraum" }, "selection": { - "empty": "Keine Einträge in diesem Monat", + "empty": "Keine Einträge in diesem Zeitraum", "ongoing": "laufend", "range": "{from} bis {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Woche vom {date}", + "hintClick": "Klicke auf einen Zeitpunkt, um ihn hier zu sehen", + "hintTap": "Tippe auf einen Zeitpunkt, um ihn hier zu sehen" }, "chronicle": { "months": "Monate", diff --git a/messages/en.json b/messages/en.json index ef3b5feba..132459665 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12448,10 +12448,13 @@ "period": "Period" }, "selection": { - "empty": "No entries this month", + "empty": "No entries in this period", "ongoing": "ongoing", "range": "{from} to {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Week of {date}", + "hintClick": "Click a point in time to see it here", + "hintTap": "Tap a point in time to see it here" }, "chronicle": { "months": "Months", diff --git a/messages/es.json b/messages/es.json index b920dd20c..c36722994 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12448,10 +12448,13 @@ "period": "Periodo" }, "selection": { - "empty": "Sin entradas este mes", + "empty": "No hay entradas en este periodo", "ongoing": "en curso", "range": "{from} a {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Semana del {date}", + "hintClick": "Haz clic en un momento para verlo aquí", + "hintTap": "Toca un momento para verlo aquí" }, "chronicle": { "months": "Meses", diff --git a/messages/fr.json b/messages/fr.json index 37ab610cd..819d7bee3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12448,10 +12448,13 @@ "period": "Période" }, "selection": { - "empty": "Aucune entrée ce mois-ci", + "empty": "Aucune entrée sur cette période", "ongoing": "en cours", "range": "{from} au {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Semaine du {date}", + "hintClick": "Cliquez sur un moment pour le voir ici", + "hintTap": "Touchez un moment pour le voir ici" }, "chronicle": { "months": "Mois", diff --git a/messages/it.json b/messages/it.json index a763be6bf..319242df5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12448,10 +12448,13 @@ "period": "Periodo" }, "selection": { - "empty": "Nessuna voce in questo mese", + "empty": "Nessuna voce in questo periodo", "ongoing": "in corso", "range": "dal {from} al {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Settimana del {date}", + "hintClick": "Fai clic su un momento per vederlo qui", + "hintTap": "Tocca un momento per vederlo qui" }, "chronicle": { "months": "Mesi", diff --git a/messages/ko.json b/messages/ko.json index 42938c942..2af6e8037 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12448,10 +12448,13 @@ "period": "기간" }, "selection": { - "empty": "이번 달 기록 없음", + "empty": "이 기간에는 항목이 없습니다", "ongoing": "진행 중", "range": "{from}~{to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "{date} 주", + "hintClick": "시점을 클릭하면 여기에 표시됩니다", + "hintTap": "시점을 탭하면 여기에 표시됩니다" }, "chronicle": { "months": "월", diff --git a/messages/pl.json b/messages/pl.json index 56627bedc..337572ce2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12448,10 +12448,13 @@ "period": "Okres" }, "selection": { - "empty": "Brak wpisów w tym miesiącu", + "empty": "Brak wpisów w tym okresie", "ongoing": "trwa", "range": "od {from} do {to}", - "mean": "Ø {values}" + "mean": "Ø {values}", + "week": "Tydzień od {date}", + "hintClick": "Kliknij punkt w czasie, aby zobaczyć go tutaj", + "hintTap": "Dotknij punktu w czasie, aby zobaczyć go tutaj" }, "chronicle": { "months": "Miesiące", diff --git a/src/components/day/use-day-value-format.ts b/src/components/day/use-day-value-format.ts index 70dca643c..13f03251d 100644 --- a/src/components/day/use-day-value-format.ts +++ b/src/components/day/use-day-value-format.ts @@ -134,5 +134,5 @@ export function useDayValueFormat() { [labelFor, number, unitFor], ); - return { labelFor, formatTile }; + return { labelFor, formatTile, number, unitFor }; } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 1ac911c5a..2f8aa81a9 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -269,132 +269,9 @@ export const MEASUREMENT_TYPE_ICONS: Record = { WAIST_TO_HEIGHT: Scale, }; -export const MEASUREMENT_TYPE_COLORS: Record = { - WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - // ── v1.4.23 Apple Health additions ── - HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - // ── v1.4.25 W5d Withings full coverage ── - // chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5 - // (pulse-derived) — extend the existing color-family conventions. - FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - // ── v1.4.25 W8d Apple Health server-prep ── - // chart-5 (pulse / sound family) carries audio exposure; chart-2 - // (activity / daylight family) carries time-in-daylight so the - // existing palette conventions hold. - AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - // ── v1.4.30 R-F T1.4 + T1.5 ── - WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - // ── v1.5.5 iOS-coord additions ── - // Reuse the existing palette families: chart-5 (cardio/pulse), - // chart-1 (mass), chart-2 (activity/gait). - RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── - // Stay in chart-2 (Dracula green) — the entire Mobility cluster - // (steadiness + asymmetry + double-support + step length + speed) - // shares the activity-family colour so the gait cards read as one - // visual group on Insights. - WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - // ── v1.10.0 — additive HealthKit signals (WX-A) ── - // chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family), - // chart-5 (sleep-breathing, shares the SpO2 family). - CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - // ── v1.10.0 — categorical events (WX-B) ── - // chart-3 (cardio family) carries the rhythm + heart-rate events; - // chart-2 (activity/mobility) carries the steadiness event; chart-5 - // (respiratory/pulse family) carries the breathing event. - IRREGULAR_RHYTHM_NOTIFICATION: - "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - // ── v1.10.0 — computed scores (WX-C) ── - // chart-1 (Dracula purple) marks the server-derived composites as their - // own visual group, distinct from the raw-signal families above. - RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - // ── v1.11.0 — WHOOP-native score classes ── - // chart-1 (Dracula purple) for the strain composites (same group as the - // WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set - // and energy; chart-5 (cardio/pulse family) for RMSSD HRV. - HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── - // chart-3 (cardio family) for the daily-aggregate heart-rate pair; - // chart-2 (sleep/activity family) for the per-night disturbance count. - AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── - // chart-1 (strain/score family) for both, matching DAY_STRAIN. - ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - // ── v1.17.1 — Oura coverage completion ── - // chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal) - // for the body-temperature deviation. - SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", - BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", - // ── v1.19.0 — Oura resilience ── - // chart-1 (Dracula purple) marks the derived recovery / score composites as - // their own visual group, matching RECOVERY_SCORE / DAY_STRAIN. - RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - // ── v1.25 — clinical-signals wave ── - // chart-1 (composite/score family) for the screener totals + grip strength; - // chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist. - PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - // ── v1.27.9 — screening scores join the composite/score colour family ── - WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", - PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", - WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", - WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", -}; +// The colour map lives in its own icon-free module so a surface that only +// needs the colours (the timeline's value lines) does not pull every icon. +export { MEASUREMENT_TYPE_COLORS } from "./measurement-type-colors"; /** * Count-shaped types whose stored unit is a source token ("count", diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts new file mode 100644 index 000000000..5f3386b6c --- /dev/null +++ b/src/components/measurements/measurement-type-colors.ts @@ -0,0 +1,132 @@ +/** + * The colour each measurement type carries in the list (a `--chart-N` wash + * and text), and that its value line carries on the timeline. Class strings + * over the theme tokens, so both themes retune them; no icon imports here, + * so a colour-only consumer stays light. + */ +export const MEASUREMENT_TYPE_COLORS: Record = { + WEIGHT: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + BLOOD_PRESSURE_SYS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + BLOOD_PRESSURE_DIA: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + PULSE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + BODY_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + SLEEP_DURATION: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + ACTIVITY_STEPS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + BLOOD_GLUCOSE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + TOTAL_BODY_WATER: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + BONE_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + OXYGEN_SATURATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + // ── v1.4.23 Apple Health additions ── + HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + BODY_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.4.25 W5d Withings full coverage ── + // chart-1 (mass), chart-3 (cardio), chart-4 (fat/temp), chart-5 + // (pulse-derived) — extend the existing color-family conventions. + FAT_FREE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + FAT_MASS: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + MUSCLE_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + SKIN_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + PULSE_WAVE_VELOCITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + VASCULAR_AGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + VISCERAL_FAT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.4.25 W8d Apple Health server-prep ── + // chart-5 (pulse / sound family) carries audio exposure; chart-2 + // (activity / daylight family) carries time-in-daylight so the + // existing palette conventions hold. + AUDIO_EXPOSURE_ENV: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + TIME_IN_DAYLIGHT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + // ── v1.4.30 R-F T1.4 + T1.5 ── + WALKING_STEADINESS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + AUDIO_EXPOSURE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + // ── v1.5.5 iOS-coord additions ── + // Reuse the existing palette families: chart-5 (cardio/pulse), + // chart-1 (mass), chart-2 (activity/gait). + RESPIRATORY_RATE: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + BODY_MASS_INDEX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + LEAN_BODY_MASS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + WALKING_HEART_RATE_AVERAGE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + WALKING_ASYMMETRY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + WALKING_DOUBLE_SUPPORT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Stay in chart-2 (Dracula green) — the entire Mobility cluster + // (steadiness + asymmetry + double-support + step length + speed) + // shares the activity-family colour so the gait cards read as one + // visual group on Insights. + WALKING_STEP_LENGTH: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + WALKING_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family), + // chart-5 (sleep-breathing, shares the SpO2 family). + CARDIO_RECOVERY: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + WRIST_TEMPERATURE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + FALL_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAIR_ASCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAIR_DESCENT_SPEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + BREATHING_DISTURBANCES: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + // ── v1.10.0 — categorical events (WX-B) ── + // chart-3 (cardio family) carries the rhythm + heart-rate events; + // chart-2 (activity/mobility) carries the steadiness event; chart-5 + // (respiratory/pulse family) carries the breathing event. + IRREGULAR_RHYTHM_NOTIFICATION: + "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + HIGH_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + LOW_HEART_RATE_EVENT: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + WALKING_STEADINESS_EVENT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + BREATHING_DISTURBANCE_EVENT: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + // ── v1.10.0 — computed scores (WX-C) ── + // chart-1 (Dracula purple) marks the server-derived composites as their + // own visual group, distinct from the raw-signal families above. + RECOVERY_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + STRESS_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + STRAIN_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + // ── v1.11.0 — WHOOP-native score classes ── + // chart-1 (Dracula purple) for the strain composites (same group as the + // WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set + // and energy; chart-5 (cardio/pulse family) for RMSSD HRV. + HRV_RMSSD: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + DAY_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + WORKOUT_STRAIN: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + SLEEP_PERFORMANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + SLEEP_EFFICIENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + SLEEP_CONSISTENCY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + SLEEP_NEED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + ENERGY_EXPENDITURE_KJ: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // chart-3 (cardio family) for the daily-aggregate heart-rate pair; + // chart-2 (sleep/activity family) for the per-night disturbance count. + AVERAGE_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + MAX_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + SLEEP_DISTURBANCE_COUNT: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // chart-1 (strain/score family) for both, matching DAY_STRAIN. + ANS_CHARGE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + CARDIO_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + // ── v1.17.1 — Oura coverage completion ── + // chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal) + // for the body-temperature deviation. + SLEEP_SCORE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + BODY_TEMPERATURE_DEVIATION: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", + // ── v1.19.0 — Oura resilience ── + // chart-1 (Dracula purple) marks the derived recovery / score composites as + // their own visual group, matching RECOVERY_SCORE / DAY_STRAIN. + RESILIENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + // ── v1.25 — clinical-signals wave ── + // chart-1 (composite/score family) for the screener totals + grip strength; + // chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist. + PHQ9_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + GAD7_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + // ── v1.27.9 — screening scores join the composite/score colour family ── + WHO5_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + SCI_SCORE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + GRIP_STRENGTH: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", +}; diff --git a/src/components/timeline/__tests__/series-colors.test.ts b/src/components/timeline/__tests__/series-colors.test.ts new file mode 100644 index 000000000..d14355803 --- /dev/null +++ b/src/components/timeline/__tests__/series-colors.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + SERIES_FALLBACK_COLOR, + SERIES_PALETTE, + assignSeriesColors, + preferredSeriesColor, +} from "../series-colors"; +import { VALUE_OPTIONS } from "../timeline-view"; + +/** + * A value line takes the colour its type carries in the measurement list, + * and no two chosen lines ever share one. + */ +describe("assignSeriesColors", () => { + it("gives a type on its own the colour it has in the list", () => { + expect(assignSeriesColors(["WEIGHT"]).get("WEIGHT")).toBe("var(--chart-1)"); + expect(assignSeriesColors(["PULSE"]).get("PULSE")).toBe("var(--chart-5)"); + expect(assignSeriesColors(["SLEEP_DURATION"]).get("SLEEP_DURATION")).toBe( + "var(--chart-2)", + ); + for (const key of VALUE_OPTIONS) { + const own = preferredSeriesColor(key); + const alone = assignSeriesColors([key]).get(key); + expect(alone).toBe(own ?? SERIES_PALETTE[0]); + } + }); + + it("only ever hands out data tokens, and the foreground once they run out", () => { + for (const key of VALUE_OPTIONS) { + const own = preferredSeriesColor(key); + if (own) expect(SERIES_PALETTE).toContain(own); + } + }); + + it("never repeats a colour among six chosen lines", () => { + // Every six-line choice from the selector's options. + const options = [...VALUE_OPTIONS]; + const pick = (from: number, chosen: string[]): string[][] => + chosen.length === 6 + ? [chosen] + : options + .slice(from) + .flatMap((key, i) => pick(from + i + 1, [...chosen, key])); + const choices = pick(0, []); + expect(choices.length).toBeGreaterThan(100); + for (const keys of choices) { + const colours = assignSeriesColors(keys, VALUE_OPTIONS); + expect(colours.size).toBe(6); + expect(new Set(colours.values()).size).toBe(6); + for (const colour of colours.values()) { + expect([...SERIES_PALETTE, SERIES_FALLBACK_COLOR]).toContain(colour); + } + } + }); + + it("is the same whatever order the lines were picked in", () => { + const keys = [ + "BLOOD_PRESSURE_SYS", + "WEIGHT", + "RESTING_HEART_RATE", + "BLOOD_PRESSURE_DIA", + "MOOD", + ]; + const forward = assignSeriesColors(keys, VALUE_OPTIONS); + const backward = assignSeriesColors([...keys].reverse(), VALUE_OPTIONS); + expect(Object.fromEntries(backward)).toEqual(Object.fromEntries(forward)); + // Systolic keeps its own colour; diastolic, resting pulse and mood + // move to free ones. + expect(forward.get("BLOOD_PRESSURE_SYS")).toBe("var(--chart-3)"); + expect(forward.get("WEIGHT")).toBe("var(--chart-1)"); + expect(forward.get("BLOOD_PRESSURE_DIA")).not.toBe("var(--chart-3)"); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 3aef7cb9a..67b709c38 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -6,26 +6,64 @@ */ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CAPTURE_KIND_ORDER, visibleCaptureKinds, } from "@/components/layout/capture-picker"; -import { I18nProvider } from "@/lib/i18n/context"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import { ReadinessCard } from "../readiness-card"; import { ReadinessInventory, VerdictMeter } from "../readiness-inventory"; -import { SelectionBar } from "../selection-bar"; -import { TimelineChart } from "../timeline-chart"; +import { SelectionBar, bucketTitle } from "../selection-bar"; +import { assignSeriesColors } from "../series-colors"; +import type { SeriesValueFormat } from "../series-format"; +import { MeanPartsLine, SeriesLines, TimelineChart } from "../timeline-chart"; import { TimelineChronicle } from "../timeline-chronicle"; +import { layoutTimeline } from "../timeline-geometry"; +import { useSeriesValueFormat } from "../use-series-value-format"; import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; function render(node: React.ReactElement, locale: "de" | "en" = "de") { + // A fresh client with no account answer: the unit preference resolves to + // its default (metric), as it does before `/api/auth/me` lands. return renderToStaticMarkup( - {node}, + + {node} + , ); } +/** Renders `build` with the timeline's real value format. */ +function WithFormat({ + build, +}: { + build: (format: SeriesValueFormat) => React.ReactNode; +}) { + return <>{build(useSeriesValueFormat())}; +} + +/** Runs `run` with the bundle's `t`, for the plain wording helpers. */ +function I18nProbe({ + run, +}: { + run: (t: ReturnType["t"]) => string; +}) { + const { t } = useTranslations(); + return <>{run(t)}; +} + +/** The text a reader sees, without the markup between the runs. */ +const text = (html: string) => html.replace(/<[^>]+>/g, ""); + +const COLORS = assignSeriesColors([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "WEIGHT", +]); +const seriesColor = (key: string) => COLORS.get(key) ?? "var(--foreground)"; + describe("readiness inventory", () => { it("names every lane with its state and one link per gap", () => { const html = render(); @@ -90,13 +128,19 @@ describe("readiness inventory", () => { describe("chronicle", () => { it("names an empty stretch instead of skipping it, and folds the ongoing items", () => { const html = render( - undefined} - onEditLifeEvent={null} + ( + undefined} + onEditLifeEvent={null} + /> + )} />, ); expect(html).toContain("April bis Oktober 2026 · keine Einträge"); @@ -111,16 +155,22 @@ describe("chronicle", () => { it("shows the month's means beside the month", () => { const html = render( - undefined} - onEditLifeEvent={null} + ( + undefined} + onEditLifeEvent={null} + /> + )} />, ); - expect(html).toContain("129/82 mmHg · 82,6 kg"); + expect(text(html)).toContain("129/82 mmHg · 82,6 kg"); }); it("names a quarter once, at its newest month, with its span", () => { @@ -136,17 +186,23 @@ describe("chronicle", () => { ], }; const html = render( - undefined} - onEditLifeEvent={null} + ( + undefined} + onEditLifeEvent={null} + /> + )} />, ); expect(html.match(/data-slot="timeline-chronicle-means"/g)).toHaveLength(1); - expect(html).toContain("Jan bis März 2026: 82,6 kg"); + expect(text(html)).toContain("Jan bis März 2026: 82,6 kg"); }); }); @@ -157,18 +213,26 @@ const LABELS: Record = { }; const seriesLabel = (key: string) => LABELS[key] ?? key; -describe("selection bar", () => { - const bar = (selected: string) => - render( - undefined} - />, - ); +/** The selection bar with the real value format. */ +const bar = (selected: string, timeline = fullTimeline(), showHint = false) => + render( + ( + undefined} + /> + )} + />, + ); +describe("selection bar", () => { it("lists the month's entries and offers the selected day", () => { const html = bar("2026-01-03"); expect(html).toContain("Januar 2026"); @@ -180,18 +244,18 @@ describe("selection bar", () => { }); it("names the bucket's means with the readings behind each", () => { - expect(bar("2026-01-03")).toContain( - "Ø Januar 2026: Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)", + expect(text(bar("2026-01-03"))).toContain( + "Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)", ); - expect(bar("2026-07-15")).toContain( - "Ø Juli 2026: Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert", + expect(text(bar("2026-07-15"))).toContain( + "Ø Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert", ); }); it("says a missing month has no value instead of interpolating one", () => { const html = bar("2026-02-10"); - expect(html).toContain( - "Ø Februar 2026: Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert", + expect(text(html)).toContain( + "Ø Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert", ); // January's 129 and March's 133 would make 131 between them. expect(html).not.toMatch(/\d mmHg/); @@ -201,16 +265,22 @@ describe("selection bar", () => { describe("value table for a screen reader", () => { it("lists every month between the first and the last reading, gaps as gaps", () => { const html = render( - undefined} - onOpenDay={() => undefined} + ( + undefined} + onOpenDay={() => undefined} + /> + )} />, ); const table = html.slice(html.indexOf('data-slot="timeline-series-table"')); @@ -228,6 +298,143 @@ describe("value table for a screen reader", () => { }); }); +describe("selection bar head and hint", () => { + it("names the bucket outright, for each bucket size", () => { + const de = (start: string, bucket: "quarter" | "month" | "week") => + text( + render( + bucketTitle(start, bucket, "de-DE", t)} />, + ), + ); + expect(de("2026-07-01", "quarter")).toBe("Juli bis September 2026"); + expect(de("2026-09-01", "month")).toBe("September 2026"); + expect(de("2026-09-21", "week")).toBe("Woche vom 21. September 2026"); + }); + + it("heads the bar with the selected day's bucket and lists its entries", () => { + const quarter = { ...fullTimeline(), bucket: "quarter" as const }; + const html = bar("2026-01-03", quarter); + expect(html).toContain('data-period-from="2026-01-01"'); + expect(html).toContain('data-period-to="2026-03-31"'); + expect(text(html)).toContain("Januar bis März 2026"); + expect(text(html)).toContain("Erkältung · 31. Dez. bis 8. Jan."); + }); + + it("says how to pick a day only until one is picked", () => { + expect(bar("2026-01-03", fullTimeline(), true)).toContain( + 'data-slot="timeline-selection-hint"', + ); + expect(text(bar("2026-01-03", fullTimeline(), true))).toContain( + "Klicke auf einen Zeitpunkt, um ihn hier zu sehen", + ); + expect(bar("2026-01-03")).not.toContain("timeline-selection-hint"); + }); + + it("reads sleep as a duration, not as minutes", () => { + const timeline = { + ...fullTimeline(), + series: [ + { + key: "SLEEP_DURATION", + unit: "minutes", + points: [{ t: "2026-01-01", mean: 556, count: 31 }], + }, + ], + }; + const html = text(bar("2026-01-03", timeline)); + expect(html).toContain("9 Std. 16 Min. (Schnitt aus 31 Messungen)"); + expect(html).not.toContain("556"); + }); +}); + +describe("value line colours", () => { + const layout = () => + layoutTimeline({ + width: 900, + window: { from: "2025-10-01", to: "2026-10-31" }, + lanes: [], + series: fullTimeline().series, + bucket: "month", + startMissing: "", + today: TODAY, + }); + + it("draws a line's swatch, path, bridge and points in one colour", () => { + const html = render( + ( + + key} + fmt={format} + /> + + )} + />, + ); + const group = (key: string) => { + const start = html.indexOf(`data-series="${key}"`); + const end = html.indexOf("", start); + return html.slice(start, end); + }; + const colours = new Set(); + for (const key of ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"]) { + const g = group(key); + const colour = seriesColor(key); + colours.add(colour); + // The group sets the colour once; every mark inherits it. + expect(g).toContain(`color="${colour}"`); + expect(g).toContain('data-slot="timeline-series-swatch"'); + const strokes = [...g.matchAll(/stroke="([^"]+)"/g)].map((m) => m[1]); + expect(strokes.length).toBeGreaterThan(0); + expect(new Set(strokes)).toEqual(new Set(["currentColor"])); + const fills = [...g.matchAll(/]*fill="([^"]+)"/g)].map( + (m) => m[1], + ); + for (const fill of fills) { + expect(["currentColor", "var(--card)", "none"]).toContain(fill); + } + } + // The systolic line has its bridge and its hollow point in that colour. + expect(group("BLOOD_PRESSURE_SYS")).toContain( + 'data-slot="timeline-series-bridge"', + ); + expect(group("BLOOD_PRESSURE_SYS")).toContain('data-thin="true"'); + // Its January point is ringed for the selection. + expect(group("BLOOD_PRESSURE_SYS")).toContain( + 'data-slot="timeline-series-selected"', + ); + // Three lines, three colours. + expect(colours.size).toBe(3); + }); + + it("marks each mean with its line's colour", () => { + const html = render( + , + ); + for (const key of ["BLOOD_PRESSURE_SYS", "WEIGHT"]) { + const part = html.slice(html.indexOf(`data-series="${key}"`)); + expect(part.slice(0, part.indexOf(""))).toContain( + `style="background:${seriesColor(key)}"`, + ); + } + expect(text(html)).toBe("129/82 mmHg · 82,6 kg"); + }); +}); + describe("life events in the capture picker", () => { const own = { inSharedRecord: false, canWriteDomain: () => true }; it("is offered only with the timeline module on", () => { diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 6bd8cc4cf..d011716ae 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -13,7 +13,8 @@ import { fitText, gridTicks, itemLabel, - itemsInMonth, + itemsInPeriod, + latestDataDate, layoutLane, layoutSeries, layoutTimeline, @@ -512,7 +513,12 @@ describe("interaction", () => { }); it("names what happens, starts, ends or runs through the month, not the backdrop", () => { - const entries = itemsInMonth(fullTimeline().lanes, "2026-01-03", TODAY); + const entries = itemsInPeriod( + fullTimeline().lanes, + "2026-01-01", + "2026-01-31", + TODAY, + ); const ids = entries.map((e) => e.item.id); expect(ids).toEqual(["ill-5", "course-3", "v-4"]); // The winter course neither starts nor ends in January: it runs through. @@ -522,7 +528,50 @@ describe("interaction", () => { expect(ids).not.toContain("ill-chronic"); expect(ids).not.toContain("al-1"); // An open period that starts in the month is named. - const march2019 = itemsInMonth(fullTimeline().lanes, "2019-03-15", TODAY); + const march2019 = itemsInPeriod( + fullTimeline().lanes, + "2019-03-01", + "2019-03-31", + TODAY, + ); expect(march2019.map((e) => e.item.id)).toContain("ill-chronic"); }); + + it("selects the newest bucket the bar has something for, never a document-only one", () => { + const timeline = fullTimeline(); + const all = timeline.range; + // The newest value (July's systolic mean) beats the older entries. + expect(latestDataDate(timeline, TODAY, all)).toBe("2026-07-01"); + // A later document does not count: the bar does not list documents, + // so selecting its month would open on "No entries". + const withDocument = { + ...timeline, + lanes: timeline.lanes.map((lane) => + lane.key === "documents" + ? { + ...lane, + items: [ + ...lane.items, + item({ id: "doc-late", kind: "document", start: "2026-08-20" }), + ], + } + : lane, + ), + }; + expect(latestDataDate(withDocument, TODAY, all)).toBe("2026-07-01"); + expect( + latestDataDate(withDocument, TODAY, { + from: "2026-08-01", + to: "2026-10-31", + }), + ).toBeNull(); + // Inside a window, a closed period running past its end counts up to + // the window's last day, and whatever is selected the bar names it. + const window = { from: "2025-11-01", to: "2026-01-31" }; + const day = latestDataDate(timeline, TODAY, window)!; + expect(day).toBe("2026-01-31"); + expect( + itemsInPeriod(timeline.lanes, "2026-01-01", "2026-01-31", TODAY).length, + ).toBeGreaterThan(0); + }); }); diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 901e6032e..62c2bdfe1 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -1,11 +1,14 @@ "use client"; /** - * The bar under the chart (v1.42, #613): the selected month, every entry - * that touches it as a chip (each one a way into its day), the means of the - * chosen values in the bucket that holds the selected day (with the readings - * behind each, and "no value" where there is none), and "Open 3 Jan." for - * the selected day. + * The bar under the chart (v1.42, #613): a cross-section of one bucket, the + * one that holds the selected day. Its head names the bucket outright ("July + * to September 2026", "September 2026", "Week of 22 September 2026"), so a + * value that changes with the zoom says why: it is the mean of a different + * stretch. Then every entry that touches the bucket as a chip (each one a + * way into its day), the means of the chosen values with the readings behind + * each ("no value" where there is none), and "Open 3 Jan." for the selected + * day. Until someone picks a day, a short line says how to. * * It is also where a label the chart had to leave out is read in full, and * where each keyboard step on the chart is announced (`aria-live`). @@ -14,25 +17,40 @@ import { ArrowRight } from "lucide-react"; import type { TimelineResponse } from "@/lib/day/contract"; import { resolveIntlLocale } from "@/lib/format-locale"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { - endOfMonth, + bucketAfter, + bucketStart, + dayKey, + dayNumber, formatAtPrecision, formatDayMonth, + formatDayMonthYearLong, + formatMonthLong, formatMonthYear, - monthOf, - startOfMonth, } from "./timeline-dates"; -import { itemsInMonth } from "./timeline-geometry"; -import { bucketText, laneDotStyle } from "./timeline-chart"; -import { bucketValues, formatLabelledMeans } from "./series-format"; +import { itemsInPeriod } from "./timeline-geometry"; +import { MeanPartsLine, laneDotStyle } from "./timeline-chart"; +import { + bucketValues, + labelledMeanParts, + type SeriesValueFormat, +} from "./series-format"; + +const VALUES_MARK = "\u0000"; export interface SelectionBarProps { timeline: TimelineResponse; selected: string; + /** Nobody has picked a day yet: the bar says how to. */ + showHint?: boolean; today: string; seriesLabel: (key: string) => string; + /** The colour of each value line, as on the chart (`series-colors.ts`). */ + seriesColor: (key: string) => string; + /** How a value reads (`useSeriesValueFormat`). */ + seriesFormat: SeriesValueFormat; onOpenDay: (date: string) => void; onEditLifeEvent?: (id: string) => void; } @@ -40,39 +58,71 @@ export interface SelectionBarProps { export function SelectionBar({ timeline, selected, + showHint = false, today, seriesLabel, + seriesColor, + seriesFormat, onOpenDay, onEditLifeEvent, }: SelectionBarProps) { const { t, tCount, locale } = useTranslations(); - const fmt = useFormatters(); const intl = resolveIntlLocale(locale); - const entries = itemsInMonth(timeline.lanes, selected, today); + const bucket = timeline.bucket; + const periodFrom = bucketStart(selected, bucket); + const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1); + const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today); const means = bucketValues(timeline.series, timeline.bucket, selected); - const monthFrom = startOfMonth(selected); - const monthTo = endOfMonth(selected); + // "Ø {values}": the words around the values, so the values can carry + // their colour dots. + const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", { + values: VALUES_MARK, + }).split(VALUES_MARK); return (

- - {formatMonthYear(selected, intl)} + {showHint && ( + // One line, worded for the input in hand: a pointer clicks, a + // finger taps. +

+ + {t("timeline.selection.hintClick")} + + + {t("timeline.selection.hintTap")} + +

+ )} + + {bucketTitle(periodFrom, bucket, intl, t)} {entries.length === 0 && ( - + {t("timeline.selection.empty")} )} {entries.map(({ lane, item, through }) => { - // A chip opens the day its entry touches inside this month. + // A chip opens the day its entry touches inside this period. const day = - item.start >= monthFrom && item.start <= monthTo + item.start >= periodFrom && item.start <= periodTo ? item.start - : monthFrom; + : periodFrom; const canOpenDay = item.precision === "DAY" && day <= today; const when = item.open || through @@ -128,18 +178,17 @@ export function SelectionBar({ data-slot="timeline-selection-means" data-bucket={means.start} > - {t("timeline.selection.mean", { - values: `${bucketText(means.start, timeline.bucket, intl, t)}: ${formatLabelledMeans( - means.values, - fmt, - { - label: seriesLabel, - bloodPressure: t("timeline.values.bloodPressure"), - noValue: t("timeline.values.noValue"), - readings: (n) => tCount("timeline.values.readings", n), - }, - )}`, - })} + {meanBefore} + tCount("timeline.values.readings", n), + })} + seriesColor={seriesColor} + /> + {meanAfter} )}
@@ -268,9 +283,10 @@ interface ChartBodyProps { dataFrom: string | null; bucket: TimelineBucket; seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; intl: string; t: ReturnType["t"]; - fmt: ReturnType; + fmt: SeriesValueFormat; } function ChartBody({ @@ -282,11 +298,11 @@ function ChartBody({ dataFrom, bucket, seriesLabel, + seriesColor, intl, t, fmt, }: ChartBodyProps) { - const { tCount } = useTranslations(); const { scale, height, width } = layout; const todayInWindow = today >= window.from && today <= window.to; const todayX = scale.xMid(today); @@ -303,7 +319,12 @@ function ChartBody({ dataFrom && dataFrom > window.from && dataFrom <= window.to ? scale.x(dataFrom) : null; - const seriesTop = layout.seriesTop; + // The bucket that holds the selected day: its point on every line is + // ringed in the line's colour, so the selection reads on each scale. + const selectedBucket = + selected && selected >= window.from && selected <= window.to + ? bucketStart(selected, bucket) + : null; return ( @@ -434,81 +455,20 @@ function ChartBody({ ); })} - {/* Value lines: neutral, one scale each, never coloured by meaning. */} - {layout.series.length > 0 && ( - - )} - {layout.series.map((series) => ( - - - {fitText(seriesLabel(series.key), scale.x0 - 12, 13)} - - - {series.latest === null - ? t("timeline.values.noneInWindow") - : formatSeriesValue(series.key, series.latest, series.unit, fmt)} - - {series.path && ( - - )} - {series.bridges && ( - - )} - {series.points.map((point) => ( - - - {`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} - - - ))} - - ))} + {/* Value lines: one scale each, each in its own colour (the colour + its type carries in the measurement list). The colour names the + line, never a judgement of its values. */} + {/* Today. */} {todayInWindow && ( @@ -559,6 +519,157 @@ function ChartBody({ ); } +/** + * The value lines under the lanes, each in its own colour: the swatch beside + * the name, the line, the dashed bridge over a gap, the points (hollow where + * a mean rests on one or two readings) and the ring on the selected bucket. + * Every mark of a line inherits one `color` from its group, so they cannot + * drift apart. + */ +export function SeriesLines({ + layout, + width, + selectedBucket, + bucket, + seriesLabel, + seriesColor, + intl, + t, + fmt, +}: { + layout: NonNullable>; + width: number; + selectedBucket: string | null; + bucket: TimelineBucket; + seriesLabel: (key: string) => string; + seriesColor: (key: string) => string; + intl: string; + t: ReturnType["t"]; + fmt: SeriesValueFormat; +}) { + const { tCount } = useTranslations(); + const { scale, seriesTop } = layout; + return ( + <> + {layout.series.length > 0 && ( + + )} + {layout.series.map((series) => { + const color = seriesColor(series.key); + return ( + + + + {fitText( + seriesLabel(series.key), + scale.x0 - 12 - SERIES_TEXT_X, + 13, + )} + + + {series.latest === null + ? t("timeline.values.noneInWindow") + : formatSeriesValue( + series.key, + series.latest, + series.unit, + fmt, + )} + + {series.path && ( + + )} + {series.bridges && ( + + )} + {series.points.map((point) => ( + + + {`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} + + + ))} + {series.points + .filter((point) => point.t === selectedBucket) + .map((point) => ( + + ))} + + ); + })} + + ); +} + function spanTitle( span: PlacedSpan, intl: string, @@ -821,15 +932,16 @@ function SeriesTable({ timeline, window, seriesLabel, + fmt, intl, }: { timeline: TimelineResponse; window: { from: string; to: string }; seriesLabel: (key: string) => string; + fmt: SeriesValueFormat; intl: string; }) { const { t, tCount } = useTranslations(); - const fmt = useFormatters(); const { bucket, series } = timeline; const inWindow = (start: string) => start <= window.to && bucketAfter(start, bucket) > window.from; @@ -883,6 +995,31 @@ function SeriesTable({ ); } +/** + * A means line with each value marked by its line's colour: "● 129/82 mmHg + * · ● 82,6 kg". The dot is decoration; the text reads the same without it. + */ +export function MeanPartsLine({ + parts, + seriesColor, +}: { + parts: readonly MeanPart[]; + seriesColor: (key: string) => string; +}) { + return parts.map((part, i) => ( + + {i > 0 ? " · " : null} + + )); +} + /** Inline style for a lane colour dot in HTML (chips, chronicle rails). */ export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties { return { background: LANE_COLOR[lane] }; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 42ed5aab4..9e453240b 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -30,7 +30,7 @@ import { import { Card } from "@/components/ui/card"; import type { TimelineItemKind, TimelineResponse } from "@/lib/day/contract"; import { resolveIntlLocale } from "@/lib/format-locale"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { @@ -48,8 +48,12 @@ import { formatMonthYear, } from "./timeline-dates"; import { LANE_COLOR } from "./timeline-geometry"; -import { bucketText, laneDotStyle } from "./timeline-chart"; -import { chronicleMeans, formatMeans } from "./series-format"; +import { MeanPartsLine, bucketText, laneDotStyle } from "./timeline-chart"; +import { + chronicleMeans, + meanParts, + type SeriesValueFormat, +} from "./series-format"; const KIND_ICON: Partial> = { lifeEvent: Flag, @@ -125,6 +129,8 @@ export function TimelineChronicle({ today, grouping, selected, + seriesColor, + seriesFormat, onOpenDay, onEditLifeEvent, }: { @@ -132,11 +138,14 @@ export function TimelineChronicle({ today: string; grouping: ChronicleGrouping; selected: string | null; + /** The colour of each value line, as on the chart (`series-colors.ts`). */ + seriesColor: (key: string) => string; + /** How a value reads (`useSeriesValueFormat`). */ + seriesFormat: SeriesValueFormat; onOpenDay: (date: string) => void; onEditLifeEvent: ((id: string) => void) | null; }) { const { t, tCount, locale } = useTranslations(); - const fmt = useFormatters(); const intl = resolveIntlLocale(locale); const [standingOpen, setStandingOpen] = useState(true); const chips = standingChips(timeline); @@ -255,9 +264,9 @@ export function TimelineChronicle({ } if (row.type === "header") { const bucketMeans = means.get(row.group); - const line = bucketMeans - ? formatMeans(bucketMeans.values, fmt) - : ""; + const parts = bucketMeans + ? meanParts(bucketMeans.values, seriesFormat) + : []; return (
  • {groupLabel(row.group)} - {bucketMeans && line && ( + {bucketMeans && parts.length > 0 && ( {timeline.bucket === "month" - ? line - : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`} + ? null + : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: `} + )}
  • diff --git a/src/components/timeline/timeline-dates.ts b/src/components/timeline/timeline-dates.ts index 9869875c8..7df91b788 100644 --- a/src/components/timeline/timeline-dates.ts +++ b/src/components/timeline/timeline-dates.ts @@ -158,6 +158,18 @@ export function formatDayMonthYear(key: string, intlLocale: string): string { }); } +/** "3. Januar 2026". */ +export function formatDayMonthYearLong( + key: string, + intlLocale: string, +): string { + return format(key, intlLocale, { + day: "numeric", + month: "long", + year: "numeric", + }); +} + /** "Jan". Axis labels. */ export function formatMonthShort(key: string, intlLocale: string): string { return format(key, intlLocale, { month: "short" }); diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 3195e221d..f43a92076 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -929,20 +929,20 @@ export function stepSelection( } /** - * What the selection bar names for the month of `selected`: everything that - * happens, starts or ends in it, and every closed period that runs through - * it. A period that has been open since before the month (a chronic - * condition, a standing medication, an allergy) is the backdrop of every - * month and is left out, as are documents, which the day lists with their - * visit. Lane order kept. + * What the selection bar names for the period `[from, to]` (the bucket that + * holds the selected day): everything that happens, starts or ends in it, + * and every closed period that runs through it. A period that has been open + * since before the period (a chronic condition, a standing medication, an + * allergy) is the backdrop of every period and is left out, as are + * documents, which the day lists with their visit. Lane order kept. The + * same rule holds for a week, a month and a quarter. */ -export function itemsInMonth( +export function itemsInPeriod( lanes: readonly TimelineLane[], - selected: string, + from: string, + to: string, today: string, ): Array<{ lane: TimelineLaneKey; item: TimelineItem; through: boolean }> { - const from = startOfMonth(selected); - const to = endOfMonth(selected); const out: Array<{ lane: TimelineLaneKey; item: TimelineItem; @@ -963,3 +963,45 @@ export function itemsInMonth( } return out; } + +/** + * The day to select before anyone has picked one: the latest day inside + * `window` (and not after today) that the selection bar has something for, + * so the bar opens on the newest bucket with data, never on an empty one. + * A lane entry counts on the last day `itemsInPeriod` would name it (an open + * period on its start, a closed one on its end); a value line counts on the + * first day of its newest bucket. Null when the window holds nothing. + */ +export function latestDataDate( + timeline: { + lanes: readonly TimelineLane[]; + series: readonly TimelineSeries[]; + }, + today: string, + window: TimeWindow, +): string | null { + const last = window.to < today ? window.to : today; + let best: string | null = null; + const consider = (day: string) => { + if (day < window.from || day > last) return; + if (!best || day > best) best = day; + }; + for (const lane of timeline.lanes) { + if (lane.key === "documents") continue; + for (const item of lane.items) { + if (item.start > last) continue; + if (item.open) { + consider(item.start); + continue; + } + const end = item.end ?? item.start; + // A closed period still running past the window's end is named in + // every bucket up to it; its last named day here is the window's end. + consider(end > last ? last : end); + } + } + for (const series of timeline.series) { + for (const point of series.points) consider(point.t); + } + return best; +} diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index c4878a0d4..b94cdd571 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -27,11 +27,14 @@ export function ValueSeriesMenu({ options, selected, label, + color, onChange, }: { options: readonly string[]; selected: readonly string[]; label: (key: string) => string; + /** A chosen line's colour, shown as a dot beside its name. */ + color: (key: string) => string; onChange: (next: string[]) => void; }) { const { t } = useTranslations(); @@ -72,6 +75,14 @@ export function ValueSeriesMenu({ ) } > + {/* The dot is the line's colour on the chart; an unchosen + value has no line yet, so its place stays empty. */} +
    + {rangeControls} - {layers(true)} +
    + + {layers(true)} +
    + {rangeControls} > = { quarter: "timeline.legendMeanQuarter", month: "timeline.legendMeanMonth", week: "timeline.legendMeanWeek", + day: "timeline.legendMeanDay", }; function Legend({ diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index ffb3ee430..505bcb7aa 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -211,17 +211,29 @@ export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number]; /* ─── Timeline ────────────────────────────────────────────────────────────── */ -/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */ -export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const; +/** + * Zoom levels. `all` bundles hardest: no symptoms, no single intakes. + * `range` is a stretch the person chose, named by `from` and `to` (both + * required, at most {@link TIMELINE_RANGE_MAX_DAYS} days). + */ +export const TIMELINE_ZOOMS = ["all", "year", "quarter", "range"] as const; export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number]; /** * The span one value-series point averages. The server picks it per zoom * (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a - * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`. + * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`, + * and for a `range` by its length, down to single days under six weeks. */ -export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const; +export const TIMELINE_BUCKETS = ["quarter", "month", "week", "day"] as const; + +/** + * The longest `range` the timeline answers: fifteen years. Longer than any + * fixed zoom reads, and the series of such a window come from the rollup + * tier in quarters, so the cost stays that of an `all` view. + */ +export const TIMELINE_RANGE_MAX_DAYS = 15 * 366; export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number]; diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 0bbc1892f..a3dbd24eb 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -28,6 +28,7 @@ import { TIMELINE_READINESS_KEYS, TIMELINE_READINESS_STATUSES, TIMELINE_READINESS_VERDICTS, + TIMELINE_RANGE_MAX_DAYS, TIMELINE_ZOOMS, } from "./contract"; @@ -266,17 +267,43 @@ export const dayNotableResponseSchema = z /* ─── GET /api/timeline ───────────────────────────────────────────────────── */ -export const timelineQuerySchema = z.object({ - zoom: z.enum(TIMELINE_ZOOMS).default("all"), - from: dateKeySchema.optional(), - to: dateKeySchema.optional(), - values: z - .string() - .optional() - .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", - ), -}); +export const timelineQuerySchema = z + .object({ + zoom: z + .enum(TIMELINE_ZOOMS) + .default("all") + .describe( + "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.", + ), + from: dateKeySchema.optional(), + to: dateKeySchema.optional(), + values: z + .string() + .optional() + .describe( + "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", + ), + }) + .refine((q) => q.zoom !== "range" || (q.from && q.to), { + message: "`range` needs both `from` and `to`", + path: ["from"], + }) + .refine( + (q) => + q.zoom !== "range" || + !q.from || + !q.to || + // Both ends as the same instant of their calendar day, so the + // difference counts calendar days whatever the process zone. + (dateOnlyAtNoonUtc(q.to).getTime() - + dateOnlyAtNoonUtc(q.from).getTime()) / + 86_400_000 < + TIMELINE_RANGE_MAX_DAYS, + { + message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`, + path: ["to"], + }, + ); const timelineItem = z .object({ @@ -357,7 +384,7 @@ export const timelineResponseSchema = z bucket: z .enum(TIMELINE_BUCKETS) .describe( - "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`.", + "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.", ), series: z.array(timelineSeries), notable: z.array( diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index b9aa6d4c4..f3b985d54 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from "vitest"; -import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline"; +import { + overlaps, + parseSeriesKeys, + rangeBucket, + timelineBucket, +} from "../load-timeline"; import { coveredWeeks, READINESS_WEEKS } from "../readiness"; import { bucketKey, foldDays, foldMonths } from "../series"; @@ -72,6 +77,24 @@ describe("buckets", () => { expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month"); expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter"); }); + + it("picks a chosen range's bucket by its length, down to days", () => { + // Two years and more: quarters. + expect(rangeBucket("2024-03-31", "2026-03-30")).toBe("quarter"); + expect(rangeBucket("2024-04-01", "2026-03-30")).toBe("month"); + // Four months (120 days) and more: months. + expect(rangeBucket("2026-01-01", "2026-04-30")).toBe("month"); + expect(rangeBucket("2026-01-02", "2026-04-30")).toBe("week"); + // Six weeks and more: weeks. + expect(rangeBucket("2026-03-01", "2026-04-11")).toBe("week"); + expect(rangeBucket("2026-03-02", "2026-04-11")).toBe("day"); + expect(rangeBucket("2026-03-01", "2026-03-01")).toBe("day"); + expect(timelineBucket("range", "2026-03-01", "2026-03-14")).toBe("day"); + }); + + it("names a day bucket by the day itself", () => { + expect(bucketKey("2026-03-05", "day")).toBe("2026-03-05"); + }); }); describe("window", () => { diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index bc97b723d..a24ef1a97 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -7,7 +7,8 @@ * read only the window. * * Windows by zoom, when the request names none: `quarter` the last 91 days, - * `year` the last 365, `all` from the earliest date the record carries. The + * `year` the last 365, `all` from the earliest date the record carries. A + * `range` always names its own (the query schema requires both ends). The * notable days of an `all` window are computed over its last three years at * most: an extreme is "the highest for at least three months", and older * history is what the comparison reads, not what it marks. @@ -48,6 +49,29 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ /** An `all` window longer than this many days averages quarters. */ const QUARTER_BUCKET_MIN_DAYS = 2 * 365; +/** + * The bucket for a chosen range, by its length: about the point count the + * fixed zooms draw (a dozen to a few dozen), never one point per reading. + * Two years and more average quarters (eight and up), four months and more + * months (four to twenty-four), six weeks and more weeks (six to + * seventeen), and anything shorter single days (at most six weeks of them). + */ +export const RANGE_BUCKET_MIN_DAYS: Readonly< + Record, number> +> = { + quarter: 2 * 365, + month: 120, + week: 42, +}; + +export function rangeBucket(from: string, to: string): TimelineBucket { + const days = daysBetweenDateKeys(from, to) + 1; + if (days >= RANGE_BUCKET_MIN_DAYS.quarter) return "quarter"; + if (days >= RANGE_BUCKET_MIN_DAYS.month) return "month"; + if (days >= RANGE_BUCKET_MIN_DAYS.week) return "week"; + return "day"; +} + /** * The span one series point averages. A point per day or per week over * years drew a line of fragments wherever readings came in bursts, so the @@ -61,13 +85,16 @@ export function timelineBucket( from: string, to: string, ): TimelineBucket { + if (zoom === "range") return rangeBucket(from, to); if (zoom === "quarter") return "week"; if (zoom === "year") return "month"; const days = daysBetweenDateKeys(from, to); return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month"; } -const ZOOM_DAYS: Readonly, number>> = { +const ZOOM_DAYS: Readonly< + Record, number> +> = { year: 365, quarter: 91, }; @@ -144,7 +171,7 @@ export async function loadTimeline(args: { const to = query.to ?? today; const from = query.from ?? - (query.zoom === "all" + (query.zoom === "all" || query.zoom === "range" ? dataFrom !== null && dataFrom < to ? dataFrom : shiftDateKey(to, -364) diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 9dd6a56b7..4a6be0a76 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -61,6 +61,7 @@ export const MOOD_SERIES_KEY = "MOOD"; /** The bucket a local day belongs to, named by its first day. */ export function bucketKey(day: string, bucket: TimelineBucket): string { + if (bucket === "day") return day; if (bucket === "month") return `${day.slice(0, 7)}-01`; if (bucket === "quarter") { const month = Number(day.slice(5, 7)); @@ -110,7 +111,7 @@ export interface RolledMonth { */ export function foldMonths( months: readonly RolledMonth[], - bucket: Exclude, + bucket: Exclude, ): SeriesPoint[] { const buckets = new Map< string, @@ -230,7 +231,13 @@ async function measurementSeries(args: { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); let points: SeriesPoint[]; - if (args.bucket !== "week" && readsRollups(args.from, args.to)) { + // Weeks and days never read the rollups: their windows are short (a + // `day` range is under six weeks), and the rollups cut at UTC midnight. + if ( + args.bucket !== "week" && + args.bucket !== "day" && + readsRollups(args.from, args.to) + ) { const bucket = args.bucket; // The fold boundary rounded up to a bucket start, so no bucket is half // live and half rolled up. diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 4bbb9a28f..bac08bd06 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -932,6 +932,43 @@ describe("timeline", () => { ["2026-03-02", 1], ]); + // A chosen range under six weeks averages single days; the two + // readings of 10 January stay one day. + const daily = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=range&from=2026-01-05&to=2026-01-25&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(daily.zoom).toBe("range"); + expect(daily.bucket).toBe("day"); + expect(daily.range).toMatchObject({ from: "2026-01-05", to: "2026-01-25" }); + expect(daily.series[0].points).toEqual([ + { t: "2026-01-10", mean: 125, count: 2 }, + { t: "2026-01-20", mean: 135, count: 1 }, + ]); + // Four months and more: months, as the fixed zooms draw them. + const monthly = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=range&from=2025-12-01&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(monthly.bucket).toBe("month"); + // A range needs both ends, and spans fifteen years at most. + for (const query of [ + "zoom=range", + "zoom=range&from=2026-01-01", + "zoom=range&from=2000-01-01&to=2026-01-01", + ]) { + expect( + (await call(GET as Handler, "GET", `/api/timeline?${query}`)).status, + query, + ).toBe(422); + } + const six = "BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD"; expect( From 5f5ddd37a63dc1c621c4b4f0ad27ba1f8f6046ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 10:05:06 +0200 Subject: [PATCH 4/5] fix(timeline): keep "No entries" for a bucket with nothing in it A bucket that holds a mean but no lane entry is not empty; the means say what it holds. The bar opened on such a bucket by default and called it empty beside its own values. --- .../timeline/__tests__/timeline-components.test.tsx | 9 +++++++++ src/components/timeline/selection-bar.tsx | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 67b709c38..5a1ffdfb8 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -320,6 +320,15 @@ describe("selection bar head and hint", () => { expect(text(html)).toContain("Erkältung · 31. Dez. bis 8. Jan."); }); + it("calls a bucket empty only when it holds neither an entry nor a value", () => { + // July 2026: no entry, but a systolic mean. + expect(bar("2026-07-15")).not.toContain("timeline-selection-empty"); + // May 2026: no entry, no value. + expect(text(bar("2026-05-10"))).toContain( + "Keine Einträge in diesem Zeitraum", + ); + }); + it("says how to pick a day only until one is picked", () => { expect(bar("2026-01-03", fullTimeline(), true)).toContain( 'data-slot="timeline-selection-hint"', diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 4b2b7cc73..de5bbeaf0 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -73,6 +73,9 @@ export function SelectionBar({ const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1); const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today); const means = bucketValues(timeline.series, timeline.bucket, selected); + // A bucket with a value but no entry is not empty: the means below say + // what it holds, so "No entries" is kept for a stretch with nothing. + const hasValue = means.values.some((v) => v.mean !== null); // "Ø {values}": the words around the values, so the values can carry // their colour dots. const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", { @@ -109,7 +112,7 @@ export function SelectionBar({ > {bucketTitle(periodFrom, bucket, intl, t)} - {entries.length === 0 && ( + {entries.length === 0 && !hasValue && ( Date: Fri, 9 Oct 2026 10:08:54 +0200 Subject: [PATCH 5/5] test(timeline): wait for the values menu before reading its dots --- e2e/timeline.spec.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 7c0a57534..857d6e83f 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -258,14 +258,22 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin for (const stroke of marks) expect(stroke).toBe(colour); } // The menu marks each chosen line with the same colour. - await page.locator('[data-slot="timeline-values-trigger"]').click(); - const dots = await page - .locator( - '[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]', - ) - .evaluateAll((els) => - els.map((el) => getComputedStyle(el).backgroundColor), - ); + const trigger = page.locator('[data-slot="timeline-values-trigger"]'); + await expect(async () => { + if ((await trigger.getAttribute("data-state")) !== "open") { + await trigger.click(); + } + await expect(trigger).toHaveAttribute("data-state", "open", { + timeout: 1_000, + }); + }).toPass(); + const chosenDots = page.locator( + '[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]', + ); + await expect(chosenDots).toHaveCount(6); + const dots = await chosenDots.evaluateAll((els) => + els.map((el) => getComputedStyle(el).backgroundColor), + ); expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour))); await page.keyboard.press("Escape"); });