mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
88836452eb
commit
af982e3812
9 files changed
+466
-105
No files matched your search
+23
-10
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user