mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0
This commit is contained in:
commit
c40b21de3d
39 files changed
+2185
-543
No files matched your search
@@ -134,5 +134,5 @@ export function useDayValueFormat() {
|
||||
[labelFor, number, unitFor],
|
||||
);
|
||||
|
||||
return { labelFor, formatTile };
|
||||
return { labelFor, formatTile, number, unitFor };
|
||||
}
|
||||
@@ -46,8 +46,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. The round New
|
||||
* chat button sits at the panel's bottom right.
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
@@ -214,13 +215,41 @@ export function ConversationsPanel({
|
||||
// app (`shell-safe-area` in `auth-shell.tsx`).
|
||||
// Docked, the row is the top bar's band (height and bottom border
|
||||
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
|
||||
// Touch: 44 px buttons already carry their own air, so the row packs
|
||||
// them tighter to leave the title room at 390 px.
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"flex shrink-0 items-center gap-1 pointer-fine:gap-2",
|
||||
inSheet
|
||||
? "border-border border-b p-3"
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -287,28 +316,6 @@ export function ConversationsPanel({
|
||||
onUndoDeleteActive={(id) => onSelect(id)}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{/* New chat: a round filled button floating at the panel's bottom
|
||||
right; the list pads its own scroll area so the last row clears it. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
className="absolute right-4 bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] size-12 rounded-full shadow-lg md:right-6 md:bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))]"
|
||||
>
|
||||
<Plus className="size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -149,11 +149,7 @@ export function HistoryRail({
|
||||
<div
|
||||
ref={setListNode}
|
||||
data-slot="coach-history-list"
|
||||
className={cn(
|
||||
// `pb-20` keeps the last row clear of the panel's floating New
|
||||
// chat button; it pads this list's own scroll area, not `main`.
|
||||
"-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1 pb-20",
|
||||
)}
|
||||
className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1"
|
||||
>
|
||||
{isLoading && visible.length === 0 ? (
|
||||
<p
|
||||
|
||||
@@ -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,108 @@
|
||||
/**
|
||||
* The chosen-range fields: two date fields with the record's limits, and
|
||||
* every change made honest before it leaves (never past today, never before
|
||||
* the first entry, never inverted).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DateFieldProps } from "@/components/ui/date-field";
|
||||
|
||||
const fields: DateFieldProps[] = [];
|
||||
vi.mock("@/components/ui/date-field", () => ({
|
||||
DateField: (props: DateFieldProps) => {
|
||||
fields.push(props);
|
||||
return (
|
||||
<input
|
||||
aria-label={props["aria-label"]}
|
||||
data-testid={props["data-testid"]}
|
||||
data-min={props.min}
|
||||
data-max={props.max}
|
||||
value={props.value}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const { RangeFields } = await import("../range-fields");
|
||||
|
||||
const TODAY = "2026-10-07";
|
||||
|
||||
function render(onChange: (next: { from: string; to: string }) => void) {
|
||||
fields.length = 0;
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<RangeFields
|
||||
range={{ from: "2026-03-01", to: "2026-04-15" }}
|
||||
today={TODAY}
|
||||
dataFrom="2019-03-10"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const [from, to] = fields;
|
||||
return { html, from, to };
|
||||
}
|
||||
|
||||
describe("<RangeFields>", () => {
|
||||
it("names both ends and carries the record's limits", () => {
|
||||
const { html, from, to } = render(() => undefined);
|
||||
expect(html).toContain('data-slot="timeline-range"');
|
||||
expect(html).toContain('data-from="2026-03-01"');
|
||||
expect(html).toContain('data-to="2026-04-15"');
|
||||
expect(html).toContain('aria-label="Gewählter Zeitraum"');
|
||||
expect(from["aria-label"]).toBe("Von");
|
||||
expect(to["aria-label"]).toBe("Bis");
|
||||
expect(from.min).toBe("2019-03-10");
|
||||
expect(from.max).toBe("2026-04-15");
|
||||
expect(to.min).toBe("2026-03-01");
|
||||
expect(to.max).toBe(TODAY);
|
||||
});
|
||||
|
||||
it("passes a valid change on as it is", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from, to } = render(onChange);
|
||||
from.onChange?.("2026-02-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-02-01",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
to.onChange?.("2026-05-31");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-03-01",
|
||||
to: "2026-05-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps softly instead of refusing", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from, to } = render(onChange);
|
||||
// Before the first entry: the first entry.
|
||||
from.onChange?.("2010-01-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2019-03-10",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
// Past today: today.
|
||||
to.onChange?.("2027-01-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-03-01",
|
||||
to: TODAY,
|
||||
});
|
||||
// A start after the end: the end.
|
||||
from.onChange?.("2026-05-01");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
from: "2026-04-15",
|
||||
to: "2026-04-15",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a cleared field", () => {
|
||||
const onChange = vi.fn();
|
||||
const { from } = render(onChange);
|
||||
from.onChange?.("");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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,152 @@ 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("calls a bucket empty only when it holds neither an entry nor a value", () => {
|
||||
// July 2026: no entry, but a systolic mean.
|
||||
expect(bar("2026-07-15")).not.toContain("timeline-selection-empty");
|
||||
// May 2026: no entry, no value.
|
||||
expect(text(bar("2026-05-10"))).toContain(
|
||||
"Keine Einträge in diesem Zeitraum",
|
||||
);
|
||||
});
|
||||
|
||||
it("says how to pick a day only until one is picked", () => {
|
||||
expect(bar("2026-01-03", fullTimeline(), true)).toContain(
|
||||
'data-slot="timeline-selection-hint"',
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TIMELINE_RANGE_MAX_DAYS } from "@/lib/day/contract";
|
||||
|
||||
import { dayKey, dayNumber } from "../timeline-dates";
|
||||
import { clampRange, parseTimelineUrl, timelineSearch } from "../timeline-url";
|
||||
|
||||
const TODAY = "2026-10-07";
|
||||
const parse = (search: string) =>
|
||||
parseTimelineUrl(new URLSearchParams(search), TODAY);
|
||||
|
||||
describe("timeline URL", () => {
|
||||
it("reads a fixed zoom and leaves the choice open without one", () => {
|
||||
expect(parse("")).toEqual({ zoom: null, range: null });
|
||||
expect(parse("zoom=year")).toEqual({ zoom: "year", range: null });
|
||||
expect(parse("zoom=quarter&day=2026-01-03")).toEqual({
|
||||
zoom: "quarter",
|
||||
range: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a chosen range with both ends", () => {
|
||||
expect(parse("zoom=range&from=2026-03-01&to=2026-04-15")).toEqual({
|
||||
zoom: "range",
|
||||
range: { from: "2026-03-01", to: "2026-04-15" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to all, without a word, on anything it cannot read", () => {
|
||||
for (const search of [
|
||||
"zoom=decade",
|
||||
"zoom=range",
|
||||
"zoom=range&from=2026-03-01",
|
||||
"zoom=range&from=2026-02-30&to=2026-04-15",
|
||||
"zoom=range&from=2026-05-01&to=2026-04-15",
|
||||
"zoom=range&from=2027-01-01&to=2027-02-01",
|
||||
"zoom=range&from=yesterday&to=today",
|
||||
]) {
|
||||
expect(parse(search), search).toEqual({ zoom: "all", range: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("pulls a range's end back to today and its span under the cap", () => {
|
||||
expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({
|
||||
from: "2026-09-01",
|
||||
to: TODAY,
|
||||
});
|
||||
const long = clampRange({ from: "1990-01-01", to: TODAY }, TODAY, null);
|
||||
expect(dayNumber(long.to) - dayNumber(long.from) + 1).toBe(
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
);
|
||||
expect(long.from).toBe(
|
||||
dayKey(dayNumber(TODAY) - TIMELINE_RANGE_MAX_DAYS + 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the zoom and range and keeps every other parameter", () => {
|
||||
expect(
|
||||
timelineSearch("?day=2026-01-03", "range", {
|
||||
from: "2026-01-01",
|
||||
to: "2026-01-31",
|
||||
}),
|
||||
).toBe("?day=2026-01-03&zoom=range&from=2026-01-01&to=2026-01-31");
|
||||
expect(
|
||||
timelineSearch("?zoom=range&from=2026-01-01&to=2026-01-31", "year", null),
|
||||
).toBe("?zoom=year");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two date fields of a chosen range (v1.42): from and to, through the
|
||||
* app's own date field (the browser's native date input reads its order
|
||||
* from the browser, not from the person's setting). Each change is made
|
||||
* honest before it leaves (`clampRange`): never past today, never before
|
||||
* the record's first entry, never inverted. The fields carry the same
|
||||
* limits, so the calendar greys out what the clamp would move anyway.
|
||||
*/
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { clampRange, type TimelineRange } from "./timeline-url";
|
||||
|
||||
export function RangeFields({
|
||||
range,
|
||||
today,
|
||||
dataFrom,
|
||||
onChange,
|
||||
}: {
|
||||
range: TimelineRange;
|
||||
today: string;
|
||||
/** The record's first entry, once known; the earliest the range may start. */
|
||||
dataFrom: string | null;
|
||||
onChange: (next: TimelineRange) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const commit = (next: TimelineRange) => {
|
||||
const clamped = clampRange(next, today, dataFrom);
|
||||
if (clamped.from !== range.from || clamped.to !== range.to) {
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("timeline.range.label")}
|
||||
data-slot="timeline-range"
|
||||
data-from={range.from}
|
||||
data-to={range.to}
|
||||
className="grid grid-cols-2 gap-3 sm:flex sm:items-end"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
|
||||
{/* The field's own input carries the name; this is its caption. */}
|
||||
<span className="text-xs font-medium" aria-hidden="true">
|
||||
{t("timeline.range.from")}
|
||||
</span>
|
||||
<DateField
|
||||
aria-label={t("timeline.range.from")}
|
||||
value={range.from}
|
||||
min={dataFrom ?? undefined}
|
||||
max={range.to}
|
||||
onChange={(from) => {
|
||||
if (from) commit({ from, to: range.to });
|
||||
}}
|
||||
data-testid="timeline-range-from"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:w-44">
|
||||
{/* The field's own input carries the name; this is its caption. */}
|
||||
<span className="text-xs font-medium" aria-hidden="true">
|
||||
{t("timeline.range.to")}
|
||||
</span>
|
||||
<DateField
|
||||
aria-label={t("timeline.range.to")}
|
||||
value={range.to}
|
||||
min={range.from}
|
||||
max={today}
|
||||
onChange={(to) => {
|
||||
if (to) commit({ from: range.from, to });
|
||||
}}
|
||||
data-testid="timeline-range-to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,74 @@ 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);
|
||||
// A bucket with a value but no entry is not empty: the means below say
|
||||
// what it holds, so "No entries" is kept for a stretch with nothing.
|
||||
const hasValue = means.values.some((v) => v.mean !== null);
|
||||
// "Ø {values}": the words around the values, so the values can carry
|
||||
// their colour dots.
|
||||
const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", {
|
||||
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">
|
||||
{entries.length === 0 && !hasValue && (
|
||||
<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 +181,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 +207,27 @@ export function SelectionBar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket by name, in full: "July to September 2026", "September 2026",
|
||||
* "Week of 22 September 2026", "22 September 2026".
|
||||
*/
|
||||
export function bucketTitle(
|
||||
start: string,
|
||||
bucket: TimelineResponse["bucket"],
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (bucket === "day") return formatDayMonthYearLong(start, intl);
|
||||
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,
|
||||
@@ -72,11 +72,19 @@ import {
|
||||
wrapToWidth,
|
||||
layoutTimeline,
|
||||
stepSelection,
|
||||
zoomShape,
|
||||
type PlacedPoint,
|
||||
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 +106,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 +141,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>();
|
||||
|
||||
@@ -158,14 +172,15 @@ export function TimelineChart({
|
||||
);
|
||||
|
||||
const floor = timeline.range.dataFrom ?? window.from;
|
||||
const shape = zoomShape(zoom, window);
|
||||
|
||||
function handleKey(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const current = selected ?? today;
|
||||
let next: string | null = null;
|
||||
if (event.key === "ArrowLeft")
|
||||
next = stepSelection(current, -1, zoom, today, floor);
|
||||
next = stepSelection(current, -1, shape, today, floor);
|
||||
else if (event.key === "ArrowRight")
|
||||
next = stepSelection(current, 1, zoom, today, floor);
|
||||
next = stepSelection(current, 1, shape, today, floor);
|
||||
else if (event.key === "Home") next = floor;
|
||||
else if (event.key === "End") next = today;
|
||||
else if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -229,12 +244,13 @@ export function TimelineChart({
|
||||
<ChartBody
|
||||
layout={layout}
|
||||
window={window}
|
||||
zoom={zoom}
|
||||
zoom={shape}
|
||||
today={today}
|
||||
selected={selected}
|
||||
dataFrom={timeline.range.dataFrom}
|
||||
bucket={timeline.bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
@@ -253,6 +269,7 @@ export function TimelineChart({
|
||||
timeline={timeline}
|
||||
window={window}
|
||||
seriesLabel={seriesLabel}
|
||||
fmt={fmt}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
@@ -262,15 +279,16 @@ export function TimelineChart({
|
||||
interface ChartBodyProps {
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
window: TimeWindow;
|
||||
zoom: TimelineZoom;
|
||||
zoom: Exclude<TimelineZoom, "range">;
|
||||
today: string;
|
||||
selected: string | null;
|
||||
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 +300,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 +321,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 +457,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 +521,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 +934,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 +997,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>
|
||||
|
||||
@@ -61,6 +61,7 @@ export function addMonths(key: string, months: number): string {
|
||||
|
||||
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
|
||||
export function bucketStart(key: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "day") return key;
|
||||
if (bucket === "month") return startOfMonth(key);
|
||||
if (bucket === "quarter") {
|
||||
const month = Number(key.slice(5, 7));
|
||||
@@ -74,6 +75,7 @@ export function bucketStart(key: string, bucket: TimelineBucket): string {
|
||||
|
||||
/** The first day after the bucket that starts on `start`. */
|
||||
export function bucketAfter(start: string, bucket: TimelineBucket): string {
|
||||
if (bucket === "day") return dayKey(dayNumber(start) + 1);
|
||||
if (bucket === "month") return addMonths(start, 1);
|
||||
if (bucket === "quarter") return addMonths(start, 3);
|
||||
return dayKey(dayNumber(start) + 7);
|
||||
@@ -84,6 +86,7 @@ export function bucketAfter(start: string, bucket: TimelineBucket): string {
|
||||
* buckets differ by one, so a difference counts the buckets between.
|
||||
*/
|
||||
export function bucketIndex(start: string, bucket: TimelineBucket): number {
|
||||
if (bucket === "day") return dayNumber(start);
|
||||
if (bucket === "week") return Math.floor((dayNumber(start) - 4) / 7);
|
||||
const months = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
|
||||
return bucket === "quarter" ? Math.floor(months / 3) : months;
|
||||
@@ -158,6 +161,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" });
|
||||
@@ -193,6 +208,7 @@ export function formatBucket(
|
||||
bucket: TimelineBucket,
|
||||
intlLocale: string,
|
||||
): string | { from: string; to: string } {
|
||||
if (bucket === "day") return formatDayMonthYear(start, intlLocale);
|
||||
if (bucket === "month") return formatMonthYear(start, intlLocale);
|
||||
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
|
||||
if (bucket === "quarter") {
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface TimeWindow {
|
||||
* fallback before an answer is the current and the previous year. `year` and
|
||||
* `quarter` are 12 and 3 whole months that keep `anchor` (the selected day)
|
||||
* in view, end no later than the month of `today`, and so never open onto an
|
||||
* empty future.
|
||||
* empty future. `range` is exactly the chosen stretch (`serverRange`).
|
||||
*/
|
||||
export function windowFor(
|
||||
zoom: TimelineZoom,
|
||||
@@ -129,7 +129,7 @@ export function windowFor(
|
||||
anchor: string | null,
|
||||
serverRange?: { from: string; to: string } | null,
|
||||
): TimeWindow {
|
||||
if (zoom === "all") {
|
||||
if (zoom === "all" || zoom === "range") {
|
||||
if (serverRange) return { from: serverRange.from, to: serverRange.to };
|
||||
const year = Number(today.slice(0, 4));
|
||||
return { from: `${year - 1}-01-01`, to: `${year}-12-31` };
|
||||
@@ -146,6 +146,23 @@ export function windowFor(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How a window draws its grid and steps its selection: a chosen range
|
||||
* borrows the fixed zoom nearest its length (years for two years and more,
|
||||
* months from four months, days below), so a range never needs a grid of
|
||||
* its own.
|
||||
*/
|
||||
export function zoomShape(
|
||||
zoom: TimelineZoom,
|
||||
window: TimeWindow,
|
||||
): Exclude<TimelineZoom, "range"> {
|
||||
if (zoom !== "range") return zoom;
|
||||
const days = dayNumber(window.to) - dayNumber(window.from) + 1;
|
||||
if (days >= 2 * 365) return "all";
|
||||
if (days >= 120) return "year";
|
||||
return "quarter";
|
||||
}
|
||||
|
||||
/** A linear map from calendar days to x, over `[from, to + 1 day)`. */
|
||||
export interface Scale {
|
||||
readonly x0: number;
|
||||
@@ -281,7 +298,7 @@ export interface GridFormat {
|
||||
*/
|
||||
export function gridTicks(
|
||||
window: TimeWindow,
|
||||
zoom: TimelineZoom,
|
||||
zoom: Exclude<TimelineZoom, "range">,
|
||||
scale: Scale,
|
||||
fmt: GridFormat,
|
||||
): GridTick[] {
|
||||
@@ -735,6 +752,11 @@ export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
quarter: 1,
|
||||
month: 2,
|
||||
week: 4,
|
||||
// Days come only with a chosen range under six weeks. Three missing days
|
||||
// (points four days apart) bridge a weekend away or a forgotten cuff;
|
||||
// a longer silence in a window that short is a break worth seeing, and
|
||||
// the bridge stays well under a third of even a two-week range.
|
||||
day: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -915,7 +937,7 @@ export function dateAtPointer(
|
||||
export function stepSelection(
|
||||
current: string,
|
||||
direction: -1 | 1,
|
||||
zoom: TimelineZoom,
|
||||
zoom: Exclude<TimelineZoom, "range">,
|
||||
today: string,
|
||||
floor: string,
|
||||
): string {
|
||||
@@ -929,20 +951,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 +985,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The timeline's zoom and chosen range in the URL (v1.42):
|
||||
* `?zoom=year`, `?zoom=range&from=2026-03-01&to=2026-04-15`. Back and a
|
||||
* bookmark both land where the person was. Anything that does not read as
|
||||
* a zoom, or a range without two valid ends, falls back to `all` without a
|
||||
* word: a stale bookmark should open the timeline, not an error.
|
||||
*/
|
||||
import {
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
TIMELINE_ZOOMS,
|
||||
type TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
import { dayKey, dayNumber } from "./timeline-dates";
|
||||
|
||||
export const ZOOM_PARAM = "zoom";
|
||||
export const FROM_PARAM = "from";
|
||||
export const TO_PARAM = "to";
|
||||
|
||||
export interface TimelineRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TimelineUrlState {
|
||||
/** The zoom the URL names, or null when it names none (or a bad one). */
|
||||
zoom: TimelineZoom | null;
|
||||
/** The chosen range, with `zoom` `range` only. */
|
||||
range: TimelineRange | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chosen range made honest, softly: never past today, never before the
|
||||
* record's first entry (when known), never inverted, and never longer than
|
||||
* the server answers. Each end moves only as far as it must.
|
||||
*/
|
||||
export function clampRange(
|
||||
range: TimelineRange,
|
||||
today: string,
|
||||
dataFrom: string | null,
|
||||
): TimelineRange {
|
||||
let { from, to } = range;
|
||||
if (to > today) to = today;
|
||||
if (dataFrom && dataFrom <= to && from < dataFrom) from = dataFrom;
|
||||
if (from > to) from = to;
|
||||
const earliest = dayKey(dayNumber(to) - (TIMELINE_RANGE_MAX_DAYS - 1));
|
||||
if (from < earliest) from = earliest;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function parseTimelineUrl(
|
||||
params: Pick<URLSearchParams, "get">,
|
||||
today: string,
|
||||
): TimelineUrlState {
|
||||
const raw = params.get(ZOOM_PARAM);
|
||||
const zoom = (TIMELINE_ZOOMS as readonly string[]).includes(raw ?? "")
|
||||
? (raw as TimelineZoom)
|
||||
: null;
|
||||
if (raw !== null && zoom === null) return { zoom: "all", range: null };
|
||||
if (zoom !== "range") return { zoom, range: null };
|
||||
const from = params.get(FROM_PARAM);
|
||||
const to = params.get(TO_PARAM);
|
||||
if (
|
||||
!from ||
|
||||
!to ||
|
||||
!isCalendarDateKey(from) ||
|
||||
!isCalendarDateKey(to) ||
|
||||
from > to ||
|
||||
from > today
|
||||
) {
|
||||
return { zoom: "all", range: null };
|
||||
}
|
||||
return { zoom, range: clampRange({ from, to }, today, null) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The search string for a zoom (and, for `range`, its ends), keeping every
|
||||
* other parameter (`day`, `add`) as it is.
|
||||
*/
|
||||
export function timelineSearch(
|
||||
current: string,
|
||||
zoom: TimelineZoom,
|
||||
range: TimelineRange | null,
|
||||
): string {
|
||||
const params = new URLSearchParams(current);
|
||||
params.set(ZOOM_PARAM, zoom);
|
||||
if (zoom === "range" && range) {
|
||||
params.set(FROM_PARAM, range.from);
|
||||
params.set(TO_PARAM, range.to);
|
||||
} else {
|
||||
params.delete(FROM_PARAM);
|
||||
params.delete(TO_PARAM);
|
||||
}
|
||||
const search = params.toString();
|
||||
return search ? `?${search}` : "";
|
||||
}
|
||||
@@ -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
|
||||
@@ -18,7 +19,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CalendarRange, Plus } from "lucide-react";
|
||||
|
||||
import { openDay as openDayLayer } from "@/components/day/day-layer-controller";
|
||||
import { parseDayParam } from "@/components/day/day-url";
|
||||
@@ -52,11 +53,20 @@ 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 { dayKey, dayNumber, todayKeyIn } from "./timeline-dates";
|
||||
import { RangeFields } from "./range-fields";
|
||||
import {
|
||||
clampRange,
|
||||
parseTimelineUrl,
|
||||
timelineSearch,
|
||||
type TimelineRange,
|
||||
} from "./timeline-url";
|
||||
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
|
||||
import {
|
||||
LayersMenu,
|
||||
MAX_VALUE_SERIES,
|
||||
@@ -106,24 +116,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();
|
||||
@@ -135,7 +127,12 @@ export function TimelineView() {
|
||||
// Life events are written in one's own record only (owner-only routes).
|
||||
const canAddLifeEvent = !caps.inSharedRecord;
|
||||
|
||||
const [zoom, setZoom] = useState<TimelineZoom>(dayParam ? "quarter" : "all");
|
||||
// The zoom and a chosen range live in the URL (`timeline-url.ts`), so Back
|
||||
// and a bookmark return to them. Without a `zoom`, an incoming `?day=`
|
||||
// opens three months around that day and anything else the whole record.
|
||||
const urlState = parseTimelineUrl(searchParams, today);
|
||||
const zoom: TimelineZoom = urlState.zoom ?? (dayParam ? "quarter" : "all");
|
||||
const range = zoom === "range" ? urlState.range : null;
|
||||
const [grouping, setGrouping] = useState<ChronicleGrouping>("month");
|
||||
// The selection follows `?day=` (Back, Forward, the day panel's previous
|
||||
// and next) and otherwise the last click or key. A pick remembers the
|
||||
@@ -185,13 +182,26 @@ 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],
|
||||
);
|
||||
|
||||
const requestWindow =
|
||||
zoom === "all" ? null : windowFor(zoom, today, selected ?? dayParam);
|
||||
zoom === "all"
|
||||
? null
|
||||
: zoom === "range"
|
||||
? range
|
||||
: windowFor(zoom, today, selected ?? dayParam);
|
||||
const timeline = useTimeline(
|
||||
zoom,
|
||||
requestWindow?.from ?? null,
|
||||
@@ -223,8 +233,37 @@ export function TimelineView() {
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
// A pick outside a newly chosen range no longer counts as one.
|
||||
const chosen =
|
||||
selected !== null &&
|
||||
(!requestWindow ||
|
||||
(selected >= requestWindow.from && selected <= requestWindow.to))
|
||||
? selected
|
||||
: null;
|
||||
// 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;
|
||||
chosen ??
|
||||
(data ? latestDataDate(data, today, requestWindow ?? data.range) : null) ??
|
||||
(range ? range.to : today);
|
||||
|
||||
function navigate(nextZoom: TimelineZoom, nextRange: TimelineRange | null) {
|
||||
const search = timelineSearch(window.location.search, nextZoom, nextRange);
|
||||
window.history.pushState(null, "", `${pathname}${search}`);
|
||||
}
|
||||
|
||||
function setZoom(next: TimelineZoom) {
|
||||
if (next !== "range") {
|
||||
navigate(next, null);
|
||||
return;
|
||||
}
|
||||
// A new range starts as what is on screen, up to today.
|
||||
const shown = window_ ?? {
|
||||
from: dayKey(dayNumber(today) - 29),
|
||||
to: today,
|
||||
};
|
||||
navigate("range", clampRange(shown, today, data?.range.dataFrom ?? null));
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -255,7 +294,7 @@ export function TimelineView() {
|
||||
zoom,
|
||||
today,
|
||||
effectiveSelected,
|
||||
zoom === "all" ? data.range : null,
|
||||
zoom === "all" || zoom === "range" ? data.range : null,
|
||||
)
|
||||
: null;
|
||||
const presentLanes = (data?.lanes ?? [])
|
||||
@@ -294,6 +333,29 @@ export function TimelineView() {
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// The chosen range's fields, under the zoom; on a phone with a way back to
|
||||
// the whole record, since the phone has no zoom control to switch with.
|
||||
const rangeControls =
|
||||
zoom === "range" && range ? (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<RangeFields
|
||||
range={range}
|
||||
today={today}
|
||||
dataFrom={data?.range.dataFrom ?? null}
|
||||
onChange={(next) => navigate("range", next)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-10 md:hidden"
|
||||
onClick={() => setZoom("all")}
|
||||
data-slot="timeline-range-clear"
|
||||
>
|
||||
{t("timeline.range.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const layers = (iconOnly: boolean) => (
|
||||
<LayersMenu
|
||||
lanes={presentLanes}
|
||||
@@ -337,6 +399,25 @@ export function TimelineView() {
|
||||
description={t("timeline.loadFailed")}
|
||||
onRetry={() => void timeline.refetch()}
|
||||
/>
|
||||
) : zoom === "range" && (isEmpty || !data || !window_) ? (
|
||||
// A chosen stretch with nothing in it: the fields stay, so the next
|
||||
// choice is one step away.
|
||||
<div className="space-y-4" data-slot="timeline-range-empty">
|
||||
{rangeControls}
|
||||
<EmptyState
|
||||
title={t("timeline.selection.empty")}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setZoom("all")}
|
||||
>
|
||||
{t("timeline.range.clear")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : isEmpty || !data || !window_ ? (
|
||||
<EmptyState
|
||||
title={t("timeline.empty.title")}
|
||||
@@ -363,11 +444,13 @@ export function TimelineView() {
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{layers(false)}
|
||||
</div>
|
||||
</div>
|
||||
{rangeControls}
|
||||
<Card className="md:gap-4">
|
||||
<CardContent className="space-y-4">
|
||||
<TimelineChart
|
||||
@@ -378,14 +461,19 @@ export function TimelineView() {
|
||||
selected={effectiveSelected}
|
||||
hiddenLanes={hidden}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onSelect={setSelected}
|
||||
onOpenDay={openDay}
|
||||
/>
|
||||
<SelectionBar
|
||||
timeline={data}
|
||||
selected={effectiveSelected}
|
||||
showHint={chosen === null}
|
||||
today={today}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
|
||||
/>
|
||||
@@ -407,8 +495,22 @@ export function TimelineView() {
|
||||
label={t("timeline.chronicle.groupingLabel")}
|
||||
slot="timeline-grouping"
|
||||
/>
|
||||
{layers(true)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={zoom === "range" ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("timeline.range.open")}
|
||||
aria-pressed={zoom === "range"}
|
||||
onClick={() => setZoom(zoom === "range" ? "all" : "range")}
|
||||
data-slot="timeline-range-toggle"
|
||||
>
|
||||
<CalendarRange className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{layers(true)}
|
||||
</div>
|
||||
</div>
|
||||
{rangeControls}
|
||||
<TimelineChronicle
|
||||
timeline={{
|
||||
...data,
|
||||
@@ -417,6 +519,8 @@ export function TimelineView() {
|
||||
today={today}
|
||||
grouping={grouping}
|
||||
selected={dayParam}
|
||||
seriesColor={seriesColor}
|
||||
seriesFormat={seriesFormat}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : null}
|
||||
/>
|
||||
@@ -444,6 +548,7 @@ const LEGEND_MEAN_KEY: Readonly<Record<TimelineBucket, string>> = {
|
||||
quarter: "timeline.legendMeanQuarter",
|
||||
month: "timeline.legendMeanMonth",
|
||||
week: "timeline.legendMeanWeek",
|
||||
day: "timeline.legendMeanDay",
|
||||
};
|
||||
|
||||
function Legend({
|
||||
|
||||
@@ -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