mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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[]> {
|
||||
|
||||
Reference in new issue
Block a user