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. */} +