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", () => {