From af982e3812d6e34ca91f8df9b6bcb2d673ad9a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 09:03:42 +0200 Subject: [PATCH 1/2] feat(timeline): average value series per quarter, month or week with reading counts The server picks the bucket per zoom and sends it as `bucket`: quarters for an `all` window over two years, months for `year` and a shorter `all`, weeks for `quarter`. Each point carries the readings behind its mean as `count`; a bucket without a reading stays absent. Means remain the mean of day values, so pulse-like types keep the hourly-mean day. `values` accepts up to six series. --- docs/api/openapi.yaml | 33 ++-- src/app/api/timeline/route.ts | 2 +- src/lib/day/contract.ts | 16 ++ src/lib/day/daily-stats.ts | 55 +++++- src/lib/day/wire-schemas.ts | 31 +++- src/lib/timeline/__tests__/series.test.ts | 73 +++++++- src/lib/timeline/load-timeline.ts | 45 +++-- src/lib/timeline/series.ts | 197 +++++++++++++++------- tests/integration/day-view.test.ts | 119 +++++++++++++ 9 files changed, 466 insertions(+), 105 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 323f63544..a709ff3ea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23467,9 +23467,11 @@ paths: - in: query name: values schema: - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an + unknown name, answers 422. type: string - description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an + unknown name, answers 422. responses: "200": description: The timeline for the requested zoom and window. @@ -81541,6 +81543,14 @@ components: items: $ref: "#/components/schemas/TimelineStandingItem" description: "Things without a start: shown once as standing." + bucket: + type: string + enum: + - quarter + - month + - week + description: "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` + under two years, `week` for `quarter`." series: type: array items: @@ -81569,6 +81579,7 @@ components: - range - lanes - standing + - bucket - series - notable additionalProperties: false @@ -81683,12 +81694,6 @@ components: anyOf: - type: string - type: "null" - granularity: - type: string - enum: - - month - - week - - day points: type: array items: @@ -81696,17 +81701,25 @@ components: properties: t: type: string - description: A local calendar date, `YYYY-MM-DD`. + description: The bucket's first day. mean: type: number + description: The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a + pulse-like type's its hourly-mean day value). + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Readings behind the mean. Clients draw a point from fewer than three as thin. required: - t - mean + - count additionalProperties: false + description: Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated. required: - key - unit - - granularity - points additionalProperties: false TimelineReadinessEnvelope: diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 6180427b2..a14be92d3 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -45,7 +45,7 @@ export const GET = apiHandler(async (request: Request) => { } const seriesKeys = parseSeriesKeys(parsed.data.values); if (seriesKeys === null) { - return apiError("Unknown value series", 422, { + return apiError("Unknown value series, or more than six", 422, { errorCode: "timeline.invalid", }); } diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 500d8d2f2..7b3c419fd 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -212,6 +212,22 @@ export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const; export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number]; +/** + * The span one value-series point averages. The server picks it per zoom + * (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a + * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`. + */ +export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const; + +export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number]; + +/** + * The most value series one request may name. Each series is its own row + * under the lanes, drawn in neutral ink and labelled at the left, so the cap + * is the chart's height, not a palette: six rows add 360 px under the lanes. + */ +export const TIMELINE_MAX_SERIES = 6; + /** * The lanes, top to bottom. An empty lane is not sent. Each lane a module * owns is a surface (`timeline-lane:`) and disappears with it. diff --git a/src/lib/day/daily-stats.ts b/src/lib/day/daily-stats.ts index 0645b57dc..a9bcba033 100644 --- a/src/lib/day/daily-stats.ts +++ b/src/lib/day/daily-stats.ts @@ -30,6 +30,15 @@ import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read"; /** Day key → the day's value, per type. */ export type DailyStats = Map>; +/** A day's value with the readings behind it (the picked source's). */ +export interface DayCell { + value: number; + count: number; +} + +/** Day key → the day's value and reading count, per type. */ +export type DailyCells = Map>; + interface StatRow { type: MeasurementType; source: MeasurementSource; @@ -60,7 +69,22 @@ export async function readLocalDailyStats(args: { tz: string; priorityJson: unknown; }): Promise { - const out: DailyStats = new Map(); + return valuesOnly(await readLocalDailyCells(args)); +} + +/** + * {@link readLocalDailyStats} with each day's reading count beside its + * value: the timeline marks a mean that rests on few readings. + */ +export async function readLocalDailyCells(args: { + userId: string; + types: readonly MeasurementType[]; + from: Date; + to: Date; + tz: string; + priorityJson: unknown; +}): Promise { + const out: DailyCells = new Map(); if (args.types.length === 0 || args.from >= args.to) return out; const hourlyTypes = args.types.filter((t) => HOURLY_MEAN_DAY_TYPES.has(t)); // Every value is parameter-bound; the enum casts are literal SQL. The hour @@ -87,7 +111,17 @@ export async function readLocalDailyStats(args: { AND "measured_at" < ${args.to} GROUP BY 1, 2, 3, 4 `; - return foldDailyStats(rows, args.priorityJson); + return foldDailyCells(rows, args.priorityJson); +} + +function valuesOnly(cells: DailyCells): DailyStats { + const out: DailyStats = new Map(); + for (const [type, days] of cells) { + const perDay = new Map(); + for (const [day, cell] of days) perDay.set(day, cell.value); + out.set(type, perDay); + } + return out; } /** @@ -99,6 +133,14 @@ export function foldDailyStats( rows: readonly StatRow[], priorityJson: unknown, ): DailyStats { + return valuesOnly(foldDailyCells(rows, priorityJson)); +} + +/** {@link foldDailyStats} with the picked source's reading count per day. */ +export function foldDailyCells( + rows: readonly StatRow[], + priorityJson: unknown, +): DailyCells { // type → day → source → hour cells const nested = new Map< MeasurementType, @@ -114,7 +156,7 @@ export function foldDailyStats( cells.push({ total: Number(row.total), count: Number(row.count) }); } - const out: DailyStats = new Map(); + const out: DailyCells = new Map(); for (const [type, days] of nested) { const cumulative = isCumulativeDaySumType(type); const hourly = HOURLY_MEAN_DAY_TYPES.has(type); @@ -141,9 +183,12 @@ export function foldDailyStats( } } const picked = collapseRollupRowsBySource(cells, type, priorityJson); - const perDay = new Map(); + const perDay = new Map(); for (const cell of picked) { - perDay.set(dateOnlyKey(cell.bucketStart), cell.value); + perDay.set(dateOnlyKey(cell.bucketStart), { + value: cell.value, + count: cell.count, + }); } out.set(type, perDay); } diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 1a535c4b1..6cfa4e118 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -22,6 +22,7 @@ import { LIFE_EVENT_NOTE_MAX, LIFE_EVENT_PRECISIONS, LIFE_EVENT_TITLE_MAX, + TIMELINE_BUCKETS, TIMELINE_ITEM_KINDS, TIMELINE_LANE_KEYS, TIMELINE_READINESS_KEYS, @@ -267,7 +268,7 @@ export const timelineQuerySchema = z.object({ .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`.", + "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", ), }); @@ -304,8 +305,27 @@ const timelineSeries = z .object({ key: z.string().describe("A `MeasurementType` or `MOOD`."), unit: z.string().nullable(), - granularity: z.enum(["month", "week", "day"]), - points: z.array(z.object({ t: dateKeySchema, mean: z.number() })), + points: z + .array( + z.object({ + t: dateKeySchema.describe("The bucket's first day."), + mean: z + .number() + .describe( + "The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a pulse-like type's its hourly-mean day value).", + ), + count: z + .number() + .int() + .min(1) + .describe( + "Readings behind the mean. Clients draw a point from fewer than three as thin.", + ), + }), + ) + .describe( + "Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated.", + ), }) .meta({ id: "TimelineSeries" }); @@ -328,6 +348,11 @@ export const timelineResponseSchema = z standing: z .array(timelineStanding) .describe("Things without a start: shown once as standing."), + bucket: z + .enum(TIMELINE_BUCKETS) + .describe( + "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`.", + ), series: z.array(timelineSeries), notable: z.array( z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }), diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index 0e0506328..b9aa6d4c4 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it } from "vitest"; -import { overlaps, parseSeriesKeys } from "../load-timeline"; +import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline"; import { coveredWeeks, READINESS_WEEKS } from "../readiness"; -import { bucketKey, foldDays } from "../series"; +import { bucketKey, foldDays, foldMonths } from "../series"; describe("buckets", () => { it("names a bucket by its first local day, weeks from Monday", () => { @@ -13,23 +13,65 @@ describe("buckets", () => { // 2026-03-29 is a Sunday; its week began on Monday the 23rd. expect(bucketKey("2026-03-29", "week")).toBe("2026-03-23"); expect(bucketKey("2026-03-23", "week")).toBe("2026-03-23"); - expect(bucketKey("2026-03-29", "day")).toBe("2026-03-29"); + expect(bucketKey("2026-03-29", "quarter")).toBe("2026-01-01"); + expect(bucketKey("2026-04-01", "quarter")).toBe("2026-04-01"); + expect(bucketKey("2026-12-31", "quarter")).toBe("2026-10-01"); }); - it("weighs each day once in a bucket", () => { + it("weighs each day once in a bucket and counts the readings behind it", () => { const points = foldDays( new Map([ - ["2026-03-01", 100], - ["2026-03-02", 110], - ["2026-04-01", 90], + ["2026-03-01", { value: 100, count: 3 }], + ["2026-03-02", { value: 110, count: 1 }], + ["2026-04-01", { value: 90, count: 1 }], ]), "month", ); expect(points).toEqual([ - { t: "2026-03-01", mean: 105 }, - { t: "2026-04-01", mean: 90 }, + { t: "2026-03-01", mean: 105, count: 4 }, + { t: "2026-04-01", mean: 90, count: 1 }, ]); }); + + it("leaves a bucket without a reading out rather than filling it", () => { + const points = foldDays( + new Map([ + ["2026-02-10", { value: 120, count: 2 }], + ["2026-06-10", { value: 130, count: 2 }], + ["2026-08-10", { value: 125, count: 1 }], + ]), + "month", + ); + expect(points.map((p) => p.t)).toEqual([ + "2026-02-01", + "2026-06-01", + "2026-08-01", + ]); + }); + + it("folds rolled-up months into quarters, each month weighing its days", () => { + expect( + foldMonths( + [ + { t: "2026-01-01", mean: 100, days: 30, count: 30 }, + { t: "2026-02-01", mean: 130, days: 10, count: 12 }, + { t: "2026-04-01", mean: 90, days: 2, count: 2 }, + ], + "quarter", + ), + ).toEqual([ + // (100·30 + 130·10) / 40, not the plain mean of the two months (115). + { t: "2026-01-01", mean: 107.5, count: 42 }, + { t: "2026-04-01", mean: 90, count: 2 }, + ]); + }); + + it("picks the bucket by zoom, quarters only for a long `all`", () => { + expect(timelineBucket("quarter", "2026-01-01", "2026-03-31")).toBe("week"); + expect(timelineBucket("year", "2025-04-01", "2026-03-31")).toBe("month"); + expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month"); + expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter"); + }); }); describe("window", () => { @@ -55,6 +97,19 @@ describe("window", () => { expect(parseSeriesKeys("WEIGHT,MOOD,WEIGHT")).toEqual(["WEIGHT", "MOOD"]); expect(parseSeriesKeys("WEIGHT,DROP TABLE")).toBeNull(); }); + + it("accepts up to six series and refuses a seventh", () => { + const six = [ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "WEIGHT", + "PULSE", + "MOOD", + "BODY_FAT", + ]; + expect(parseSeriesKeys(six.join(","))).toEqual(six); + expect(parseSeriesKeys([...six, "BLOOD_GLUCOSE"].join(","))).toBeNull(); + }); }); describe("readiness weeks", () => { diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index 0b0691b54..bc97b723d 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -18,6 +18,8 @@ import type { MeasurementType } from "@/generated/prisma/enums"; import { DAY_NOTABLE_MAX_SPAN_DAYS, TIMELINE_LANE_KEYS, + TIMELINE_MAX_SERIES, + type TimelineBucket, type TimelineItem, type TimelineQuery, type TimelineResponse, @@ -33,12 +35,8 @@ import { type StandingItem, type TimelineAccess, } from "@/lib/timeline/lanes"; -import { - loadTimelineSeries, - MOOD_SERIES_KEY, - type SeriesGranularity, -} from "@/lib/timeline/series"; -import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series"; +import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; /** The series sent when the request names none. */ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ @@ -47,11 +45,27 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [ "WEIGHT", ]; -const ZOOM_GRANULARITY: Readonly> = { - all: "month", - year: "week", - quarter: "day", -}; +/** An `all` window longer than this many days averages quarters. */ +const QUARTER_BUCKET_MIN_DAYS = 2 * 365; + +/** + * The span one series point averages. A point per day or per week over + * years drew a line of fragments wherever readings came in bursts, so the + * bucket grows with the window: weeks in three months (thirteen points), + * months in a year (twelve), quarters once `all` reaches back more than two + * years. An `all` window shorter than that keeps months, so a young record + * does not shrink to a handful of quarters. + */ +export function timelineBucket( + zoom: TimelineZoom, + from: string, + to: string, +): TimelineBucket { + if (zoom === "quarter") return "week"; + if (zoom === "year") return "month"; + const days = daysBetweenDateKeys(from, to); + return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month"; +} const ZOOM_DAYS: Readonly, number>> = { year: 365, @@ -64,7 +78,8 @@ const MEASUREMENT_TYPE_SET: ReadonlySet = new Set( /** * The series keys a `values` parameter names, or null when one is not a - * series this API knows (the route answers 422). + * series this API knows or there are more than {@link TIMELINE_MAX_SERIES} + * (the route answers 422). */ export function parseSeriesKeys(raw: string | undefined): string[] | null { if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES]; @@ -76,7 +91,7 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null { .filter((k) => k.length > 0), ), ]; - if (keys.length > 8) return null; + if (keys.length > TIMELINE_MAX_SERIES) return null; for (const key of keys) { if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null; } @@ -158,6 +173,7 @@ export async function loadTimeline(args: { shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) > from ? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) : from; + const bucket = timelineBucket(query.zoom, from, to); const [series, notable] = await Promise.all([ loadTimelineSeries({ userId: recordId, @@ -165,7 +181,7 @@ export async function loadTimeline(args: { from, to, tz, - granularity: ZOOM_GRANULARITY[query.zoom], + bucket, priorityJson, now, }), @@ -187,6 +203,7 @@ export async function loadTimeline(args: { range: { from, to, dataFrom }, lanes, standing, + bucket, series, notable: notable.map(({ date, kind }) => ({ date, kind })), }; diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index aa51c1024..9dd6a56b7 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -1,76 +1,137 @@ /** - * Value series for the timeline: monthly, weekly or daily means beside the - * lanes (v1.42, #613). + * Value series for the timeline: quarterly, monthly or weekly means beside + * the lanes (v1.42, #613). * - * Three ways in, by zoom: + * The bucket comes with the zoom (`timelineBucket` in `load-timeline.ts`), + * and the source with the window's length: * - * - **all (month)**: the months of the last five years come from the rollup - * tier, MONTH buckets folded from the canonical-source DAY rows. Those - * buckets cut at UTC midnight, so a reading in the first or last hours of - * a month can sit in the neighbouring month. For a monthly mean over + * - **longer than a year** (`all`): the last five years come from the + * rollup tier, MONTH buckets folded from the canonical-source DAY rows + * and, for quarters, folded once more here, each month weighing its days. + * Those buckets cut at UTC midnight, so a reading in the first or last + * hours of a month can sit in the neighbouring month. For a mean over * dozens of days that is a rounding matter, and it is accepted here; the * day view, where it is not, never reads the rollups. The rollups fold - * only five years (`ROLLUP_FOLD_WINDOW_MS`); the months before that come + * only five years (`ROLLUP_FOLD_WINDOW_MS`); the buckets before that come * from one live query over the record's own local days, cached per record * in the analytics bucket, which every measurement write marks stale. - * - **year (week)** and **quarter (day)**: live over the record's local - * days, a year at most. + * - **a year or less** (`year`, `quarter`, a short `all`): live over the + * record's local days. * * Every value is the mean of the bucket's days, each day weighing one (a - * cumulative type's days are its day totals), matching the charts. A series + * cumulative type's days are its day totals, a pulse-like type's day is the + * mean of its hourly means, `daily-stats.ts`), matching the charts. Each + * point carries the readings behind it, so the chart can mark a mean that + * rests on one or two. A bucket without a reading is not sent, and a series * without a point is not sent. `MOOD` is the mood score, read from the - * entries' own dates. + * entries' own dates; its count is the entries. */ import type { MeasurementType } from "@/generated/prisma/enums"; import { caches, cached } from "@/lib/cache/server-cache"; -import { readLocalDailyStats } from "@/lib/day/daily-stats"; -import type { TimelineSeries } from "@/lib/day/contract"; +import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats"; +import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract"; import { prisma } from "@/lib/db"; import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read"; import { ROLLUP_FOLD_WINDOW_MS } from "@/lib/rollups/measurement-rollups"; import { dateOnlyKey } from "@/lib/tz/date-only"; -import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format"; +import { + daysBetweenDateKeys, + shiftDateKey, + userDayKey, + weekdayOfDateKey, +} from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; -export type SeriesGranularity = TimelineSeries["granularity"]; +export type SeriesPoint = TimelineSeries["points"][number]; /** - * How long the months before the fold window stay cached. They change only + * How long the buckets before the fold window stay cached. They change only * when an old reading is imported or deleted, and either write marks the * entry stale at once. */ const PRE_FOLD_TTL_MS = 6 * 3_600_000; +/** Windows longer than this read the rollup tier rather than live days. */ +const LIVE_WINDOW_MAX_DAYS = 366; + /** The series key for the mood score. */ export const MOOD_SERIES_KEY = "MOOD"; /** The bucket a local day belongs to, named by its first day. */ -export function bucketKey(day: string, granularity: SeriesGranularity): string { - if (granularity === "day") return day; - if (granularity === "month") return `${day.slice(0, 7)}-01`; +export function bucketKey(day: string, bucket: TimelineBucket): string { + if (bucket === "month") return `${day.slice(0, 7)}-01`; + if (bucket === "quarter") { + const month = Number(day.slice(5, 7)); + const first = month - ((month - 1) % 3); + return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`; + } // Weeks start on Monday. const offset = (weekdayOfDateKey(day) + 6) % 7; return shiftDateKey(day, -offset); } -/** Fold a day series into buckets: the mean of the bucket's days. */ +const round2 = (v: number) => Math.round(v * 100) / 100; + +/** + * Fold a day series into buckets: the mean of the bucket's days, and the + * readings behind them. + */ export function foldDays( - days: ReadonlyMap, - granularity: SeriesGranularity, -): Array<{ t: string; mean: number }> { - const buckets = new Map(); - for (const [day, value] of days) { - const key = bucketKey(day, granularity); - const slot = buckets.get(key) ?? { sum: 0, n: 0 }; - slot.sum += value; + days: ReadonlyMap, + bucket: TimelineBucket, +): SeriesPoint[] { + const buckets = new Map(); + for (const [day, cell] of days) { + const key = bucketKey(day, bucket); + const slot = buckets.get(key) ?? { sum: 0, n: 0, count: 0 }; + slot.sum += cell.value; slot.n += 1; + slot.count += cell.count; buckets.set(key, slot); } return [...buckets] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([t, { sum, n }]) => ({ t, mean: Math.round((sum / n) * 100) / 100 })); + .map(([t, { sum, n, count }]) => ({ t, mean: round2(sum / n), count })); +} + +/** A month from the rollup tier: its day mean, its days, its readings. */ +export interface RolledMonth { + t: string; + mean: number; + days: number; + count: number; +} + +/** + * Fold rolled-up months into `bucket`: each month weighs its days, so a + * quarter's mean is the mean of its days exactly as a live fold makes it. + */ +export function foldMonths( + months: readonly RolledMonth[], + bucket: Exclude, +): SeriesPoint[] { + const buckets = new Map< + string, + { sum: number; days: number; count: number } + >(); + for (const m of months) { + const key = bucketKey(m.t, bucket); + const slot = buckets.get(key) ?? { sum: 0, days: 0, count: 0 }; + slot.sum += m.mean * m.days; + slot.days += m.days; + slot.count += m.count; + buckets.set(key, slot); + } + return [...buckets] + .sort((a, b) => a[0].localeCompare(b[0])) + .filter(([, b]) => b.days > 0 && b.count > 0) + .map(([t, { sum, days, count }]) => ({ + t, + mean: round2(sum / days), + count, + })); } async function latestUnit( @@ -85,24 +146,25 @@ async function latestUnit( return row?.unit ?? null; } -/** Months older than the rollup fold window, live and cached. */ -async function preFoldMonths(args: { +/** Buckets older than the rollup fold window, live and cached. */ +async function preFoldBuckets(args: { userId: string; type: MeasurementType; tz: string; from: Date; boundary: Date; + bucket: TimelineBucket; priorityJson: unknown; -}): Promise> { +}): Promise { if (args.from >= args.boundary) return []; // Keyed under the record's `${userId}|` prefix: every measurement write // marks it stale with the rest of the analytics bucket. - const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`; + const key = `${args.userId}|timeline-pre-fold|${args.type}|${args.bucket}|${args.tz}|${dateOnlyKey(args.from)}|${dateOnlyKey(args.boundary)}`; return cached( caches.analytics, key, async () => { - const stats = await readLocalDailyStats({ + const cells = await readLocalDailyCells({ userId: args.userId, types: [args.type], from: args.from, @@ -110,20 +172,20 @@ async function preFoldMonths(args: { tz: args.tz, priorityJson: args.priorityJson, }); - return foldDays(stats.get(args.type) ?? new Map(), "month"); + return foldDays(cells.get(args.type) ?? new Map(), args.bucket); }, undefined, PRE_FOLD_TTL_MS, - ) as Promise>; + ) as Promise; } -async function monthlyFromRollups(args: { +async function monthsFromRollups(args: { userId: string; type: MeasurementType; from: Date; to: Date; priorityJson: unknown; -}): Promise> { +}): Promise { const rows = await readCanonicalRollupBuckets({ userId: args.userId, type: args.type, @@ -142,59 +204,67 @@ async function monthlyFromRollups(args: { : r.mean; return { t: `${dateOnlyKey(r.bucketStart).slice(0, 7)}-01`, - mean: Math.round(value * 100) / 100, + mean: value, + days: Math.max(1, r.days), + count: r.count, }; }); } +/** Whether `[from, to]` is long enough to read the rollup tier. */ +function readsRollups(from: string, to: string): boolean { + const days = daysBetweenDateKeys(from, to); + return days >= LIVE_WINDOW_MAX_DAYS; +} + async function measurementSeries(args: { userId: string; type: MeasurementType; from: string; to: string; tz: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; priorityJson: unknown; now: Date; }): Promise { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); - let points: Array<{ t: string; mean: number }>; - if (args.granularity === "month") { - // The fold boundary rounded up to a month start, so no month is half + let points: SeriesPoint[]; + if (args.bucket !== "week" && readsRollups(args.from, args.to)) { + const bucket = args.bucket; + // The fold boundary rounded up to a bucket start, so no bucket is half // live and half rolled up. const raw = new Date(args.now.getTime() - ROLLUP_FOLD_WINDOW_MS); const boundaryKey = bucketKey( - shiftDateKey(userDayKey(raw, args.tz), 31), - "month", + shiftDateKey(userDayKey(raw, args.tz), bucket === "quarter" ? 92 : 31), + bucket, ); const boundary = startOfLocalDayKey(boundaryKey, args.tz); const [older, recent] = await Promise.all([ - preFoldMonths({ + preFoldBuckets({ userId: args.userId, type: args.type, tz: args.tz, from: start, boundary: boundary < end ? boundary : end, + bucket, priorityJson: args.priorityJson, }), end > boundary - ? monthlyFromRollups({ + ? monthsFromRollups({ userId: args.userId, type: args.type, from: boundary > start ? boundary : start, to: end, priorityJson: args.priorityJson, - }) + }).then((months) => foldMonths(months, bucket)) : Promise.resolve([]), ]); - const merged = new Map(); - for (const p of [...older, ...recent]) merged.set(p.t, p.mean); - points = [...merged] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([t, mean]) => ({ t, mean })); + const merged = new Map(); + for (const p of [...older, ...recent]) merged.set(p.t, p); + points = [...merged.values()].sort((a, b) => a.t.localeCompare(b.t)); } else { - const stats = await readLocalDailyStats({ + const cells = await readLocalDailyCells({ userId: args.userId, types: [args.type], from: start, @@ -202,13 +272,12 @@ async function measurementSeries(args: { tz: args.tz, priorityJson: args.priorityJson, }); - points = foldDays(stats.get(args.type) ?? new Map(), args.granularity); + points = foldDays(cells.get(args.type) ?? new Map(), args.bucket); } if (points.length === 0) return null; return { key: args.type, unit: await latestUnit(args.userId, args.type), - granularity: args.granularity, points, }; } @@ -217,7 +286,7 @@ async function moodSeries(args: { userId: string; from: string; to: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; }): Promise { const rows = await prisma.moodEntry.groupBy({ by: ["date"], @@ -227,17 +296,19 @@ async function moodSeries(args: { date: { gte: args.from, lte: args.to }, }, _avg: { score: true }, + _count: { score: true }, }); - const days = new Map(); + const days = new Map(); for (const row of rows) { - if (row._avg.score !== null) days.set(row.date, row._avg.score); + if (row._avg.score !== null && row._count.score > 0) { + days.set(row.date, { value: row._avg.score, count: row._count.score }); + } } - const points = foldDays(days, args.granularity); + const points = foldDays(days, args.bucket); if (points.length === 0) return null; return { key: MOOD_SERIES_KEY, unit: null, - granularity: args.granularity, points, }; } @@ -248,7 +319,7 @@ export async function loadTimelineSeries(args: { from: string; to: string; tz: string; - granularity: SeriesGranularity; + bucket: TimelineBucket; priorityJson: unknown; now?: Date; }): Promise { diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index 1633cd3cd..35df59adf 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -837,6 +837,125 @@ describe("timeline", () => { expect(byKey.values.status).toBe("empty"); expect(inventory.verdict).toBe("thin"); }); + + it("averages value series per bucket, counts the readings and leaves gaps out", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tl-values", { timeline: true }); + const reading = (at: string, value: number) => ({ + userId: owner.id, + type: "BLOOD_PRESSURE_SYS" as const, + value, + unit: "mmHg", + measuredAt: new Date(at), + }); + await db.measurement.createMany({ + data: [ + // Two readings on one January day, one on another: three readings, + // two days, each day weighing one. + reading("2026-01-10T07:00:00.000Z", 120), + reading("2026-01-10T19:00:00.000Z", 130), + reading("2026-01-20T07:00:00.000Z", 135), + // Nothing in February. + reading("2026-03-05T07:00:00.000Z", 128), + ], + }); + await signIn(owner.id); + + const { GET } = await import("@/app/api/timeline/route"); + const timeline = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=year&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(timeline.bucket).toBe("month"); + expect(timeline.series).toEqual([ + { + key: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + points: [ + { t: "2026-01-01", mean: 130, count: 3 }, + { t: "2026-03-01", mean: 128, count: 1 }, + ], + }, + ]); + + const weekly = await json( + await call( + GET as Handler, + "GET", + "/api/timeline?zoom=quarter&to=2026-03-31&values=BLOOD_PRESSURE_SYS", + ), + ); + expect(weekly.bucket).toBe("week"); + expect(weekly.series[0].points.map((p) => [p.t, p.count])).toEqual([ + ["2026-01-05", 2], + ["2026-01-19", 1], + ["2026-03-02", 1], + ]); + + const six = + "BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD"; + expect( + (await call(GET as Handler, "GET", `/api/timeline?values=${six}`)).status, + ).toBe(200); + expect( + ( + await call( + GET as Handler, + "GET", + `/api/timeline?values=${six},BLOOD_GLUCOSE`, + ) + ).status, + ).toBe(422); + }); + + it("averages quarters over the years, live before the fold window and rolled up after it", async () => { + const db = getPrismaClient(); + const owner = await makeUser("tl-quarters", { timeline: true }); + const reading = (at: string, value: number) => ({ + userId: owner.id, + type: "WEIGHT" as const, + value, + unit: "kg", + measuredAt: new Date(at), + }); + await db.measurement.createMany({ + data: [ + // Before the five-year fold window: read live. + reading("2018-02-10T07:00:00.000Z", 90), + reading("2018-03-10T07:00:00.000Z", 88), + // Inside it: read from the rollups. January has three days, + // February one; the quarter's mean weighs the four days alike. + reading("2024-01-10T07:00:00.000Z", 80), + reading("2024-01-11T07:00:00.000Z", 80), + reading("2024-01-12T07:00:00.000Z", 80), + reading("2024-02-10T07:00:00.000Z", 84), + // Nothing from April 2024 to March 2025. + reading("2025-05-10T07:00:00.000Z", 79), + ], + }); + const { recomputeUserRollups } = + await import("@/lib/rollups/measurement-rollups"); + await recomputeUserRollups(owner.id, { + types: ["WEIGHT"], + from: new Date("2018-01-01T00:00:00.000Z"), + to: new Date(), + }); + await signIn(owner.id); + + const { GET } = await import("@/app/api/timeline/route"); + const timeline = await json( + await call(GET as Handler, "GET", "/api/timeline?zoom=all&values=WEIGHT"), + ); + expect(timeline.bucket).toBe("quarter"); + expect(timeline.series[0].points).toEqual([ + { t: "2018-01-01", mean: 89, count: 2 }, + { t: "2024-01-01", mean: 81, count: 4 }, + { t: "2025-04-01", mean: 79, count: 1 }, + ]); + }); }); describe("performance smoke", () => { From fcc6f404106efe8816fe6908dc00ffebde070533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 09:03:42 +0200 Subject: [PATCH 2/2] feat(timeline): keep gaps in value lines, mark thin means, allow six lines Every bucket with a reading gets its point; one with fewer than three readings is drawn hollow. A short gap (one quarter, two months, four weeks) is bridged with a dashed stroke between the two real points; a longer one leaves the line open. The selection bar and the value table for screen readers name each bucket's readings and say "no value" where there is none. The phone chronicle names each bucket once, at its newest month. The value menu takes up to six lines. The legend's causality line is gone with its keys in all seven bundles. --- CHANGELOG.md | 5 +- e2e/timeline.spec.ts | 86 +++++++++- e2e/utils/mock-timeline.ts | 148 ++++++++++++++---- messages/de.json | 19 ++- messages/en.json | 19 ++- messages/es.json | 19 ++- messages/fr.json | 19 ++- messages/it.json | 19 ++- messages/ko.json | 19 ++- messages/pl.json | 19 ++- .../__tests__/timeline-components.test.tsx | 93 ++++++++++- .../timeline/__tests__/timeline-fixture.ts | 22 +-- .../__tests__/timeline-geometry.test.ts | 141 +++++++++++++++-- src/components/timeline/selection-bar.tsx | 51 +++--- src/components/timeline/series-format.ts | 131 +++++++++++++++- src/components/timeline/timeline-chart.tsx | 136 +++++++++++++++- .../timeline/timeline-chronicle.tsx | 42 +++-- src/components/timeline/timeline-dates.ts | 58 +++++++ src/components/timeline/timeline-geometry.ts | 110 +++++++++---- src/components/timeline/timeline-menus.tsx | 6 +- src/components/timeline/timeline-view.tsx | 52 ++++-- 21 files changed, 1011 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bdae4fa6..23cd87923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,9 @@ an earlier fold computed from part of a day. - **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with lanes for life events, conditions, allergies, medications, vaccinations, visits and procedures, labs and documents, a year, month or week grid and - up to three monthly-mean value lines; a chronicle, newest first, on a - phone. Switching the module on opens a readiness inventory that rates + up to six value lines of quarterly, monthly or weekly means by zoom (a + short gap dashed, a longer one left open, a mean from fewer than three + readings drawn hollow); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates each lane and links each gap; it stays available as Data coverage. - **Life events.** Title, kind (family, home, work, loss, other), a start at day, month or year precision, optional end and note; title and note diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 14723d0b6..8fcdc3bb9 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -10,8 +10,11 @@ * day. * 4. A life event is entered through the sheet and posted in the * contract's shape. - * 5. On a phone the chronicle replaces the chart, says its empty months - * out loud, and a row opens its day. + * 5. The value lines: quarterly means over the years, a missing quarter + * left out, a short gap bridged dashed, a thin quarter hollow, and up + * to six lines. + * 6. On a phone the chronicle replaces the chart, says its empty months + * out loud, names each quarter's means once, and a row opens its day. * * The timeline's server routes are mocked with the contract's shapes * (`utils/mock-timeline.ts`); the module switch is the real one. Every @@ -22,7 +25,7 @@ import type { Page } from "@playwright/test"; import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup"; import { expect, test } from "./setup/test"; -import { mockTimeline } from "./utils/mock-timeline"; +import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline"; test.use({ storageState: TIMELINE_STORAGE_STATE_PATH }); test.describe.configure({ mode: "serial" }); @@ -119,11 +122,10 @@ test("the timeline hands the selected day to the day view through ?day=", async await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1); await expect( page.locator('[data-slot="timeline-legend-causality"]'), - ).toBeVisible(); - // At most three value lines are asked for. + ).toHaveCount(0); const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? []; expect(asked.length).toBeGreaterThan(0); - expect(asked.length).toBeLessThanOrEqual(3); + expect(asked.length).toBeLessThanOrEqual(6); // The keyboard: one step back and Enter opens that day. const before = await page @@ -157,6 +159,69 @@ test("the timeline hands the selected day to the day view through ?day=", async await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); }); +test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]'); + await expect(sys).toBeVisible({ timeout: 20_000 }); + await expect( + page.locator('[data-slot="timeline-legend-mean"]'), + ).toHaveAttribute("data-bucket", "quarter"); + + const point = (t: string) => + sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`); + // A missing quarter stays missing: no point is drawn for it. + for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0); + // The quarters around Q2 2023 are joined by a dashed bridge; the three + // missing quarters of 2021 are not (one bridge in the whole line). + await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount( + 1, + ); + await expect(point("2023-01-01")).toHaveCount(1); + await expect(point("2023-07-01")).toHaveCount(1); + // Two readings: drawn hollow; four and more: filled. + await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true"); + await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false"); + + // The value table keeps the missing quarter as a row of its own. + await expect( + page.locator( + '[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]', + ), + ).toBeAttached(); + + // Six value lines can be chosen; a seventh cannot. + await page.locator('[data-slot="timeline-values-trigger"]').click(); + const options = page.locator('[data-slot="timeline-values-option"]'); + await expect(options.first()).toBeVisible(); + const count = await options.count(); + for (let i = 0; i < count; i++) { + const checked = page.locator( + '[data-slot="timeline-values-option"][aria-checked="true"]', + ); + if ((await checked.count()) >= 6) break; + const option = options.nth(i); + if ((await option.getAttribute("aria-checked")) === "true") continue; + await option.click(); + } + await expect( + page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'), + ).toHaveCount(6); + await expect( + page.locator( + '[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]', + ), + ).toHaveCount(count - 6); + await page.keyboard.press("Escape"); + await expect + .poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length) + .toBe(6); + await expect(page.locator("svg [data-series]")).toHaveCount(6); +}); + async function setTimelineModuleOn(page: Page) { await page.goto("/settings/modules"); await setTimelineModule(page, true); @@ -218,6 +283,15 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn await expect( page.locator('[data-slot="timeline-chronicle-gap"]').first(), ).toBeVisible(); + // Quarterly means, each quarter named once, at its newest listed month. + const means = page.locator('[data-slot="timeline-chronicle-means"]'); + await expect(means.first()).toBeVisible(); + const buckets = await means.evaluateAll((els) => + els.map((el) => el.getAttribute("data-bucket") ?? ""), + ); + expect(buckets.length).toBeGreaterThan(0); + expect(new Set(buckets).size).toBe(buckets.length); + for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/); await page .locator( diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index 0159c1a69..f2f2542ce 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -46,76 +46,142 @@ function rng(seed: number) { }; } -function monthly( - from: string, - to: string, - value: (i: number, monthKey: string) => number, -) { - const out: Array<{ t: string; mean: number }> = []; +type Bucket = "quarter" | "month" | "week"; + +/** The bucket the server picks for a zoom (`timelineBucket`). */ +export function bucketFor(zoom: string): Bucket { + return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter"; +} + +function dayNum(key: string): number { + const [y, m, d] = key.split("-").map(Number); + return Math.round(Date.UTC(y, m - 1, d) / 86_400_000); +} + +function dayStr(n: number): string { + return new Date(n * 86_400_000).toISOString().slice(0, 10); +} + +/** Bucket starts from the one holding `from` to the one holding `to`. */ +function bucketStarts(from: string, to: string, bucket: Bucket): string[] { + const out: string[] = []; + if (bucket === "week") { + const n = dayNum(from); + // 1970-01-01 was a Thursday: day 4 is the first Monday. + for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) { + out.push(dayStr(d)); + } + return out; + } + const step = bucket === "quarter" ? 3 : 1; let [y, m] = from.split("-").map(Number); + m -= (m - 1) % step; const [ty, tm] = to.split("-").map(Number); - let i = 0; while (y < ty || (y === ty && m <= tm)) { - const key = `${y}-${String(m).padStart(2, "0")}-01`; - out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 }); - i++; - m++; + out.push(`${y}-${String(m).padStart(2, "0")}-01`); + m += step; if (m > 12) { - m = 1; + m -= 12; y++; } } return out; } -function series(dataFrom: string, today: string) { +/** + * Systolic pressure leaves gaps the chart has to show honestly: in the + * quarterly view one missing quarter (Q2 2023, bridged dashed) and three + * (2021, the line breaks). Q1 2022 rests on two readings in every series. + */ +export const SYS_MISSING = new Set([ + "2021-01-01", + "2021-04-01", + "2021-07-01", + "2023-04-01", +]); +export const SYS_THIN = "2022-01-01"; + +function points( + from: string, + to: string, + bucket: Bucket, + value: (i: number, key: string) => number, + skip: (key: string) => boolean = () => false, +) { + const starts = bucketStarts(from, to, bucket); + return starts + .map((key, i) => ({ + t: key, + mean: Math.round(value(i / starts.length, key) * 10) / 10, + count: key === SYS_THIN ? 2 : 4 + (i % 9), + })) + .filter((p) => !skip(p.t)); +} + +function series(dataFrom: string, today: string, bucket: Bucket) { const r = rng(11); - const months = monthly(dataFrom, today, () => 0).length; return [ { key: "BLOOD_PRESSURE_SYS", unit: "mmHg", - granularity: "month", - points: monthly( + points: points( dataFrom, today, - (i) => 147 - (18 * i) / months + (r() - 0.5) * 4, + bucket, + (f) => 147 - 18 * f + (r() - 0.5) * 4, + (t) => bucket === "quarter" && SYS_MISSING.has(t), ), }, { key: "BLOOD_PRESSURE_DIA", unit: "mmHg", - granularity: "month", - points: monthly( + points: points( dataFrom, today, - (i) => 92 - (10 * i) / months + (r() - 0.5) * 3, + bucket, + (f) => 92 - 10 * f + (r() - 0.5) * 3, ), }, { key: "WEIGHT", unit: "kg", - granularity: "month", - points: monthly( + points: points( dataFrom, today, - (i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6, + bucket, + (f) => 89 - 6.5 * f + (r() - 0.5) * 0.6, ), }, { key: "RESTING_HEART_RATE", unit: "bpm", - granularity: "month", - points: monthly(dataFrom, today, (i, k) => { + points: points(dataFrom, today, bucket, (f, k) => { const month = Number(k.slice(5, 7)); return ( - 68 - - (5 * i) / months + - Math.sin((month / 12) * 6.28) * 1.2 + - (r() - 0.5) * 1.2 + 68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2 ); }), }, + { + key: "PULSE", + unit: "bpm", + points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()), + }, + { + key: "BODY_FAT", + unit: "%", + points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4), + }, + { + key: "ACTIVITY_STEPS", + unit: "steps", + points: points( + dataFrom, + today, + bucket, + (f) => 7200 + 900 * f + r() * 400, + ), + }, ]; } @@ -350,7 +416,8 @@ export function fullTimeline(today: string) { }, ], standing: [], - series: series("2019-01-01", today), + bucket: "quarter" as Bucket, + series: series("2019-01-01", today, "quarter"), notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } @@ -468,7 +535,8 @@ export function readyTimeline(today: string) { }, ], standing: [], - series: series("2024-05-01", today).filter( + bucket: "quarter" as Bucket, + series: series("2024-05-01", today, "quarter").filter( (s) => s.key !== "RESTING_HEART_RATE", ), notable: [], @@ -673,10 +741,24 @@ export async function mockTimeline( const values = (url.searchParams.get("values") ?? "") .split(",") .filter(Boolean); + const zoom = url.searchParams.get("zoom") ?? "all"; + const bucket = bucketFor(zoom); + const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + const all = + bucket === body.bucket ? body.series : series(dataFrom, today, bucket); return json(route, 200, { ...body, - zoom: url.searchParams.get("zoom") ?? "all", - series: body.series.filter((s) => values.includes(s.key)), + zoom, + bucket, + // Any other value the menu offers answers a flat line of its own. + series: values.map( + (key) => + all.find((s) => s.key === key) ?? { + key, + unit: null, + points: points(dataFrom, today, bucket, (f) => 50 + 10 * f), + }, + ), }); }); await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => { diff --git a/messages/de.json b/messages/de.json index b7cc77b50..1359c9ab7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Zeitleiste", "subtitle": "Was wann war, neben deinen Werten.", - "legend": "Untereinander heißt gleichzeitig, nicht ursächlich.", "standing": "Dauerhaft", "startMissing": "Startdatum fehlt", "openDay": "{date} öffnen", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Zeitraum", "legendEvent": "Ereignis", - "legendMean": "Monatsmittel", + "legendMeanQuarter": "Quartalsmittel", + "legendMeanMonth": "Monatsmittel", + "legendMeanWeek": "Wochenmittel", + "legendThin": "Weniger als 3 Messungen", "today": "Heute", "noDataYet": "Noch keine Daten", "layers": "Ebenen", @@ -12416,20 +12418,27 @@ "description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier." }, "values": { - "menuLabel": "Bis zu drei Werte", + "menuLabel": "Bis zu sechs Werte", "none": "Keine Werte", "noneInWindow": "Keine Werte in diesem Zeitraum", "series": { "BLOOD_PRESSURE_SYS": "Blutdruck sys.", "BLOOD_PRESSURE_DIA": "Blutdruck dia." - } + }, + "noValue": "kein Wert", + "bloodPressure": "Blutdruck", + "readingsOne": "1 Messung", + "readingsFew": "Schnitt aus {count} Messungen", + "readingsOther": "Schnitt aus {count} Messungen" }, "table": { "caption": "Einträge der Zeitleiste", "lane": "Spur", "entry": "Eintrag", "start": "Beginn", - "end": "Ende" + "end": "Ende", + "seriesCaption": "Mittelwerte nach Zeitraum", + "period": "Zeitraum" }, "selection": { "empty": "Keine Einträge in diesem Monat", diff --git a/messages/en.json b/messages/en.json index bdea7e6a7..b4bf7793e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Timeline", "subtitle": "What happened when, beside your values.", - "legend": "Stacked means at the same time, not cause and effect.", "standing": "Ongoing", "startMissing": "Start date missing", "openDay": "Open {date}", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Period", "legendEvent": "Event", - "legendMean": "Monthly mean", + "legendMeanQuarter": "Quarterly mean", + "legendMeanMonth": "Monthly mean", + "legendMeanWeek": "Weekly mean", + "legendThin": "Fewer than 3 readings", "today": "Today", "noDataYet": "No data yet", "layers": "Layers", @@ -12416,20 +12418,27 @@ "description": "Dated entries appear here as soon as there are some." }, "values": { - "menuLabel": "Up to three values", + "menuLabel": "Up to six values", "none": "No values", "noneInWindow": "No values in this range", "series": { "BLOOD_PRESSURE_SYS": "Blood pressure sys.", "BLOOD_PRESSURE_DIA": "Blood pressure dia." - } + }, + "noValue": "no value", + "bloodPressure": "Blood pressure", + "readingsOne": "1 reading", + "readingsFew": "mean of {count} readings", + "readingsOther": "mean of {count} readings" }, "table": { "caption": "Timeline entries", "lane": "Lane", "entry": "Entry", "start": "Start", - "end": "End" + "end": "End", + "seriesCaption": "Value means by period", + "period": "Period" }, "selection": { "empty": "No entries this month", diff --git a/messages/es.json b/messages/es.json index 3e9806ff9..1d82142bc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Cronología", "subtitle": "Qué pasó y cuándo, junto a tus valores.", - "legend": "Uno debajo de otro significa al mismo tiempo, no causa y efecto.", "standing": "Permanente", "startMissing": "Falta la fecha de inicio", "openDay": "Abrir {date}", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Periodo", "legendEvent": "Evento", - "legendMean": "Media mensual", + "legendMeanQuarter": "Media trimestral", + "legendMeanMonth": "Media mensual", + "legendMeanWeek": "Media semanal", + "legendThin": "Menos de 3 mediciones", "today": "Hoy", "noDataYet": "Aún sin datos", "layers": "Capas", @@ -12416,20 +12418,27 @@ "description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna." }, "values": { - "menuLabel": "Hasta tres valores", + "menuLabel": "Hasta seis valores", "none": "Sin valores", "noneInWindow": "Sin valores en este periodo", "series": { "BLOOD_PRESSURE_SYS": "Presión sistólica", "BLOOD_PRESSURE_DIA": "Presión diastólica" - } + }, + "noValue": "sin valor", + "bloodPressure": "Presión arterial", + "readingsOne": "1 medición", + "readingsFew": "media de {count} mediciones", + "readingsOther": "media de {count} mediciones" }, "table": { "caption": "Entradas de la cronología", "lane": "Pista", "entry": "Entrada", "start": "Inicio", - "end": "Fin" + "end": "Fin", + "seriesCaption": "Medias por periodo", + "period": "Periodo" }, "selection": { "empty": "Sin entradas este mes", diff --git a/messages/fr.json b/messages/fr.json index 4ffa3c91a..d04b61760 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Chronologie", "subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.", - "legend": "Superposé signifie simultané, pas cause et effet.", "standing": "Permanent", "startMissing": "Date de début manquante", "openDay": "Ouvrir le {date}", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Période", "legendEvent": "Événement", - "legendMean": "Moyenne mensuelle", + "legendMeanQuarter": "Moyenne trimestrielle", + "legendMeanMonth": "Moyenne mensuelle", + "legendMeanWeek": "Moyenne hebdomadaire", + "legendThin": "Moins de 3 mesures", "today": "Aujourd’hui", "noDataYet": "Pas encore de données", "layers": "Calques", @@ -12416,20 +12418,27 @@ "description": "Les entrées datées apparaîtront ici dès qu’il y en aura." }, "values": { - "menuLabel": "Jusqu’à trois valeurs", + "menuLabel": "Jusqu’à six valeurs", "none": "Aucune valeur", "noneInWindow": "Aucune valeur sur cette période", "series": { "BLOOD_PRESSURE_SYS": "Tension systolique", "BLOOD_PRESSURE_DIA": "Tension diastolique" - } + }, + "noValue": "aucune valeur", + "bloodPressure": "Tension artérielle", + "readingsOne": "1 mesure", + "readingsFew": "moyenne de {count} mesures", + "readingsOther": "moyenne de {count} mesures" }, "table": { "caption": "Entrées de la chronologie", "lane": "Piste", "entry": "Entrée", "start": "Début", - "end": "Fin" + "end": "Fin", + "seriesCaption": "Moyennes par période", + "period": "Période" }, "selection": { "empty": "Aucune entrée ce mois-ci", diff --git a/messages/it.json b/messages/it.json index 7fb859048..1abc28247 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Cronologia", "subtitle": "Cosa è successo e quando, accanto ai tuoi valori.", - "legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.", "standing": "Permanente", "startMissing": "Data di inizio mancante", "openDay": "Apri {date}", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Periodo", "legendEvent": "Evento", - "legendMean": "Media mensile", + "legendMeanQuarter": "Media trimestrale", + "legendMeanMonth": "Media mensile", + "legendMeanWeek": "Media settimanale", + "legendThin": "Meno di 3 misurazioni", "today": "Oggi", "noDataYet": "Ancora nessun dato", "layers": "Livelli", @@ -12416,20 +12418,27 @@ "description": "Le voci con data compariranno qui appena ce ne saranno." }, "values": { - "menuLabel": "Fino a tre valori", + "menuLabel": "Fino a sei valori", "none": "Nessun valore", "noneInWindow": "Nessun valore in questo periodo", "series": { "BLOOD_PRESSURE_SYS": "Pressione sistolica", "BLOOD_PRESSURE_DIA": "Pressione diastolica" - } + }, + "noValue": "nessun valore", + "bloodPressure": "Pressione arteriosa", + "readingsOne": "1 misurazione", + "readingsFew": "media di {count} misurazioni", + "readingsOther": "media di {count} misurazioni" }, "table": { "caption": "Voci della cronologia", "lane": "Corsia", "entry": "Voce", "start": "Inizio", - "end": "Fine" + "end": "Fine", + "seriesCaption": "Medie per periodo", + "period": "Periodo" }, "selection": { "empty": "Nessuna voce in questo mese", diff --git a/messages/ko.json b/messages/ko.json index 3405b2f39..8ea928d80 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "타임라인", "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", - "legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.", "standing": "지속", "startMissing": "시작일 없음", "openDay": "{date} 열기", @@ -12402,7 +12401,10 @@ }, "legendSpan": "기간", "legendEvent": "사건", - "legendMean": "월평균", + "legendMeanQuarter": "분기 평균", + "legendMeanMonth": "월평균", + "legendMeanWeek": "주간 평균", + "legendThin": "측정 3회 미만", "today": "오늘", "noDataYet": "아직 데이터 없음", "layers": "레이어", @@ -12416,20 +12418,27 @@ "description": "날짜가 있는 기록이 생기면 여기에 나타나요." }, "values": { - "menuLabel": "최대 세 가지 수치", + "menuLabel": "최대 여섯 가지 수치", "none": "수치 없음", "noneInWindow": "이 기간에 수치 없음", "series": { "BLOOD_PRESSURE_SYS": "수축기 혈압", "BLOOD_PRESSURE_DIA": "이완기 혈압" - } + }, + "noValue": "값 없음", + "bloodPressure": "혈압", + "readingsOne": "측정 1회", + "readingsFew": "측정 {count}회 평균", + "readingsOther": "측정 {count}회 평균" }, "table": { "caption": "타임라인 기록", "lane": "줄", "entry": "기록", "start": "시작", - "end": "끝" + "end": "끝", + "seriesCaption": "기간별 평균", + "period": "기간" }, "selection": { "empty": "이번 달 기록 없음", diff --git a/messages/pl.json b/messages/pl.json index 7100d719b..8bde6862a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12280,7 +12280,6 @@ "timeline": { "title": "Oś czasu", "subtitle": "Co i kiedy się działo, obok Twoich wartości.", - "legend": "Jedno pod drugim oznacza ten sam czas, nie przyczynę i skutek.", "standing": "Stałe", "startMissing": "Brak daty rozpoczęcia", "openDay": "Otwórz {date}", @@ -12402,7 +12401,10 @@ }, "legendSpan": "Okres", "legendEvent": "Zdarzenie", - "legendMean": "Średnia miesięczna", + "legendMeanQuarter": "Średnia kwartalna", + "legendMeanMonth": "Średnia miesięczna", + "legendMeanWeek": "Średnia tygodniowa", + "legendThin": "Mniej niż 3 pomiary", "today": "Dziś", "noDataYet": "Jeszcze brak danych", "layers": "Warstwy", @@ -12416,20 +12418,27 @@ "description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą." }, "values": { - "menuLabel": "Do trzech wartości", + "menuLabel": "Do sześciu wartości", "none": "Brak wartości", "noneInWindow": "Brak wartości w tym okresie", "series": { "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe" - } + }, + "noValue": "brak wartości", + "bloodPressure": "Ciśnienie krwi", + "readingsOne": "1 pomiar", + "readingsFew": "średnia z {count} pomiarów", + "readingsOther": "średnia z {count} pomiarów" }, "table": { "caption": "Wpisy na osi czasu", "lane": "Ścieżka", "entry": "Wpis", "start": "Początek", - "end": "Koniec" + "end": "Koniec", + "seriesCaption": "Średnie według okresu", + "period": "Okres" }, "selection": { "empty": "Brak wpisów w tym miesiącu", diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 5340a1fe3..3aef7cb9a 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -16,6 +16,7 @@ import { I18nProvider } 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 { TimelineChronicle } from "../timeline-chronicle"; import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; @@ -121,25 +122,109 @@ describe("chronicle", () => { ); expect(html).toContain("129/82 mmHg · 82,6 kg"); }); + + it("names a quarter once, at its newest month, with its span", () => { + const timeline = { + ...fullTimeline(), + bucket: "quarter" as const, + series: [ + { + key: "WEIGHT", + unit: "kg", + points: [{ t: "2026-01-01", mean: 82.6, count: 9 }], + }, + ], + }; + const html = render( + 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"); + }); }); +const LABELS: Record = { + BLOOD_PRESSURE_SYS: "Blutdruck sys.", + BLOOD_PRESSURE_DIA: "Blutdruck dia.", + WEIGHT: "Gewicht", +}; +const seriesLabel = (key: string) => LABELS[key] ?? key; + describe("selection bar", () => { - it("lists the month's entries and offers the selected day", () => { - const html = render( + const bar = (selected: string) => + render( undefined} />, ); + + it("lists the month's entries and offers the selected day", () => { + const html = bar("2026-01-03"); expect(html).toContain("Januar 2026"); expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan."); expect(html).toContain("Vitamin D (Winter) · laufend"); expect(html).not.toContain("Bluthochdruck"); expect(html).toContain("3. Jan. öffnen"); expect(html).toContain('data-date="2026-01-03"'); - expect(html).toContain("Ø 129/82 mmHg · 82,6 kg"); + }); + + 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(bar("2026-07-15")).toContain( + "Ø Juli 2026: 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", + ); + // January's 129 and March's 133 would make 131 between them. + expect(html).not.toMatch(/\d mmHg/); + }); +}); + +describe("value table for a screen reader", () => { + it("lists every month between the first and the last reading, gaps as gaps", () => { + const html = render( + undefined} + onOpenDay={() => undefined} + />, + ); + const table = html.slice(html.indexOf('data-slot="timeline-series-table"')); + // October 2025 to July 2026: ten months, newest first. + expect(table.match(/data-bucket="/g)).toHaveLength(10); + const row = (bucket: string) => { + const start = table.indexOf(`data-bucket="${bucket}"`); + return table.slice(start, table.indexOf("", start)); + }; + expect(row("2026-02-01")).toContain("Februar 2026"); + expect(row("2026-02-01").match(/kein Wert/g)).toHaveLength(3); + expect(row("2026-01-01")).toContain("129 mmHg, Schnitt aus 4 Messungen"); + expect(row("2026-07-01")).toContain("131 mmHg, 1 Messung"); + expect(row("2026-05-01")).not.toMatch(/mmHg|kg/); }); }); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 2b466afd7..11e3cf2b1 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -235,31 +235,31 @@ export function fullTimeline(): TimelineResponse { }, ], standing: [], + bucket: "month", series: [ { key: "BLOOD_PRESSURE_SYS", unit: "mmHg", - granularity: "month", points: [ - { t: "2025-10-01", mean: 127 }, - { t: "2025-11-01", mean: 128 }, - { t: "2025-12-01", mean: 130 }, - { t: "2026-01-01", mean: 129 }, - // Nothing for February to June. - { t: "2026-07-01", mean: 131 }, + { t: "2025-10-01", mean: 127, count: 6 }, + { t: "2025-11-01", mean: 128, count: 5 }, + { t: "2025-12-01", mean: 130, count: 4 }, + { t: "2026-01-01", mean: 129, count: 4 }, + // Nothing in February: one missing month, bridged. + { t: "2026-03-01", mean: 133, count: 2 }, + // Nothing from April to June: three missing months, broken. + { t: "2026-07-01", mean: 131, count: 1 }, ], }, { key: "BLOOD_PRESSURE_DIA", unit: "mmHg", - granularity: "month", - points: [{ t: "2026-01-01", mean: 82 }], + points: [{ t: "2026-01-01", mean: 82, count: 4 }], }, { key: "WEIGHT", unit: "kg", - granularity: "month", - points: [{ t: "2026-01-01", mean: 82.6 }], + points: [{ t: "2026-01-01", mean: 82.6, count: 3 }], }, ], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 8b3f36e19..6bd8cc4cf 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -40,6 +40,7 @@ function layoutAt(width: number): TimelineLayout { window: windowFor("all", TODAY, null, tl.range), lanes: tl.lanes, series: tl.series, + bucket: tl.bucket, startMissing: "Startdatum fehlt", today: TODAY, }); @@ -345,30 +346,136 @@ describe("layoutTimeline", () => { }); describe("layoutSeries", () => { - it("breaks the line across missing months instead of bridging them", () => { - const window = { from: "2025-10-01", to: "2026-10-31" }; - const scale = createScale(window, 164, 1200); - const [bp] = layoutSeries( - fullTimeline().series.slice(0, 1), - 0, - window, - scale, - ); - expect(bp.path.match(/M/g)).toHaveLength(2); + const window = { from: "2025-10-01", to: "2026-10-31" }; + const scale = createScale(window, 164, 1200); + const [bp] = layoutSeries( + fullTimeline().series.slice(0, 1), + 0, + window, + scale, + "month", + ); + + it("keeps every month with a reading as a point, and invents none", () => { + expect(bp.points.map((p) => p.t)).toEqual([ + "2025-10-01", + "2025-11-01", + "2025-12-01", + "2026-01-01", + "2026-03-01", + "2026-07-01", + ]); + expect(bp.points.map((p) => p.mean)).toEqual([ + 127, 128, 130, 129, 133, 131, + ]); expect(bp.latest).toBe(131); }); - it("keeps an empty series as a row without a line", () => { - const window = { from: "2019-01-01", to: "2019-12-31" }; - const scale = createScale(window, 164, 1200); - const [bp] = layoutSeries( - fullTimeline().series.slice(0, 1), + it("draws the run solid, bridges one missing month dashed, and breaks at three", () => { + const [oct, , , jan, mar, jul] = bp.points; + const at = (p: { x: number; y: number }) => + `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; + // One solid run, October to January. + expect(bp.path.match(/M/g)).toHaveLength(1); + expect(bp.path.startsWith(`M${at(oct)}`)).toBe(true); + expect(bp.path.endsWith(`L${at(jan)}`)).toBe(true); + // February is missing: one dashed stroke from January to March, with no + // vertex between them. + expect(bp.bridges).toBe(`M${at(jan)}L${at(mar)}`); + // April to June are missing: July joins nothing. + expect(bp.path).not.toContain(at(jul)); + expect(bp.bridges).not.toContain(at(jul)); + }); + + it("draws a bucket of fewer than three readings hollow", () => { + expect(bp.points.map((p) => [p.t, p.thin])).toEqual([ + ["2025-10-01", false], + ["2025-11-01", false], + ["2025-12-01", false], + ["2026-01-01", false], + ["2026-03-01", true], + ["2026-07-01", true], + ]); + }); + + it("bridges at most one missing quarter and four missing weeks", () => { + const quarter = (points: Array<[string, number]>) => + layoutSeries( + [ + { + key: "WEIGHT", + unit: "kg", + points: points.map(([t, mean]) => ({ t, mean, count: 5 })), + }, + ], + 0, + { from: "2020-01-01", to: "2026-12-31" }, + createScale({ from: "2020-01-01", to: "2026-12-31" }, 164, 1200), + "quarter", + )[0]; + // Q1, then Q3: one missing quarter, bridged. + expect( + quarter([ + ["2024-01-01", 80], + ["2024-07-01", 81], + ]).bridges, + ).not.toBe(""); + // Q1, then Q4: two missing quarters, broken; each point still drawn. + const broken = quarter([ + ["2024-01-01", 80], + ["2024-10-01", 81], + ]); + expect(broken.bridges).toBe(""); + expect(broken.path).toBe(""); + expect(broken.points).toHaveLength(2); + + const weeks = (second: string) => + layoutSeries( + [ + { + key: "WEIGHT", + unit: "kg", + points: [ + { t: "2026-01-05", mean: 80, count: 3 }, + { t: second, mean: 81, count: 3 }, + ], + }, + ], + 0, + { from: "2026-01-01", to: "2026-03-31" }, + createScale({ from: "2026-01-01", to: "2026-03-31" }, 164, 1200), + "week", + )[0]; + // Mondays: four missing weeks bridged, five broken. + expect(weeks("2026-02-09").bridges).not.toBe(""); + expect(weeks("2026-02-16").bridges).toBe(""); + }); + + it("keeps a lone point visible", () => { + const [weight] = layoutSeries( + fullTimeline().series.slice(2, 3), 0, window, scale, + "month", ); - expect(bp.path).toBe(""); - expect(bp.latest).toBeNull(); + expect(weight.path).toBe(""); + expect(weight.bridges).toBe(""); + expect(weight.points).toHaveLength(1); + }); + + it("keeps an empty series as a row without a line", () => { + const early = { from: "2019-01-01", to: "2019-12-31" }; + const [empty] = layoutSeries( + fullTimeline().series.slice(0, 1), + 0, + early, + createScale(early, 164, 1200), + "month", + ); + expect(empty.path).toBe(""); + expect(empty.points).toEqual([]); + expect(empty.latest).toBeNull(); }); }); diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 50d143b44..901e6032e 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -2,8 +2,10 @@ /** * 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 month's - * means of the chosen values, and "Open 3 Jan." for the selected day. + * 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. * * 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`). @@ -23,45 +25,31 @@ import { startOfMonth, } from "./timeline-dates"; import { itemsInMonth } from "./timeline-geometry"; -import { laneDotStyle } from "./timeline-chart"; -import { formatMonthMeans } from "./series-format"; +import { bucketText, laneDotStyle } from "./timeline-chart"; +import { bucketValues, formatLabelledMeans } from "./series-format"; export interface SelectionBarProps { timeline: TimelineResponse; selected: string; today: string; + seriesLabel: (key: string) => string; onOpenDay: (date: string) => void; onEditLifeEvent?: (id: string) => void; } -/** The month means of each series for the month of `date`. */ -export function monthMeans( - series: TimelineResponse["series"], - date: string, -): Array<{ key: string; mean: number; unit: string | null }> { - const month = monthOf(date); - const out: Array<{ key: string; mean: number; unit: string | null }> = []; - for (const s of series) { - const inMonth = s.points.filter((p) => monthOf(p.t) === month); - if (inMonth.length === 0) continue; - const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length; - out.push({ key: s.key, mean, unit: s.unit }); - } - return out; -} - export function SelectionBar({ timeline, selected, today, + seriesLabel, onOpenDay, onEditLifeEvent, }: SelectionBarProps) { - const { t, locale } = useTranslations(); + const { t, tCount, locale } = useTranslations(); const fmt = useFormatters(); const intl = resolveIntlLocale(locale); const entries = itemsInMonth(timeline.lanes, selected, today); - const means = monthMeans(timeline.series, selected); + const means = bucketValues(timeline.series, timeline.bucket, selected); const monthFrom = startOfMonth(selected); const monthTo = endOfMonth(selected); @@ -134,10 +122,23 @@ export function SelectionBar({ ); })} - {means.length > 0 && ( - + {means.values.length > 0 && ( + {t("timeline.selection.mean", { - values: formatMonthMeans(means, fmt), + 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), + }, + )}`, })} )} diff --git a/src/components/timeline/series-format.ts b/src/components/timeline/series-format.ts index 01315881b..53bb50179 100644 --- a/src/components/timeline/series-format.ts +++ b/src/components/timeline/series-format.ts @@ -3,8 +3,11 @@ * pulse and counts, one decimal for the rest, sleep in hours. The unit is the * server's, already in the person's display unit. */ +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 = new Set([ "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", @@ -43,18 +46,92 @@ export function formatSeriesValue( return out.unit ? `${out.value} ${out.unit}` : out.value; } +/** One series in one bucket: its mean, or nothing when it has no reading. */ +export interface BucketValue { + key: string; + unit: string | null; + mean: number | null; + count: number | null; +} + /** - * The month means in one short line: systolic and diastolic fold into - * "129/82 mmHg", the rest follow as "82,6 kg". + * Every series in the bucket that holds `date`. A series without a reading + * in that bucket is listed with `mean: null`: the gap stays a gap. */ -export function formatMonthMeans( - means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>, +export function bucketValues( + series: TimelineResponse["series"], + bucket: TimelineBucket, + date: string, +): { start: string; values: BucketValue[] } { + const start = bucketStart(date, bucket); + return { + start, + values: series.map((s) => { + const point = s.points.find((p) => p.t === start); + return { + key: s.key, + unit: s.unit, + mean: point?.mean ?? null, + count: point?.count ?? null, + }; + }), + }; +} + +/** + * The bucket means each chronicle month shows (`months` newest first, as the + * chronicle lists them). A bucket is named once, at the newest listed month + * it overlaps: a quarter rides on its latest month with entries, never on + * all three. Where two buckets of one series meet at one month (weeks), the + * newer stands. Only buckets with a reading appear; a month without one + * shows nothing rather than a number. + */ +export function chronicleMeans( + series: TimelineResponse["series"], + bucket: TimelineBucket, + months: readonly string[], +): Map { + const out = new Map(); + for (const s of series) { + const points = [...s.points].sort((a, b) => (a.t < b.t ? 1 : -1)); + const taken = new Set(); + for (const p of points) { + const end = bucketAfter(p.t, bucket); + const month = months.find( + (m) => m < end && addMonths(m, 1) > p.t && !taken.has(m), + ); + if (!month) continue; + taken.add(month); + const slot = out.get(month) ?? { start: p.t, values: [] }; + if (p.t > slot.start) slot.start = p.t; + slot.values.push({ + key: s.key, + unit: s.unit, + mean: p.mean, + count: p.count, + }); + out.set(month, slot); + } + } + 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, ): string { - const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS"); - const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA"); + 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[] = []; - for (const m of means) { + for (const m of present) { if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue; if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") { parts.push( @@ -66,3 +143,43 @@ export function formatMonthMeans( } return parts.join(" · "); } + +/** + * 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. + */ +export function formatLabelledMeans( + means: readonly BucketValue[], + fmt: Pick, + words: { + label: (key: string) => string; + bloodPressure: string; + noValue: string; + readings: (count: number) => string; + }, +): string { + const sys = means.find( + (m) => m.key === "BLOOD_PRESSURE_SYS" && m.mean !== null, + ); + const dia = means.find( + (m) => m.key === "BLOOD_PRESSURE_DIA" && m.mean !== null, + ); + const fold = sys && dia; + const parts: string[] = []; + 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)})`, + ); + 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)})`, + ); + } + return parts.join(" · "); +} diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 1d44cf58e..4f99b953c 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -43,6 +43,7 @@ import { } from "lucide-react"; import type { + TimelineBucket, TimelineLaneKey, TimelineResponse, TimelineZoom, @@ -51,7 +52,10 @@ import { resolveIntlLocale } from "@/lib/format-locale"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { + bucketAfter, + bucketStart, formatAtPrecision, + formatBucket, formatMonthShort, formatMonthYear, dayNumber, @@ -145,11 +149,12 @@ export function TimelineChart({ window, lanes, series: timeline.series, + bucket: timeline.bucket, startMissing: t("timeline.startMissing"), today, }) : null, - [width, window, lanes, timeline.series, t, today], + [width, window, lanes, timeline.series, timeline.bucket, t, today], ); const floor = timeline.range.dataFrom ?? window.from; @@ -228,6 +233,7 @@ export function TimelineChart({ today={today} selected={selected} dataFrom={timeline.range.dataFrom} + bucket={timeline.bucket} seriesLabel={seriesLabel} intl={intl} t={t} @@ -243,6 +249,12 @@ export function TimelineChart({ hiddenLanes={hiddenLanes} intl={intl} /> + ); } @@ -254,6 +266,7 @@ interface ChartBodyProps { today: string; selected: string | null; dataFrom: string | null; + bucket: TimelineBucket; seriesLabel: (key: string) => string; intl: string; t: ReturnType["t"]; @@ -267,11 +280,13 @@ function ChartBody({ today, selected, dataFrom, + bucket, seriesLabel, 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); @@ -461,14 +476,37 @@ function ChartBody({ strokeLinecap="round" /> )} - {series.points.length === 1 && ( - )} + {series.points.map((point) => ( + + + {`${bucketText(point.t, bucket, intl, t)}: ${formatSeriesValue(series.key, point.mean, series.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`} + + + ))} ))} @@ -761,6 +799,90 @@ function TimelineTable({ ); } +/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */ +export function bucketText( + start: string, + bucket: TimelineBucket, + intl: string, + t: ReturnType["t"], +): string { + const label = formatBucket(start, bucket, intl); + return typeof label === "string" + ? label + : t("timeline.selection.range", label); +} + +/** + * The value lines as a table, for a screen reader: one row per bucket from + * the first to the last with a reading in the window, one column per + * series. A bucket without a reading says so; nothing is filled in. + */ +function SeriesTable({ + timeline, + window, + seriesLabel, + intl, +}: { + timeline: TimelineResponse; + window: { from: string; to: string }; + seriesLabel: (key: string) => string; + 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; + const starts = series + .flatMap((s) => s.points.map((p) => p.t)) + .filter(inWindow) + .sort(); + if (starts.length === 0) return null; + const rows: string[] = []; + for ( + let start = bucketStart(starts[0], bucket); + start <= starts[starts.length - 1]; + start = bucketAfter(start, bucket) + ) { + rows.push(start); + } + const byKey = new Map( + series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]), + ); + return ( + + + + + + {series.map((s) => ( + + ))} + + + + {rows.reverse().map((start) => ( + + + {series.map((s) => { + const point = byKey.get(s.key)?.get(start); + return ( + + ); + })} + + ))} + +
{t("timeline.table.seriesCaption")}
{t("timeline.table.period")} + {seriesLabel(s.key)} +
{bucketText(start, bucket, intl, t)} + {point + ? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}` + : t("timeline.values.noValue")} +
+ ); +} + /** Inline style for a lane colour dot in HTML (chips, chronicle rails). */ export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties { return { background: LANE_COLOR[lane] }; diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index d1cf93668..42ed5aab4 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -2,9 +2,10 @@ /** * The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away, - * then the chronicle, newest first, with the month's means beside each - * month, thin rails beside the rows an episode or a course spans, and empty - * stretches named rather than skipped. Built from `chronicle-model.ts`. + * then the chronicle, newest first, with the bucket means beside the month + * headers (a quarter named once, at its newest month, with its span), thin + * rails beside the rows an episode or a course spans, and empty stretches + * named rather than skipped. Built from `chronicle-model.ts`. * * A dated row opens its day (the same `?day=` layer the chart hands off to); * a life event opens its edit sheet instead, because the event is the @@ -47,9 +48,8 @@ import { formatMonthYear, } from "./timeline-dates"; import { LANE_COLOR } from "./timeline-geometry"; -import { laneDotStyle } from "./timeline-chart"; -import { monthMeans } from "./selection-bar"; -import { formatMonthMeans } from "./series-format"; +import { bucketText, laneDotStyle } from "./timeline-chart"; +import { chronicleMeans, formatMeans } from "./series-format"; const KIND_ICON: Partial> = { lifeEvent: Flag, @@ -141,6 +141,16 @@ export function TimelineChronicle({ const [standingOpen, setStandingOpen] = useState(true); const chips = standingChips(timeline); const rows = buildChronicle(timeline, today, grouping); + // The means ride on the month headers, each bucket on the newest month it + // overlaps; the year view lists none. + const means = + grouping === "month" + ? chronicleMeans( + timeline.series, + timeline.bucket, + rows.flatMap((r) => (r.type === "header" ? [r.group] : [])), + ) + : new Map(); const groupLabel = (group: string) => grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4); @@ -244,10 +254,10 @@ export function TimelineChronicle({ ); } if (row.type === "header") { - const means = - grouping === "month" - ? monthMeans(timeline.series, row.group) - : []; + const bucketMeans = means.get(row.group); + const line = bucketMeans + ? formatMeans(bucketMeans.values, fmt) + : ""; return (
  • {groupLabel(row.group)} - {means.length > 0 && ( - - {formatMonthMeans(means, fmt)} + {bucketMeans && line && ( + + {timeline.bucket === "month" + ? line + : `${bucketText(bucketMeans.start, timeline.bucket, intl, t)}: ${line}`} )}
  • diff --git a/src/components/timeline/timeline-dates.ts b/src/components/timeline/timeline-dates.ts index ac971fffb..9869875c8 100644 --- a/src/components/timeline/timeline-dates.ts +++ b/src/components/timeline/timeline-dates.ts @@ -10,6 +10,8 @@ * Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a * date never moves by a day for a reader east or west of Greenwich. */ +import type { TimelineBucket } from "@/lib/day/contract"; + const MS_PER_DAY = 86_400_000; /** Days since 1970-01-01 for a `YYYY-MM-DD` key. */ @@ -55,6 +57,38 @@ export function addMonths(key: string, months: number): string { return target.toISOString().slice(0, 10); } +/* ─── Series buckets ──────────────────────────────────────────────────────── */ + +/** The first day of the bucket `key` falls in. Weeks start on Monday. */ +export function bucketStart(key: string, bucket: TimelineBucket): string { + if (bucket === "month") return startOfMonth(key); + if (bucket === "quarter") { + const month = Number(key.slice(5, 7)); + const first = month - ((month - 1) % 3); + return `${key.slice(0, 4)}-${String(first).padStart(2, "0")}-01`; + } + const day = dayNumber(key); + // 1970-01-01 was a Thursday: day 4 is the first Monday. + return dayKey(day - ((((day - 4) % 7) + 7) % 7)); +} + +/** The first day after the bucket that starts on `start`. */ +export function bucketAfter(start: string, bucket: TimelineBucket): string { + if (bucket === "month") return addMonths(start, 1); + if (bucket === "quarter") return addMonths(start, 3); + return dayKey(dayNumber(start) + 7); +} + +/** + * A running number for the bucket that starts on `start`: neighbouring + * buckets differ by one, so a difference counts the buckets between. + */ +export function bucketIndex(start: string, bucket: TimelineBucket): number { + 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; +} + /** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */ export function monthsBetween(from: string, to: string): string[] { const out: string[] = []; @@ -148,3 +182,27 @@ export function formatAtPrecision( if (precision === "MONTH") return formatMonthYearShort(key, intlLocale); return formatDayMonthYear(key, intlLocale); } + +/** + * How a series bucket reads: "Januar 2026" for a month, otherwise its first + * and last day for the caller's "{from} to {to}" ("Jan to März 2026", + * "5. Jan. to 11. Jan. 2026"). + */ +export function formatBucket( + start: string, + bucket: TimelineBucket, + intlLocale: string, +): string | { from: string; to: string } { + if (bucket === "month") return formatMonthYear(start, intlLocale); + const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1); + if (bucket === "quarter") { + return { + from: formatMonthShort(start, intlLocale), + to: formatMonthYearShort(last, intlLocale), + }; + } + return { + from: formatDayMonth(start, intlLocale), + to: formatDayMonthYear(last, intlLocale), + }; +} diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index 4a430a388..3195e221d 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -17,6 +17,7 @@ * month in full. */ import type { + TimelineBucket, TimelineItem, TimelineItemKind, TimelineLane, @@ -27,6 +28,8 @@ import type { import { addMonths, + bucketAfter, + bucketIndex, dayKey, dayNumber, endOfMonth, @@ -688,42 +691,74 @@ function placeLabels( /* ─── Value lines ─────────────────────────────────────────────────────────── */ +export interface SeriesPointLayout { + /** The bucket's first day. */ + t: string; + mean: number; + /** Readings behind the mean. */ + count: number; + x: number; + y: number; + /** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */ + thin: boolean; +} + export interface SeriesLayout { key: string; unit: string | null; top: number; height: number; - /** SVG path data; empty when the window holds no point. */ + /** SVG path data of the solid runs; empty when no two points touch. */ path: string; + /** SVG path data of the dashed bridges over short gaps; may be empty. */ + bridges: string; /** The most recent mean inside the window, or null. */ latest: number | null; - points: Array<{ t: string; mean: number; x: number; y: number }>; + points: SeriesPointLayout[]; } -const BUCKET_DAYS: Record = { - month: 30, - week: 7, - day: 1, +/** A bucket mean from fewer readings than this is drawn hollow. */ +export const THIN_BUCKET_COUNT = 3; + +/** + * The most missing buckets a dashed bridge spans. Beyond it the line stops + * and starts again, so a long silence reads as one and not as a trend. + * + * The rule is one length, about six months between the two points a bridge + * joins, never more, and never more than a third of what the zoom shows: + * one missing quarter (the points are six months apart), two missing months + * in a year (three months apart, a sixth of the window) and four missing + * weeks in three months (five weeks apart, the window's third). A longer + * bridge would draw a slope through a stretch where nothing was measured. + */ +export const MAX_BRIDGED_GAP: Readonly> = { + quarter: 1, + month: 2, + week: 4, }; /** * One neutral line per series, scaled to its own range inside the window. - * A point sits in the middle of its bucket. Missing buckets break the line - * rather than bridging the gap: a straight stroke across eight empty months - * would claim values nobody measured. + * A point sits in the middle of its bucket, and every bucket with a reading + * gets its point, so one month with no neighbours stays visible. Missing + * buckets stay missing: a short gap is bridged by a dashed stroke + * ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none + * between them, a longer one breaks the line. */ export function layoutSeries( series: readonly TimelineSeries[], top: number, window: TimeWindow, scale: Scale, + bucket: TimelineBucket, ): SeriesLayout[] { return series.map((s, index) => { const rowTop = top + index * SERIES_HEIGHT + 6; const height = SERIES_HEIGHT - 16; - const bucket = BUCKET_DAYS[s.granularity]; + // A bucket that starts before the window still belongs to it while any + // of its days lie inside. const inside = s.points - .filter((p) => p.t >= startOfMonthIf(window.from, s) && p.t <= window.to) + .filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from) .sort((a, b) => (a.t < b.t ? -1 : 1)); if (inside.length === 0) { return { @@ -732,6 +767,7 @@ export function layoutSeries( top: rowTop, height, path: "", + bridges: "", latest: null, points: [], }; @@ -742,18 +778,34 @@ export function layoutSeries( lo -= pad; hi += pad; const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); - const placed = inside.map((p) => { - const mid = dayNumber(p.t) + bucket / 2; + const points = inside.map((p) => { + const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2; const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid))); - return { t: p.t, mean: p.mean, x, y: y(p.mean) }; + return { + t: p.t, + mean: p.mean, + count: p.count, + x, + y: y(p.mean), + thin: p.count < THIN_BUCKET_COUNT, + }; }); + const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`; let path = ""; - let prevDay: number | null = null; - for (const p of placed) { - const day = dayNumber(p.t); - const broken = prevDay === null || day - prevDay > bucket * 1.6; - path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`; - prevDay = day; + let bridges = ""; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const cur = points[i]; + const missing = + bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1; + if (missing === 0) { + // Continue the run, or open a new one at the previous point. + path += path.endsWith(xy(prev)) + ? `L${xy(cur)}` + : `M${xy(prev)}L${xy(cur)}`; + } else if (missing <= MAX_BRIDGED_GAP[bucket]) { + bridges += `M${xy(prev)}L${xy(cur)}`; + } } return { key: s.key, @@ -761,18 +813,13 @@ export function layoutSeries( top: rowTop, height, path, + bridges, latest: inside[inside.length - 1].mean, - points: placed, + points, }; }); } -function startOfMonthIf(from: string, s: TimelineSeries): string { - // A month bucket keyed on the 1st still belongs to a window that starts - // mid-month. - return s.granularity === "month" ? startOfMonth(from) : from; -} - /* ─── Whole chart ─────────────────────────────────────────────────────────── */ export interface TimelineLayout { @@ -794,6 +841,7 @@ export function layoutTimeline(input: { window: TimeWindow; lanes: readonly TimelineLane[]; series: readonly TimelineSeries[]; + bucket: TimelineBucket; startMissing: string; today: string; }): TimelineLayout { @@ -814,7 +862,13 @@ export function layoutTimeline(input: { top += layout.height; } const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0); - const series = layoutSeries(input.series, seriesTop, input.window, scale); + const series = layoutSeries( + input.series, + seriesTop, + input.window, + scale, + input.bucket, + ); const height = seriesTop + series.length * SERIES_HEIGHT + 4; return { width: input.width, height, scale, lanes, seriesTop, series }; } diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index 657aba679..c4878a0d4 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -2,7 +2,7 @@ /** * The two menus above the chart (v1.42, #613): which value lines run under - * the lanes (at most three), and which lanes show, with the way back into + * the lanes (at most six, the server's cap), and which lanes show, with the way back into * the readiness inventory ("Data coverage"). */ import { ChartLine, ChevronDown, Layers } from "lucide-react"; @@ -17,11 +17,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import type { TimelineLaneKey } from "@/lib/day/contract"; +import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract"; import { useTranslations } from "@/lib/i18n/context"; /** The most value lines the chart runs under its lanes. */ -export const MAX_VALUE_SERIES = 3; +export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES; export function ValueSeriesMenu({ options, diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index ecca6c373..c87e2c477 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -3,9 +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 three neutral value lines below them, all on one - * time axis. "Stacked means at the same time, not cause and effect" is said - * once, in the legend, and nothing on the page draws a connection. + * events in lanes, up to six neutral value lines below them, 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 * Enter on the chart, a double click, a chip, a chronicle row) pushes the @@ -35,6 +34,7 @@ import { DAY_QUERY_PARAM, TIMELINE_ZOOMS, type LifeEventDTO, + type TimelineBucket, type TimelineLaneKey, type TimelineZoom, } from "@/lib/day/contract"; @@ -385,10 +385,14 @@ export function TimelineView() { timeline={data} selected={effectiveSelected} today={today} + seriesLabel={seriesLabel} onOpenDay={openDay} onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined} /> - + 0} + /> @@ -436,7 +440,19 @@ export function TimelineView() { ); } -function Legend() { +const LEGEND_MEAN_KEY: Readonly> = { + quarter: "timeline.legendMeanQuarter", + month: "timeline.legendMeanMonth", + week: "timeline.legendMeanWeek", +}; + +function Legend({ + bucket, + hasSeries, +}: { + bucket: TimelineBucket; + hasSeries: boolean; +}) { const { t } = useTranslations(); return (
    {t("timeline.legendEvent")} - - - - {t("timeline.legend")} - + {hasSeries && ( + <> + + + + + + )}
    ); }