mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
f788e2892d
commit
f8557f57a0
24 files changed
+1244
-417
No files matched your search
@@ -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(
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
+5
-2
@@ -12448,10 +12448,13 @@
|
||||
"period": "기간"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "이번 달 기록 없음",
|
||||
"empty": "이 기간에는 항목이 없습니다",
|
||||
"ongoing": "진행 중",
|
||||
"range": "{from}~{to}",
|
||||
"mean": "Ø {values}"
|
||||
"mean": "Ø {values}",
|
||||
"week": "{date} 주",
|
||||
"hintClick": "시점을 클릭하면 여기에 표시됩니다",
|
||||
"hintTap": "시점을 탭하면 여기에 표시됩니다"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "월",
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -134,5 +134,5 @@ export function useDayValueFormat() {
|
||||
[labelFor, number, unitFor],
|
||||
);
|
||||
|
||||
return { labelFor, formatTile };
|
||||
return { labelFor, formatTile, number, unitFor };
|
||||
}
|
||||
@@ -269,132 +269,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
WAIST_TO_HEIGHT: Scale,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<typeof useTranslations>["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(<ReadinessInventory readiness={readiness()} />);
|
||||
@@ -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(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected="2026-01-03"
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected="2026-01-03"
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => 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(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => 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(
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => 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<string, string> = {
|
||||
};
|
||||
const seriesLabel = (key: string) => LABELS[key] ?? key;
|
||||
|
||||
describe("selection bar", () => {
|
||||
const bar = (selected: string) =>
|
||||
render(
|
||||
<SelectionBar
|
||||
timeline={fullTimeline()}
|
||||
selected={selected}
|
||||
today={TODAY}
|
||||
seriesLabel={seriesLabel}
|
||||
onOpenDay={() => undefined}
|
||||
/>,
|
||||
);
|
||||
/** The selection bar with the real value format. */
|
||||
const bar = (selected: string, timeline = fullTimeline(), showHint = false) =>
|
||||
render(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<SelectionBar
|
||||
timeline={timeline}
|
||||
selected={selected}
|
||||
showHint={showHint}
|
||||
today={TODAY}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => 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(
|
||||
<TimelineChart
|
||||
timeline={fullTimeline()}
|
||||
window={{ from: "2025-10-01", to: "2026-10-31" }}
|
||||
zoom="year"
|
||||
today={TODAY}
|
||||
selected={null}
|
||||
hiddenLanes={new Set()}
|
||||
seriesLabel={seriesLabel}
|
||||
onSelect={() => undefined}
|
||||
onOpenDay={() => undefined}
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChart
|
||||
timeline={fullTimeline()}
|
||||
window={{ from: "2025-10-01", to: "2026-10-31" }}
|
||||
zoom="year"
|
||||
today={TODAY}
|
||||
selected={null}
|
||||
hiddenLanes={new Set()}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={format}
|
||||
onSelect={() => 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(
|
||||
<I18nProbe run={(t) => 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(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<svg>
|
||||
<SeriesLines
|
||||
layout={layout()}
|
||||
width={900}
|
||||
selectedBucket="2026-01-01"
|
||||
bucket="month"
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl="de-DE"
|
||||
t={(key) => key}
|
||||
fmt={format}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
const group = (key: string) => {
|
||||
const start = html.indexOf(`data-series="${key}"`);
|
||||
const end = html.indexOf("</g>", start);
|
||||
return html.slice(start, end);
|
||||
};
|
||||
const colours = new Set<string>();
|
||||
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(/<circle[^>]*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(
|
||||
<MeanPartsLine
|
||||
parts={[
|
||||
{ key: "BLOOD_PRESSURE_SYS", text: "129/82 mmHg" },
|
||||
{ key: "WEIGHT", text: "82,6 kg" },
|
||||
]}
|
||||
seriesColor={seriesColor}
|
||||
/>,
|
||||
);
|
||||
for (const key of ["BLOOD_PRESSURE_SYS", "WEIGHT"]) {
|
||||
const part = html.slice(html.indexOf(`data-series="${key}"`));
|
||||
expect(part.slice(0, part.indexOf("</span>"))).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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-muted/60 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-3 py-2.5"
|
||||
data-slot="timeline-selection-bar"
|
||||
data-month={monthOf(selected)}
|
||||
data-bucket={bucket}
|
||||
data-period-from={periodFrom}
|
||||
data-period-to={periodTo}
|
||||
>
|
||||
<span className="text-sm font-semibold" aria-live="polite">
|
||||
{formatMonthYear(selected, intl)}
|
||||
{showHint && (
|
||||
// One line, worded for the input in hand: a pointer clicks, a
|
||||
// finger taps.
|
||||
<p
|
||||
className="text-muted-foreground basis-full text-xs"
|
||||
data-slot="timeline-selection-hint"
|
||||
>
|
||||
<span className="pointer-coarse:hidden">
|
||||
{t("timeline.selection.hintClick")}
|
||||
</span>
|
||||
<span className="hidden pointer-coarse:inline">
|
||||
{t("timeline.selection.hintTap")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
aria-live="polite"
|
||||
data-slot="timeline-selection-title"
|
||||
>
|
||||
{bucketTitle(periodFrom, bucket, intl, t)}
|
||||
</span>
|
||||
{entries.length === 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="timeline-selection-empty"
|
||||
>
|
||||
{t("timeline.selection.empty")}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
<MeanPartsLine
|
||||
parts={labelledMeanParts(means.values, seriesFormat, {
|
||||
label: seriesLabel,
|
||||
bloodPressure: t("timeline.values.bloodPressure"),
|
||||
noValue: t("timeline.values.noValue"),
|
||||
readings: (n) => tCount("timeline.values.readings", n),
|
||||
})}
|
||||
seriesColor={seriesColor}
|
||||
/>
|
||||
{meanAfter}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -155,3 +204,26 @@ export function SelectionBar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket by name, in full: "July to September 2026", "September 2026",
|
||||
* "Week of 22 September 2026".
|
||||
*/
|
||||
export function bucketTitle(
|
||||
start: string,
|
||||
bucket: TimelineResponse["bucket"],
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (bucket === "month") return formatMonthYear(start, intl);
|
||||
if (bucket === "quarter") {
|
||||
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
|
||||
return t("timeline.selection.range", {
|
||||
from: formatMonthLong(start, intl),
|
||||
to: formatMonthYear(last, intl),
|
||||
});
|
||||
}
|
||||
return t("timeline.selection.week", {
|
||||
date: formatDayMonthYearLong(start, intl),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The colour of each value line on the timeline (v1.42).
|
||||
*
|
||||
* A line takes the colour its measurement type already carries in the list
|
||||
* (`MEASUREMENT_TYPE_COLORS`, a `--chart-N` token), so weight reads in the
|
||||
* same colour on both pages. Where two chosen lines would share a colour
|
||||
* (systolic and diastolic, or resting pulse beside blood pressure) or a type
|
||||
* has none (mood), the later one takes the first `--chart-N` still free.
|
||||
* There are five data tokens and up to six lines; a sixth line with nothing
|
||||
* free left is drawn in `--foreground`, which is still apart from the other
|
||||
* five. The order of `rank` decides who keeps a contested colour, so the
|
||||
* same set of lines is coloured the same way whatever order it was picked
|
||||
* in.
|
||||
*/
|
||||
import { MEASUREMENT_TYPE_COLORS } from "@/components/measurements/measurement-type-colors";
|
||||
|
||||
export const SERIES_PALETTE = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
] as const;
|
||||
|
||||
/** The colour once every data token is taken. */
|
||||
export const SERIES_FALLBACK_COLOR = "var(--foreground)";
|
||||
|
||||
/** The type's own colour, as a `var(--chart-N)` string, or null. */
|
||||
export function preferredSeriesColor(key: string): string | null {
|
||||
const match = /\btext-chart-([1-5])\b/.exec(
|
||||
MEASUREMENT_TYPE_COLORS[key] ?? "",
|
||||
);
|
||||
return match ? `var(--chart-${match[1]})` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One distinct colour per key. `rank` orders who keeps a contested colour
|
||||
* (keys missing from it come after, in their own order).
|
||||
*/
|
||||
export function assignSeriesColors(
|
||||
keys: readonly string[],
|
||||
rank: readonly string[] = [],
|
||||
): Map<string, string> {
|
||||
const position = (key: string) => {
|
||||
const i = rank.indexOf(key);
|
||||
return i === -1 ? rank.length : i;
|
||||
};
|
||||
const ordered = [...new Set(keys)]
|
||||
.map((key, i) => ({ key, i }))
|
||||
.sort((a, b) => position(a.key) - position(b.key) || a.i - b.i)
|
||||
.map(({ key }) => key);
|
||||
|
||||
const out = new Map<string, string>();
|
||||
const taken = new Set<string>();
|
||||
for (const key of ordered) {
|
||||
const own = preferredSeriesColor(key);
|
||||
if (own && !taken.has(own)) {
|
||||
out.set(key, own);
|
||||
taken.add(own);
|
||||
}
|
||||
}
|
||||
for (const key of ordered) {
|
||||
if (out.has(key)) continue;
|
||||
const free = SERIES_PALETTE.find((c) => !taken.has(c));
|
||||
const color = free ?? SERIES_FALLBACK_COLOR;
|
||||
out.set(key, color);
|
||||
taken.add(color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,49 +1,46 @@
|
||||
/**
|
||||
* How a value line's number reads (v1.42, #613): whole numbers for pressure,
|
||||
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
|
||||
* server's, already in the person's display unit.
|
||||
* How a value line's number reads (v1.42, #613). The server sends each
|
||||
* series in its stored, canonical unit (minutes for sleep, kilograms for
|
||||
* weight); the person's unit preference and the duration spelling are
|
||||
* applied here, through the same formatter the day view uses
|
||||
* (`useSeriesValueFormat`), so a night reads "9 h 16 min" on both pages.
|
||||
*/
|
||||
import type { TimelineBucket, TimelineResponse } from "@/lib/day/contract";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
|
||||
import { addMonths, bucketAfter, bucketStart } from "./timeline-dates";
|
||||
|
||||
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
|
||||
export function formatSeriesNumber(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): { value: string; unit: string | null } {
|
||||
if (key === "SLEEP_DURATION" && unit === "min") {
|
||||
return { value: fmt.number(value / 60, 1), unit: "h" };
|
||||
}
|
||||
// Glucose arrives in the person's display unit: whole numbers in the
|
||||
// hundreds, one decimal for single digits.
|
||||
const digits =
|
||||
WHOLE_NUMBER_KEYS.has(key) || (key === "BLOOD_GLUCOSE" && value >= 30)
|
||||
? 0
|
||||
: 1;
|
||||
return { value: fmt.number(value, digits), unit };
|
||||
/** How one series value reads: the number, and the unit beside it. */
|
||||
export interface SeriesValueFormat {
|
||||
/** "82,6", "129", or a duration that carries its own unit ("9 h 16 min"). */
|
||||
number: (key: string, value: number, unit: string | null) => string;
|
||||
/** The unit beside the number, or "" when the number carries its own. */
|
||||
unit: (key: string, unit: string | null) => string;
|
||||
}
|
||||
|
||||
/** "129 mmHg", "82,6 kg", "7,4 h". */
|
||||
/**
|
||||
* The format for a series without a measurement type behind it (mood, a
|
||||
* score without a unit): one decimal, no unit.
|
||||
*/
|
||||
export function plainSeriesFormat(
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): SeriesValueFormat {
|
||||
return {
|
||||
number: (_key, value) => fmt.number(value, 1),
|
||||
unit: (_key, unit) => unit ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** "129 mmHg", "82,6 kg", "9 h 16 min". */
|
||||
export function formatSeriesValue(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
format: SeriesValueFormat,
|
||||
): string {
|
||||
const out = formatSeriesNumber(key, value, unit, fmt);
|
||||
return out.unit ? `${out.value} ${out.unit}` : out.value;
|
||||
const number = format.number(key, value, unit);
|
||||
const shown = format.unit(key, unit);
|
||||
return shown ? `${number} ${shown}` : number;
|
||||
}
|
||||
|
||||
/** One series in one bucket: its mean, or nothing when it has no reading. */
|
||||
@@ -116,49 +113,69 @@ export function chronicleMeans(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The means in one short line: systolic and diastolic fold into
|
||||
* "129/82 mmHg", the rest follow as "82,6 kg". Series without a value are
|
||||
* left out; the line is for a glance, the selection bar names the gaps.
|
||||
*/
|
||||
export function formatMeans(
|
||||
means: readonly BucketValue[],
|
||||
fmt: Pick<Formatters, "number">,
|
||||
/** Systolic over diastolic, one unit: "129/82 mmHg". */
|
||||
function pressure(
|
||||
sys: BucketValue,
|
||||
dia: BucketValue,
|
||||
format: SeriesValueFormat,
|
||||
): string {
|
||||
const unit = format.unit(sys.key, sys.unit);
|
||||
return `${format.number(sys.key, sys.mean!, sys.unit)}/${format.number(dia.key, dia.mean!, dia.unit)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
/** One value of a means line, with the series whose colour marks it. */
|
||||
export interface MeanPart {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The means of one bucket for a glance: systolic and diastolic fold into
|
||||
* "129/82 mmHg" (marked as systolic), the rest follow as "82,6 kg". Series
|
||||
* without a value are left out; the selection bar names the gaps.
|
||||
*/
|
||||
export function meanParts(
|
||||
means: readonly BucketValue[],
|
||||
format: SeriesValueFormat,
|
||||
): MeanPart[] {
|
||||
const present = means.filter(
|
||||
(m): m is BucketValue & { mean: number } => m.mean !== null,
|
||||
);
|
||||
const sys = present.find((m) => m.key === "BLOOD_PRESSURE_SYS");
|
||||
const dia = present.find((m) => m.key === "BLOOD_PRESSURE_DIA");
|
||||
const parts: string[] = [];
|
||||
const parts: MeanPart[] = [];
|
||||
for (const m of present) {
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
`${fmt.number(sys.mean, 0)}/${fmt.number(dia.mean, 0)}${sys.unit ? ` ${sys.unit}` : ""}`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: pressure(sys, dia, format),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
parts.push(formatSeriesValue(m.key, m.mean, m.unit, fmt));
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: formatSeriesValue(m.key, m.mean, m.unit, format),
|
||||
});
|
||||
}
|
||||
return parts.join(" · ");
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The means of one bucket with their names, readings and gaps, for the
|
||||
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings) · Weight
|
||||
* no value". Systolic and diastolic fold when both have a value.
|
||||
* selection bar: "Blood pressure 129/82 mmHg (mean of 4 readings)", then
|
||||
* "Weight no value". Systolic and diastolic fold when both have a value.
|
||||
*/
|
||||
export function formatLabelledMeans(
|
||||
export function labelledMeanParts(
|
||||
means: readonly BucketValue[],
|
||||
fmt: Pick<Formatters, "number">,
|
||||
format: SeriesValueFormat,
|
||||
words: {
|
||||
label: (key: string) => string;
|
||||
bloodPressure: string;
|
||||
noValue: string;
|
||||
readings: (count: number) => string;
|
||||
},
|
||||
): string {
|
||||
): MeanPart[] {
|
||||
const sys = means.find(
|
||||
(m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null,
|
||||
);
|
||||
@@ -166,20 +183,23 @@ export function formatLabelledMeans(
|
||||
(m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null,
|
||||
);
|
||||
const fold = sys && dia;
|
||||
const parts: string[] = [];
|
||||
const parts: MeanPart[] = [];
|
||||
for (const m of means) {
|
||||
if (fold && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (fold && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
`${words.bloodPressure} ${fmt.number(sys.mean!, 0)}/${fmt.number(dia.mean!, 0)}${sys.unit ? ` ${sys.unit}` : ""} (${words.readings(sys.count ?? 0)})`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text: `${words.bloodPressure} ${pressure(sys, dia!, format)} (${words.readings(sys.count ?? 0)})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
parts.push(
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, fmt)} (${words.readings(m.count ?? 0)})`,
|
||||
);
|
||||
parts.push({
|
||||
key: m.key,
|
||||
text:
|
||||
m.mean === null
|
||||
? `${words.label(m.key)} ${words.noValue}`
|
||||
: `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)} (${words.readings(m.count ?? 0)})`,
|
||||
});
|
||||
}
|
||||
return parts.join(" · ");
|
||||
return parts;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import type {
|
||||
TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
bucketAfter,
|
||||
@@ -76,7 +76,14 @@ import {
|
||||
type PlacedSpan,
|
||||
type TimeWindow,
|
||||
} from "./timeline-geometry";
|
||||
import { formatSeriesValue } from "./series-format";
|
||||
import {
|
||||
formatSeriesValue,
|
||||
type MeanPart,
|
||||
type SeriesValueFormat,
|
||||
} from "./series-format";
|
||||
|
||||
/** Where a value line's name and latest value start, right of its swatch. */
|
||||
const SERIES_TEXT_X = 16;
|
||||
|
||||
export const LANE_ICON: Readonly<Record<TimelineLaneKey, LucideIcon>> = {
|
||||
life: Flag,
|
||||
@@ -98,6 +105,10 @@ export interface TimelineChartProps {
|
||||
selected: string | null;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
seriesLabel: (key: string) => string;
|
||||
/** The colour of a value line (`series-colors.ts`), as a token string. */
|
||||
seriesColor: (key: string) => string;
|
||||
/** How a value reads (`useSeriesValueFormat`). */
|
||||
seriesFormat: SeriesValueFormat;
|
||||
onSelect: (date: string) => void;
|
||||
onOpenDay: (date: string) => void;
|
||||
}
|
||||
@@ -129,11 +140,13 @@ export function TimelineChart({
|
||||
selected,
|
||||
hiddenLanes,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
seriesFormat,
|
||||
onSelect,
|
||||
onOpenDay,
|
||||
}: TimelineChartProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const fmt = seriesFormat;
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { ref, width } = useElementWidth<HTMLDivElement>();
|
||||
|
||||
@@ -235,6 +248,7 @@ export function TimelineChart({
|
||||
dataFrom={timeline.range.dataFrom}
|
||||
bucket={timeline.bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
@@ -253,6 +267,7 @@ export function TimelineChart({
|
||||
timeline={timeline}
|
||||
window={window}
|
||||
seriesLabel={seriesLabel}
|
||||
fmt={fmt}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
@@ -268,9 +283,10 @@ interface ChartBodyProps {
|
||||
dataFrom: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: ReturnType<typeof useFormatters>;
|
||||
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 (
|
||||
<g fontFamily="inherit">
|
||||
@@ -434,81 +455,20 @@ function ChartBody({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Value lines: neutral, one scale each, never coloured by meaning. */}
|
||||
{layout.series.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={seriesTop - 18}
|
||||
y2={seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{layout.series.map((series) => (
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(seriesLabel(series.key), scale.x0 - 12, 13)}
|
||||
</text>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 30}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{series.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: formatSeriesValue(series.key, series.latest, series.unit, fmt)}
|
||||
</text>
|
||||
{series.path && (
|
||||
<path
|
||||
d={series.path}
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.6}
|
||||
strokeOpacity={0.85}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.bridges && (
|
||||
<path
|
||||
d={series.bridges}
|
||||
data-slot="timeline-series-bridge"
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.6}
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.points.map((point) => (
|
||||
<circle
|
||||
key={point.t}
|
||||
data-slot="timeline-series-point"
|
||||
data-t={point.t}
|
||||
data-count={point.count}
|
||||
data-thin={point.thin ? "true" : "false"}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.thin ? 2.75 : 2.5}
|
||||
fill={point.thin ? "var(--card)" : "var(--foreground)"}
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={point.thin ? 1.4 : 0}
|
||||
>
|
||||
<title>
|
||||
{`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
{/* 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. */}
|
||||
<SeriesLines
|
||||
layout={layout}
|
||||
width={width}
|
||||
selectedBucket={selectedBucket}
|
||||
bucket={bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
/>
|
||||
|
||||
{/* 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<ReturnType<typeof layoutTimeline>>;
|
||||
width: number;
|
||||
selectedBucket: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: SeriesValueFormat;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const { scale, seriesTop } = layout;
|
||||
return (
|
||||
<>
|
||||
{layout.series.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={seriesTop - 18}
|
||||
y2={seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{layout.series.map((series) => {
|
||||
const color = seriesColor(series.key);
|
||||
return (
|
||||
<g
|
||||
key={series.key}
|
||||
data-series={series.key}
|
||||
data-color={color}
|
||||
color={color}
|
||||
>
|
||||
<line
|
||||
data-slot="timeline-series-swatch"
|
||||
x1={1.5}
|
||||
x2={10.5}
|
||||
y1={series.top + 8}
|
||||
y2={series.top + 8}
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x={SERIES_TEXT_X}
|
||||
y={series.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(
|
||||
seriesLabel(series.key),
|
||||
scale.x0 - 12 - SERIES_TEXT_X,
|
||||
13,
|
||||
)}
|
||||
</text>
|
||||
<text
|
||||
x={SERIES_TEXT_X}
|
||||
y={series.top + 30}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{series.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: formatSeriesValue(
|
||||
series.key,
|
||||
series.latest,
|
||||
series.unit,
|
||||
fmt,
|
||||
)}
|
||||
</text>
|
||||
{series.path && (
|
||||
<path
|
||||
d={series.path}
|
||||
data-slot="timeline-series-line"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.bridges && (
|
||||
<path
|
||||
d={series.bridges}
|
||||
data-slot="timeline-series-bridge"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.points.map((point) => (
|
||||
<circle
|
||||
key={point.t}
|
||||
data-slot="timeline-series-point"
|
||||
data-t={point.t}
|
||||
data-count={point.count}
|
||||
data-thin={point.thin ? "true" : "false"}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={point.thin ? 2.75 : 2.5}
|
||||
fill={point.thin ? "var(--card)" : "currentColor"}
|
||||
stroke="currentColor"
|
||||
strokeWidth={point.thin ? 1.4 : 0}
|
||||
>
|
||||
<title>
|
||||
{`${seriesLabel(series.key)}, ${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
{series.points
|
||||
.filter((point) => point.t === selectedBucket)
|
||||
.map((point) => (
|
||||
<circle
|
||||
key={`selected-${point.t}`}
|
||||
data-slot="timeline-series-selected"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={5}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<span key={part.key} data-series={part.key}>
|
||||
{i > 0 ? " · " : null}
|
||||
<span
|
||||
data-slot="timeline-series-dot"
|
||||
className="mr-1 inline-block size-2 rounded-full align-middle"
|
||||
style={{ background: seriesColor(part.key) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{part.text}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
|
||||
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
|
||||
return { background: LANE_COLOR[lane] };
|
||||
|
||||
@@ -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<Record<TimelineItemKind, LucideIcon>> = {
|
||||
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 (
|
||||
<li
|
||||
key={`h-${row.group}`}
|
||||
@@ -269,15 +278,16 @@ export function TimelineChronicle({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{groupLabel(row.group)}
|
||||
</h3>
|
||||
{bucketMeans && line && (
|
||||
{bucketMeans && parts.length > 0 && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
data-slot="timeline-chronicle-means"
|
||||
data-bucket={bucketMeans.start}
|
||||
>
|
||||
{timeline.bucket === "month"
|
||||
? line
|
||||
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`}
|
||||
? null
|
||||
: `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: `}
|
||||
<MeanPartsLine parts={parts} seriesColor={seriesColor} />
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
/**
|
||||
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
|
||||
* medications, vaccinations, visits, documents and the person's own life
|
||||
* events in lanes, up to six neutral value lines below them, all on one
|
||||
* events in lanes, up to six value lines below them, each in its own
|
||||
* colour (`series-colors.ts`), all on one
|
||||
* time axis. Nothing on the page draws a connection between them.
|
||||
*
|
||||
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
|
||||
@@ -52,11 +53,13 @@ import {
|
||||
} from "./readiness-model";
|
||||
import { TimelineReadinessSheet } from "./readiness-sheet";
|
||||
import { Segmented } from "./segmented";
|
||||
import { SERIES_FALLBACK_COLOR, assignSeriesColors } from "./series-colors";
|
||||
import { useSeriesValueFormat } from "./use-series-value-format";
|
||||
import { SelectionBar } from "./selection-bar";
|
||||
import { TimelineChart } from "./timeline-chart";
|
||||
import { TimelineChronicle } from "./timeline-chronicle";
|
||||
import { todayKeyIn } from "./timeline-dates";
|
||||
import { LANE_ORDER, windowFor } from "./timeline-geometry";
|
||||
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
|
||||
import {
|
||||
LayersMenu,
|
||||
MAX_VALUE_SERIES,
|
||||
@@ -106,24 +109,6 @@ export const DEFAULT_VALUES = [
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
|
||||
|
||||
/** The latest dated entry on or before today, to select on arrival. */
|
||||
function latestEntryDate(
|
||||
lanes: ReadonlyArray<{
|
||||
items: ReadonlyArray<{ start: string; end: string | null }>;
|
||||
}>,
|
||||
today: string,
|
||||
): string | null {
|
||||
let best: string | null = null;
|
||||
for (const lane of lanes) {
|
||||
for (const item of lane.items) {
|
||||
for (const d of [item.start, item.end]) {
|
||||
if (d && d <= today && (!best || d > best)) best = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -185,6 +170,15 @@ export function TimelineView() {
|
||||
.slice(0, MAX_VALUE_SERIES),
|
||||
[storedValues, valueOptions],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
// menu and the phone chronicle; contested colours go by the menu's order.
|
||||
const seriesColors = useMemo(
|
||||
() => assignSeriesColors(values, VALUE_OPTIONS),
|
||||
[values],
|
||||
);
|
||||
const seriesFormat = useSeriesValueFormat();
|
||||
const seriesColor = (key: string) =>
|
||||
seriesColors.get(key) ?? SERIES_FALLBACK_COLOR;
|
||||
const hidden = useMemo(
|
||||
() => new Set(hiddenList as TimelineLaneKey[]),
|
||||
[hiddenList],
|
||||
@@ -223,8 +217,12 @@ export function TimelineView() {
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
// Before anyone picks a day, the newest bucket the bar has something for,
|
||||
// inside the window that was asked for.
|
||||
const effectiveSelected =
|
||||
selected ?? (data ? latestEntryDate(data.lanes, today) : null) ?? today;
|
||||
selected ??
|
||||
(data ? latestDataDate(data, today, requestWindow ?? data.range) : null) ??
|
||||
today;
|
||||
|
||||
// The shell's day layer owns `?day=` and its history entries; this only
|
||||
// asks it to open the day, and keeps the selection on it.
|
||||
@@ -363,6 +361,7 @@ export function TimelineView() {
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{layers(false)}
|
||||
@@ -378,14 +377,19 @@ export function TimelineView() {
|
||||
selected={effectiveSelected}
|
||||
hiddenLanes={hidden}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onSelect={setSelected}
|
||||
onOpenDay={openDay}
|
||||
/>
|
||||
<SelectionBar
|
||||
timeline={data}
|
||||
selected={effectiveSelected}
|
||||
showHint={selected === null}
|
||||
today={today}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
|
||||
/>
|
||||
@@ -417,6 +421,8 @@ export function TimelineView() {
|
||||
today={today}
|
||||
grouping={grouping}
|
||||
selected={dayParam}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : null}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDayValueFormat } from "@/components/day/use-day-value-format";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
|
||||
|
||||
/** The mood score's series key (`MOOD_SERIES_KEY` on the server). */
|
||||
const MOOD_SERIES_KEY = "MOOD";
|
||||
|
||||
/**
|
||||
* The timeline's value format: the day view's formatter for every
|
||||
* measurement series (unit preference, whole numbers for pressure and
|
||||
* pulse, sleep as a duration), one plain decimal for mood, which has no
|
||||
* measurement type behind it.
|
||||
*/
|
||||
export function useSeriesValueFormat(): SeriesValueFormat {
|
||||
const fmt = useFormatters();
|
||||
const { number, unitFor } = useDayValueFormat();
|
||||
return useMemo(() => {
|
||||
const plain = plainSeriesFormat(fmt);
|
||||
return {
|
||||
number: (key, value, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.number(key, value, unit)
|
||||
: number({ type: key, unit }, value),
|
||||
unit: (key, unit) =>
|
||||
key === MOOD_SERIES_KEY || unit === null
|
||||
? plain.unit(key, unit)
|
||||
: unitFor({ type: key, unit }),
|
||||
};
|
||||
}, [fmt, number, unitFor]);
|
||||
}
|
||||
Reference in new issue
Block a user