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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:03:42 +02:00
1 parent 88836452eb
commit af982e3812
9 files changed
+466 -105

No files matched your search

+23 -10
View File
@@ -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:
+1 -1
View File
@@ -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",
});
}
+16
View File
@@ -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:<key>`) and disappears with it.
+50 -5
View File
@@ -30,6 +30,15 @@ import { collapseRollupRowsBySource } from "@/lib/rollups/measurement-read";
/** Day key → the day's value, per type. */
export type DailyStats = Map<MeasurementType, Map<string, number>>;
/** 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<MeasurementType, Map<string, DayCell>>;
interface StatRow {
type: MeasurementType;
source: MeasurementSource;
@@ -60,7 +69,22 @@ export async function readLocalDailyStats(args: {
tz: string;
priorityJson: unknown;
}): Promise<DailyStats> {
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<DailyCells> {
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<string, number>();
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<string, number>();
const perDay = new Map<string, DayCell>();
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);
}
+28 -3
View File
@@ -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) }),
+64 -9
View File
@@ -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", () => {
+31 -14
View File
@@ -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<Record<TimelineZoom, SeriesGranularity>> = {
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<Record<Exclude<TimelineZoom, "all">, number>> = {
year: 365,
@@ -64,7 +78,8 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = 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 })),
};
+134 -63
View File
@@ -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<string, number>,
granularity: SeriesGranularity,
): Array<{ t: string; mean: number }> {
const buckets = new Map<string, { sum: number; n: number }>();
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<string, DayCell>,
bucket: TimelineBucket,
): SeriesPoint[] {
const buckets = new Map<string, { sum: number; n: number; count: number }>();
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<TimelineBucket, "week">,
): 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<Array<{ t: string; mean: number }>> {
}): Promise<SeriesPoint[]> {
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<Array<{ t: string; mean: number }>>;
) as Promise<SeriesPoint[]>;
}
async function monthlyFromRollups(args: {
async function monthsFromRollups(args: {
userId: string;
type: MeasurementType;
from: Date;
to: Date;
priorityJson: unknown;
}): Promise<Array<{ t: string; mean: number }>> {
}): Promise<RolledMonth[]> {
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<TimelineSeries | null> {
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<string, number>();
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<string, SeriesPoint>();
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<TimelineSeries | null> {
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<string, number>();
const days = new Map<string, DayCell>();
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<TimelineSeries[]> {
+119
View File
@@ -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<import("@/lib/day/contract").TimelineResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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<import("@/lib/day/contract").TimelineResponse>(
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", () => {