From 22d7e8c30cf8f1ffb8e949b1a697002791d72355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:00:19 +0200 Subject: [PATCH 1/4] feat(measurements): keep the intraday shape of a cumulative day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cumulative metric has no meaningful latest reading — a partial day's step count is a fragment, not a value — so the only honest comparison is against the same person's own typical total at the same local hour. That needs an hourly substrate and there was none: the nightly cumulative drain collapses per-sample Apple Health rows into one daily total after the grace window and hard-deletes the originals in the same transaction. Today's curve was derivable; every older day's was already gone. Fold the rows into twenty-four running totals and write them down first, in the same transaction and before the delete. The merge is additive so a day drained twice keeps both halves, and the rows are re-read under the advisory lock the drain already takes so an overlapping tick folds nothing. One table, one nightly range delete to bound it. --- .../migration.sql | 56 +++ prisma/schema.prisma | 67 ++++ .../drain-per-sample-cumulative.ts | 75 +++- .../intraday-cumulative-profile.ts | 373 ++++++++++++++++++ 4 files changed, 562 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0283_intraday_cumulative_profile/migration.sql create mode 100644 src/lib/measurements/intraday-cumulative-profile.ts diff --git a/prisma/migrations/0283_intraday_cumulative_profile/migration.sql b/prisma/migrations/0283_intraday_cumulative_profile/migration.sql new file mode 100644 index 000000000..c4481a3d3 --- /dev/null +++ b/prisma/migrations/0283_intraday_cumulative_profile/migration.sql @@ -0,0 +1,56 @@ +-- v1.34.0 — keep the intraday shape of a cumulative day before the fold +-- destroys it. +-- +-- The nightly cumulative drain collapses per-sample Apple Health rows into one +-- daily total after the 36-hour grace window and hard-deletes the originals in +-- the same transaction. That left no substrate at all for a "typical by 21:00" +-- comparison on any day older than the grace window — today's curve was +-- derivable, every older day's was already gone. This table holds the +-- twenty-four running totals the drain now folds out of those rows first. +-- +-- Sidecar only: the canonical `stats:` daily measurement stays the reading of +-- record. Nothing stored here is a measurement. +-- +-- Additive — one table, three indexes, one foreign key. No ALTER on an +-- existing table, no DROP. + +CREATE TABLE IF NOT EXISTS "intraday_cumulative_profiles" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" "measurement_type" NOT NULL, + -- Local calendar day in the user's timezone. VARCHAR so the trailing + -- window read and the retention prune are both lexicographic range scans. + "date_key" VARCHAR(10) NOT NULL, + -- Twenty-four running totals; element h is the value accumulated through + -- the END of local hour h, so the array is non-decreasing. + "hourly_cumulative" DOUBLE PRECISION[], + "day_total" DOUBLE PRECISION NOT NULL, + -- Raw per-sample rows that backed the fold. A two-row day is not a shape. + "sample_count" INTEGER NOT NULL, + -- The zone the day was cut on. A later move leaves profiles on the old + -- clock, and the reader drops them rather than compare mismatched hours. + "timezone" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "intraday_cumulative_profiles_pkey" PRIMARY KEY ("id") +); + +-- The natural identity: one profile per user x type x local day. Makes the +-- drain's upsert idempotent under a re-run or an overlapping nightly tick. +CREATE UNIQUE INDEX IF NOT EXISTS "intraday_cumulative_profiles_user_type_date_key" + ON "intraday_cumulative_profiles" ("user_id", "type", "date_key"); + +-- The trailing-window read the baseline issues: newest days first. +CREATE INDEX IF NOT EXISTS "intraday_cumulative_profiles_user_type_date_desc_idx" + ON "intraday_cumulative_profiles" ("user_id", "type", "date_key" DESC); + +-- The retention prune sweeps every account at once, so it needs an index that +-- does not lead with the user. +CREATE INDEX IF NOT EXISTS "intraday_cumulative_profiles_date_key_idx" + ON "intraday_cumulative_profiles" ("date_key"); + +ALTER TABLE "intraday_cumulative_profiles" + ADD CONSTRAINT "intraday_cumulative_profiles_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60e1fecf7..4cbf49763 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -770,6 +770,11 @@ model User { // granularity from the measurement write hooks; WEEK/MONTH/YEAR // are enqueued through pg-boss for the worker to fold. measurementRollups MeasurementRollup[] + // v1.34.0 — per-(user, type, local day) cumulative hourly profile. The + // shape the nightly cumulative drain folds out of the per-sample rows + // immediately before deleting them, so a "typical by 21:00" comparison + // has a substrate for days older than the 36-hour grace window. + intradayCumulativeProfiles IntradayCumulativeProfile[] // v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes. // One row per (user, local date) the worker has nudged. Prevents // a second push when the cron rechecks the same hour later in the @@ -1953,6 +1958,68 @@ model MeasurementRollup { @@map("measurement_rollups") } +// ─── Intraday cumulative profiles (v1.34.0) ──────────────── +// +// The shape a cumulative day had, kept before the fold destroys it. +// +// A cumulative metric has no meaningful "latest reading" — a partial +// day's step count is a fragment, not a value — so the only honest +// comparison is against the same person's own typical total AT THE +// SAME LOCAL HOUR. That comparison needs an hourly substrate, and +// there was none: the nightly cumulative drain collapses per-sample +// Apple Health rows into one daily total after the 36-hour grace +// window and hard-deletes the originals in the same transaction. +// Today's curve was derivable; every older day's was already gone. +// +// So the drain now folds the rows it is about to delete into +// twenty-four running totals — one per local hour, index `h` carrying +// the total accumulated through the END of local hour `h` — and +// writes them here first. One compact row per user × type × local +// day, a few thousand rows a year across the supported types. +// +// This is a SIDECAR, never a reading. The canonical `stats:` daily +// total stays the measurement of record; nothing reads a value off +// this table that the measurement rows do not already carry. Days +// still inside the grace window have no row yet and are folded live +// from the raw rows on read, so the current day is always available. +model IntradayCumulativeProfile { + id String @id @default(cuid()) + userId String @map("user_id") + type MeasurementType + /// Local calendar day the profile describes (`YYYY-MM-DD`), in the + /// user's timezone at fold time. Lexicographically ordered, so a + /// trailing-window read and the retention prune are both range scans. + dateKey String @map("date_key") @db.VarChar(10) + /// Twenty-four running totals in canonical units — element `h` is the + /// value accumulated from local midnight through the END of local hour + /// `h`, so the array is non-decreasing and `[23]` equals `dayTotal`. + /// A DST day still has exactly 24 slots: the repeated hour folds into + /// its slot and a skipped hour repeats its predecessor's running total, + /// which keeps every stored day index-comparable with every other. + hourlyCumulative Float[] @map("hourly_cumulative") + /// The day's total — `hourlyCumulative[23]`, denormalised so a coverage + /// check never has to read the array. + dayTotal Float @map("day_total") + /// How many raw per-sample rows backed this profile. A day folded from + /// two rows is not a shape; the baseline reader uses this to refuse such + /// a day rather than let it drag the median. + sampleCount Int @map("sample_count") + /// The IANA zone the day was bucketed in. A user who later moves zones + /// leaves behind profiles cut on the old clock; carrying the zone lets + /// the reader drop the mismatched days instead of silently comparing + /// hours that mean different things. + timezone String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type, dateKey]) + @@index([userId, type, dateKey(sort: Desc)]) + @@index([dateKey]) + @@map("intraday_cumulative_profiles") +} + // ─── Workouts (v1.4.25 W8d) ──────────────────────────────── // // HKWorkout-aligned typed columns for the analytics hot path + an diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 81973acc7..aa0f9c91b 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -18,7 +18,12 @@ * 5. Otherwise preserve legacy late-increment semantics, explicitly stamp * LEGACY_UNKNOWN, and increment syncVersion; fresh/adopted rows are also * LEGACY_UNKNOWN. - * 6. DELETE the original per-sample rows in the same transaction. + * 6. Fold the day's still-live per-sample rows into 24 hourly running totals + * and merge them into `IntradayCumulativeProfile` (v1.34.0). This runs + * inside the same transaction and BEFORE the delete: the shape of a + * cumulative day is the only substrate a "typical by 21:00" comparison + * can be built from, and step 7 is where it used to be destroyed. + * 7. DELETE the original per-sample rows in the same transaction. * * Designed to be invoked by both: * - the CLI at `scripts/drain-per-sample-cumulative.ts` @@ -56,6 +61,11 @@ import { localStartOfDay, type PerSampleRow, } from "./consolidation-tz"; +import { + foldHourlyCumulative, + mergeIntradayCumulativeProfile, + pruneIntradayCumulativeProfiles, +} from "./intraday-cumulative-profile"; // Re-export the shared timezone day-math primitives + `PerSampleRow` // shape so every established `drain-per-sample-cumulative` import site @@ -128,6 +138,11 @@ export interface DrainSummary { * whole global walk — every other day still collapses. */ daysFailed: number; + /** + * Intraday cumulative profiles dropped past the retention bound. Zero on + * a dry run, and zero on most nights — the bound is a year and a bit. + */ + profilesPruned: number; }; } @@ -202,6 +217,7 @@ export async function drainPerSampleCumulative( perSampleRowsDeleted: 0, dailyRowsUpserted: 0, daysFailed: 0, + profilesPruned: 0, }, }; @@ -249,6 +265,8 @@ export async function drainPerSampleCumulative( reducedValue, dayRows, sourceRowIds, + tz, + dateKey, }): Promise => { // Adopt-in-place canonical-noon resolution + a single P2002 retry, // mirroring `dense-intraday-retention.ts`. Two unique indexes can @@ -323,15 +341,23 @@ export async function drainPerSampleCumulative( AUTHORITATIVE_AGGREGATION_PROVENANCE.has(adoptProvenance); // Re-read contributors under the advisory lock. A serialized loser - // sees zero live rows and therefore performs no canonical update. + // sees zero live rows and therefore performs no canonical update — + // and, below, contributes nothing to the day's stored shape either. + // One read serves both: the sum the canonical row needs and the + // timestamps the hourly fold needs. + const liveRows = await tx.measurement.findMany({ + where: { id: { in: sourceRowIds } }, + select: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + }, + }); const liveIncrement = eidRow !== null && !protectedAggregate - ? ( - await tx.measurement.findMany({ - where: { id: { in: sourceRowIds } }, - select: { value: true }, - }) - ).reduce((sum, row) => sum + row.value, 0) + ? liveRows.reduce((sum, row) => sum + row.value, 0) : 0; const mergedValue = eidRow !== null ? eidRow.value + liveIncrement : reducedValue; @@ -381,6 +407,23 @@ export async function drainPerSampleCumulative( canonicalRowId = created.id; } + // v1.34.0 — keep the day's SHAPE before the delete below removes the + // only rows it can be derived from. A cumulative day has no + // meaningful latest reading, so the one honest comparison is against + // this person's own typical total at the same local hour; that needs + // an hourly substrate, and this transaction is the last moment it + // exists. Folded from the rows re-read under the advisory lock, so a + // serialised loser folds nothing and a late-arrival drain merges only + // its own new rows. + const shape = foldHourlyCumulative(liveRows, dateKey, tz); + if (shape) { + await mergeIntradayCumulativeProfile(tx, { + userId, + type, + increment: shape, + }); + } + // Hard-delete the per-sample rows that contributed to the sum. // EXCLUDE the adopted canonical row: a per-sample row that fell on // local-noon may be the row we just adopted as the daily total, so @@ -480,8 +523,22 @@ export async function drainPerSampleCumulative( summary.totals.usersScanned = usersScanned; + // v1.34.0 — bound the profile sidecar. One indexed range delete per run, + // across every account, on the same nightly tick that writes the profiles. + // A failure here must never fail the drain: the fold is the load-bearing + // half and a missed prune costs a few hundred bytes until tomorrow. + if (!summary.dryRun) { + try { + summary.totals.profilesPruned = + await pruneIntradayCumulativeProfiles(prismaClient); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`[drain] intraday-profile prune skipped — ${message}`); + } + } + log( - `[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed}${options.dryRun ? " (dry-run)" : ""}`, + `[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted} daysFailed=${summary.totals.daysFailed} profilesPruned=${summary.totals.profilesPruned}${options.dryRun ? " (dry-run)" : ""}`, ); return summary; } diff --git a/src/lib/measurements/intraday-cumulative-profile.ts b/src/lib/measurements/intraday-cumulative-profile.ts new file mode 100644 index 000000000..bb0891bea --- /dev/null +++ b/src/lib/measurements/intraday-cumulative-profile.ts @@ -0,0 +1,373 @@ +/** + * v1.34.0 — the intraday shape of a cumulative day, kept before the fold + * destroys it. + * + * A cumulative metric has no meaningful "latest reading": four thousand steps + * is a good day at 09:00 and a poor one at 22:00, and `signals-of-day` excludes + * steps for exactly that reason. The only honest comparison is against the same + * person's own typical total AT THE SAME LOCAL HOUR — and that needs an hourly + * substrate, which did not exist. `drainPerSampleCumulative` collapses the + * per-sample Apple Health rows into one daily total after the 36-hour grace + * window and hard-deletes the originals in the same transaction; Fitbit, + * Withings and Polar write daily totals directly and never had an intraday + * stream at all. + * + * This module owns that substrate, in three parts: + * + * 1. `foldHourlyCumulative` — the PURE fold. Per-sample rows for one local + * day become 24 running totals, element `h` carrying everything + * accumulated through the END of local hour `h`. No IO, so the drain and + * the live read share one definition of what a day's shape is. + * 2. `mergeIntradayCumulativeProfile` — the write the drain calls INSIDE its + * own transaction, immediately before the delete. Keyed on + * `(userId, type, dateKey)` and additive, so a day drained twice keeps + * both halves. + * 3. `readCumulativeDayProfiles` — the trailing-window read the same-time + * baseline issues. Persisted rows for the drained days, a live fold for + * the days still inside the grace window (which have no row yet), so the + * current day is always available. + * + * The table is a SIDECAR. The canonical `stats::` measurement + * stays the reading of record; nothing here is a measurement, and no read path + * takes a value off this table that the measurement rows do not already carry. + */ +import type { + MeasurementType, + Prisma, + PrismaClient, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +import { + dayKeyForUserTz, + hourOfDayForUserTz, + localDayWindow, + type PerSampleRow, +} from "./consolidation-tz"; + +/** Hours in a stored profile. Always 24, DST days included — see below. */ +export const PROFILE_HOURS = 24; + +/** + * How long a stored profile survives. + * + * The baseline only ever reads a trailing month, so this is not a data + * requirement — it is the bound that keeps a sidecar from growing forever. A + * year and a bit lets a same-time comparison one day reach across a full + * seasonal cycle without the table ever becoming something an operator has to + * think about: at ~200 bytes a row and four supported types, a decade of one + * account is still under a megabyte. + */ +export const CUMULATIVE_PROFILE_RETENTION_DAYS = 400; + +/** + * How far back a MISSING profile is folded live from raw rows. + * + * Days inside the drain's 36-hour grace window still hold their per-sample + * rows and have no persisted profile yet, so the reader folds them on demand — + * otherwise today and yesterday would be permanently invisible to a feature + * whose whole subject is today. Two days covers the 36-hour window plus the + * gap until the next nightly tick. + * + * It is deliberately NOT larger. Folding an arbitrary historical day live + * would mean an unbounded per-sample read on an account that has never been + * drained, on a request path — and on a drained account it would find nothing + * anyway, because the rows are gone. A day older than this with no profile is + * simply absent, which is the truth. + */ +export const CUMULATIVE_PROFILE_LIVE_FOLD_DAYS = 2; + +/** + * Rows a single live day-fold will read. A dense Apple Watch step day is + * ~1 440 rows; the cap is generous headroom over that and exists so a + * pathological account cannot turn a highlight into an unbounded scan. + */ +const MAX_LIVE_FOLD_ROWS = 5000; + +/** Prefix marking an already-collapsed daily-stats row. */ +const DAILY_STATS_PREFIX = "stats:"; + +/** One local day's cumulative shape, as the pure fold produces it. */ +export interface CumulativeDayFold { + /** Local calendar day (`YYYY-MM-DD`) in `timezone`. */ + dateKey: string; + /** IANA zone the day was cut on. */ + timezone: string; + /** + * 24 running totals — element `h` is everything accumulated from local + * midnight through the END of local hour `h`. Non-decreasing by + * construction; `[23]` equals `dayTotal`. + */ + hourlyCumulative: number[]; + /** The day's total. */ + dayTotal: number; + /** Raw per-sample rows that backed the fold. */ + sampleCount: number; +} + +/** A day profile as a reader resolved it, tagged with where it came from. */ +export interface CumulativeDayProfile extends CumulativeDayFold { + /** Whether this came off the table or was folded live from raw rows. */ + origin: "stored" | "live"; +} + +/** + * Fold one local day's per-sample rows into 24 running totals. + * + * Pure. Rows already collapsed into a `stats:` daily total are skipped — a + * day-sum row would dump the whole day into a single hour and invent a shape + * that never happened, the same trap `loadIntradayPulse` documents at its own + * step-bucket loop. Rows that fall outside `dateKey` are skipped too, so a + * caller may hand over a padded window without corrupting the day. + * + * DST costs nothing here. A fall-back day's repeated local hour folds into its + * one slot, and a spring-forward day's skipped hour simply repeats its + * predecessor's running total. Every stored day therefore has exactly 24 slots + * and is index-comparable with every other, which is the only property the + * same-hour comparison actually needs. + * + * Returns `null` when the day carried no usable per-sample row — an honest + * absence, never a zero-filled profile. A day with genuinely zero steps that + * WAS sampled comes back as a real profile of zeros; a day nobody measured + * comes back as nothing, and the two must not be confused. + */ +export function foldHourlyCumulative( + rows: readonly PerSampleRow[], + dateKey: string, + timezone: string, +): CumulativeDayFold | null { + const perHour = new Array(PROFILE_HOURS).fill(0); + let sampleCount = 0; + + for (const row of rows) { + if (row.externalId?.startsWith(DAILY_STATS_PREFIX)) continue; + if (dayKeyForUserTz(row.measuredAt, timezone) !== dateKey) continue; + const hour = hourOfDayForUserTz(row.measuredAt, timezone); + if (hour < 0 || hour >= PROFILE_HOURS) continue; + perHour[hour] += row.value; + sampleCount += 1; + } + + if (sampleCount === 0) return null; + + const hourlyCumulative = new Array(PROFILE_HOURS); + let running = 0; + for (let h = 0; h < PROFILE_HOURS; h += 1) { + running += perHour[h]; + hourlyCumulative[h] = running; + } + + return { + dateKey, + timezone, + hourlyCumulative, + dayTotal: running, + sampleCount, + }; +} + +/** + * Add one fold onto the stored profile for its day, inside the caller's + * transaction. + * + * The drain calls this immediately before deleting the rows the fold came + * from, so the shape is committed by the same commit that destroys its source + * — there is no window in which the samples are gone and the shape was never + * written. + * + * The merge is ADDITIVE, and it has to be. The drain deletes what it folds, so + * it only ever hands over rows nobody folded before: a day drained at the grace + * cutoff and then drained again a week later after a late watch sync would lose + * its first eleven hours under last-write-wins. Element-wise addition of two + * running totals is exactly the running total of the union, because a cumulative + * sum is linear — so the merged curve is the same one a single fold over all the + * rows would have produced. + * + * The caller must serialise its own reads and writes for the day; the cumulative + * drain does, under the per-bucket advisory lock it already takes. And the + * caller must pass only rows still LIVE at merge time: a re-run or the + * serialised loser of two overlapping ticks sees none, folds nothing, and never + * reaches here. + * + * A stored profile cut on a different timezone is replaced rather than added + * to. After a move the old curve's hours mean something else, and summing the + * two would produce a shape that describes no day that ever happened. + */ +export async function mergeIntradayCumulativeProfile( + tx: Prisma.TransactionClient, + args: { + userId: string; + type: MeasurementType; + increment: CumulativeDayFold; + }, +): Promise { + const { userId, type, increment } = args; + const key = { userId, type, dateKey: increment.dateKey }; + + const existing = await tx.intradayCumulativeProfile.findUnique({ + where: { userId_type_dateKey: key }, + select: { hourlyCumulative: true, sampleCount: true, timezone: true }, + }); + + const reusable = + existing !== null && + existing.timezone === increment.timezone && + existing.hourlyCumulative.length === PROFILE_HOURS; + + const hourlyCumulative = reusable + ? increment.hourlyCumulative.map((v, h) => v + existing.hourlyCumulative[h]) + : increment.hourlyCumulative; + const sampleCount = reusable + ? existing.sampleCount + increment.sampleCount + : increment.sampleCount; + + const data = { + hourlyCumulative, + dayTotal: hourlyCumulative[PROFILE_HOURS - 1], + sampleCount, + timezone: increment.timezone, + }; + + await tx.intradayCumulativeProfile.upsert({ + where: { userId_type_dateKey: key }, + create: { ...key, ...data }, + update: data, + }); +} + +/** + * Drop profiles older than the retention bound, across every account. + * + * `dateKey` is a zero-padded `YYYY-MM-DD` string, so the lexicographic + * comparison IS the chronological one and the delete is an index range scan. + * The cutoff is computed in UTC: it is 400 days back, and no timezone on earth + * moves that boundary by more than a day. + */ +export async function pruneIntradayCumulativeProfiles( + prismaClient: PrismaClient, + now: Date = new Date(), + retentionDays: number = CUMULATIVE_PROFILE_RETENTION_DAYS, +): Promise { + const cutoffKey = shiftDateKey( + now.toISOString().slice(0, 10), + -retentionDays, + ); + const { count } = await prismaClient.intradayCumulativeProfile.deleteMany({ + where: { dateKey: { lt: cutoffKey } }, + }); + return count; +} + +/** + * Read a trailing window of day profiles, newest day first. + * + * Stored rows answer the drained days. The days still inside the grace window + * hold no stored row yet, so each is folded live from its raw per-sample rows — + * bounded to `CUMULATIVE_PROFILE_LIVE_FOLD_DAYS` and to `MAX_LIVE_FOLD_ROWS` + * per day. A day that yields neither is absent from the result; the caller + * counts what it got rather than assuming a length. + * + * Profiles cut on a different timezone than the account's current one are + * dropped. After a move, "by 21:00" means a different moment than it did + * before, and averaging the two would quietly compare unlike hours. + */ +export async function readCumulativeDayProfiles(args: { + userId: string; + type: MeasurementType; + timezone: string; + /** Inclusive newest local day to consider (usually the caller's today). */ + endDateKey: string; + /** How many days back from `endDateKey`, inclusive of it. */ + windowDays: number; + prismaClient?: PrismaClient; +}): Promise { + const { userId, type, timezone, endDateKey, windowDays } = args; + const db = args.prismaClient ?? prisma; + const startDateKey = shiftDateKey(endDateKey, -(windowDays - 1)); + + const stored = await db.intradayCumulativeProfile.findMany({ + where: { + userId, + type, + dateKey: { gte: startDateKey, lte: endDateKey }, + }, + orderBy: { dateKey: "desc" }, + select: { + dateKey: true, + timezone: true, + hourlyCumulative: true, + dayTotal: true, + sampleCount: true, + }, + }); + + const byDay = new Map(); + for (const row of stored) { + // A day cut on another clock is not comparable — drop it rather than + // silently mix two meanings of "by 21:00". + if (row.timezone !== timezone) continue; + if (row.hourlyCumulative.length !== PROFILE_HOURS) continue; + byDay.set(row.dateKey, { + dateKey: row.dateKey, + timezone: row.timezone, + hourlyCumulative: row.hourlyCumulative, + dayTotal: row.dayTotal, + sampleCount: row.sampleCount, + origin: "stored", + }); + } + + // The recent days the drain has not reached yet. Folded live, newest first, + // and only for days actually inside the requested window. + for (let back = 0; back < CUMULATIVE_PROFILE_LIVE_FOLD_DAYS; back += 1) { + const dateKey = shiftDateKey(endDateKey, -back); + if (dateKey < startDateKey) break; + if (byDay.has(dateKey)) continue; + const folded = await foldLiveDay(db, userId, type, dateKey, timezone); + if (folded) byDay.set(dateKey, { ...folded, origin: "live" }); + } + + return [...byDay.values()].sort((a, b) => (a.dateKey < b.dateKey ? 1 : -1)); +} + +/** + * Fold one local day straight from its live per-sample rows. Scoped to the + * exact local-day window (DST-aware, 23/24/25 h) so the row cap is only ever + * spent on rows that are actually inside the day. + */ +async function foldLiveDay( + db: PrismaClient, + userId: string, + type: MeasurementType, + dateKey: string, + timezone: string, +): Promise { + const { dayStart, dayEnd } = localDayWindow(dateKey, timezone); + const rows = await db.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_LIVE_FOLD_ROWS, + select: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + }, + }); + return foldHourlyCumulative(rows, dateKey, timezone); +} + +/** + * The caller's local calendar day. Thin wrapper so a consumer of this module + * never has to reach for a second day-key implementation. + */ +export function localDateKey(now: Date, timezone: string): string { + return userDayKey(now, timezone); +} From b2955beffdfae7666d7608c5fbda057ba6c82a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:04:01 +0200 Subject: [PATCH 2/4] feat(insights): add the same-time baseline for cumulative metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare today's running total at the last completed local hour against this person's own typical total at that same hour, over a trailing four weeks of stored intraday profiles. Same estimator as the vitals baseline — median for the centre, median plus or minus k times MAD for the band — so a reader who understands one understands the other. It refuses to speak below fourteen usable days and says so through the existing insufficient arm rather than a band built on three days. It ships no projected day total: extrapolating a partial day is a guess, and this exists to replace guesses. Steps first; active energy, distance and flights ride the same engine. --- src/components/insights/derived/standards.ts | 10 + src/lib/insights/derived/dispatch.ts | 31 ++ src/lib/insights/derived/index.ts | 12 + src/lib/insights/derived/registry.ts | 65 +++- .../insights/derived/same-time-baseline.ts | 290 ++++++++++++++++++ 5 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/derived/same-time-baseline.ts diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index 3156f39ec..c81e29b22 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -178,4 +178,14 @@ export const METRIC_PROVENANCE: Record = }, caveatKey: "insights.derived.composite.TRAJECTORY.caveat", }, + SAME_TIME_BASELINE: { + methodKey: "insights.derived.composite.SAME_TIME_BASELINE.method", + standard: { + // Same robust median ± k·MAD estimator as the vitals baseline, applied + // to the window's totals at one local hour rather than to daily means. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat", + }, }; diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index 7044b03b0..b9ef6989c 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -15,6 +15,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { buildInsufficient, nowProvenanceTimestamp } from "./coverage"; import { getDerivedMetricMeta, + isSameTimeBaselineType, isVitalsBaselineType, isTrajectoryType, type DerivedMetricId, @@ -29,6 +30,7 @@ import { computeSleepScore } from "./sleep-score"; import { computeReadiness } from "./readiness"; import { computeCoincidentDeviation } from "./coincident-deviation"; import { computeTrajectory } from "./trajectory"; +import { computeSameTimeBaseline } from "./same-time-baseline"; import { computeWellnessScore, type WellnessScoreType, @@ -207,6 +209,35 @@ export async function computeDerivedMetric( now, }) as Promise>; } + case "SAME_TIME_BASELINE": { + // Today's running total against this person's own typical total at the + // same local hour. Steps is the default because it is the case the + // architecture structurally could not answer before, and the one the + // request was written around. + const requested = args.type ?? "ACTIVITY_STEPS"; + if (!isSameTimeBaselineType(requested)) { + return buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [requested], + }, + provenance: { + inputs: [requested], + source: "none", + windowDays: 0, + computedAt: nowProvenanceTimestamp(now), + }, + reason: "unsupported_baseline_type", + }); + } + return computeSameTimeBaseline(args.userId, args.profile, { + type: requested as MeasurementType, + windowDays: args.windowDays, + now, + }) as Promise>; + } case "RECOVERY_SCORE": case "STRESS_SCORE": case "STRAIN_SCORE": diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 3fe90a3f9..3197156a9 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -40,10 +40,14 @@ export { DERIVED_METRIC_IDS, VITALS_BASELINE_TYPES, TRAJECTORY_TYPES, + SAME_TIME_BASELINE_TYPES, + SAME_TIME_BASELINE_WINDOW_DAYS, + SAME_TIME_BASELINE_MIN_HISTORY_DAYS, isDerivedMetricId, getDerivedMetricMeta, isVitalsBaselineType, isTrajectoryType, + isSameTimeBaselineType, } from "./registry"; export type { DerivedMetricId, @@ -171,3 +175,11 @@ export type { TrajectoryOpts, OlsFit, } from "./trajectory"; + +// ── v1.34.0 same-time baseline for cumulative metrics (server-only) ── +export { computeSameTimeBaseline } from "./same-time-baseline"; +export type { + SameTimeBaselineValue, + SameTimeBaselineOpts, + SameTimeBand, +} from "./same-time-baseline"; diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 9ffb201cc..772934844 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -56,7 +56,9 @@ export type DerivedMetricId = /** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */ | "SIX_MINUTE_WALK_BAND" /** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */ - | "TRAJECTORY"; + | "TRAJECTORY" + /** v1.34.0: today's running total for a cumulative metric vs this person's own typical total at the same local hour. */ + | "SAME_TIME_BASELINE"; // Documented-as-omitted (v1.10.3): two additive HealthKit signals stay // trend-only with NO derived band, on purpose — @@ -126,6 +128,58 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [ "WEIGHT", ]; +/** + * The cumulative metrics a same-time baseline is defined for. + * + * These four accumulate through the day, which is exactly why a "latest + * reading" means nothing for them and a same-hour comparison means everything. + * + * `TIME_IN_DAYLIGHT` and `FALL_COUNT` are cumulative too and are deliberately + * out. Daylight exposure is environmental rather than behavioural — a same-time + * verdict on it would read as a judgement on the weather. A fall count is a + * near-always-zero safety event whose target is zero, and "you are behind your + * usual falls for this hour" is not a sentence this application will ever say. + */ +export const SAME_TIME_BASELINE_TYPES: MeasurementType[] = [ + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", +]; + +/** `true` when the type is one the same-time baseline is defined for. */ +export function isSameTimeBaselineType(type: string): boolean { + return (SAME_TIME_BASELINE_TYPES as string[]).includes(type); +} + +/** + * The trailing window the same-time baseline draws its typical day from. + * + * Four weeks. Long enough that every weekday is represented several times, so + * a single unusual Tuesday cannot move the median; short enough that the + * comparison still describes the life the person is living now rather than the + * one they had last season. + */ +export const SAME_TIME_BASELINE_WINDOW_DAYS = 28; + +/** + * How many usable days must be in hand before the highlight says anything. + * + * THIS IS THE THRESHOLD. It is defined here, in the client-safe registry, so + * the compute engine, the rail builder and the copy that explains the wait all + * read the same number rather than each carrying their own. + * + * Two weeks, not the seven days the vitals baseline uses. A resting heart rate + * is a stable physiological quantity and seven readings genuinely bound it. A + * person's day is not: it has a weekday shape and a weekend shape, and a median + * built from three working days would confidently call a Saturday morning + * "behind" for no better reason than that Saturdays were never in the sample. + * Fourteen days covers both twice over. Below it the metric returns the + * `insufficient` arm with `learning_usual_day`, which is the honest answer and + * the one this arm exists for. + */ +export const SAME_TIME_BASELINE_MIN_HISTORY_DAYS = 14; + const REGISTRY: Record = { VITALS_BASELINE: { id: "VITALS_BASELINE", @@ -282,6 +336,15 @@ const REGISTRY: Record = { minInputs: 1, implemented: true, }, + SAME_TIME_BASELINE: { + id: "SAME_TIME_BASELINE", + displayName: "Same-time baseline", + archetype: "any-user-baseline", + inputs: SAME_TIME_BASELINE_TYPES, + minHistoryDays: SAME_TIME_BASELINE_MIN_HISTORY_DAYS, + minInputs: 1, + implemented: true, + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/derived/same-time-baseline.ts b/src/lib/insights/derived/same-time-baseline.ts new file mode 100644 index 000000000..cea9da85f --- /dev/null +++ b/src/lib/insights/derived/same-time-baseline.ts @@ -0,0 +1,290 @@ +/** + * v1.34.0 — the same-time baseline for a cumulative metric. + * + * `signals-of-day` excludes steps and says why in its own comment: intraday + * step rows arrive as many partial-day fragments, so the newest raw row is not + * a reading. Every other derived metric in this directory compares a latest + * value to a band, and for a quantity that accumulates through the day that + * comparison has no meaning — four thousand steps is a good day at 09:00 and a + * poor one at 22:00. + * + * So this engine compares along the other axis. It takes today's running total + * at the last COMPLETED local hour and puts it against the same person's own + * typical total at that same hour, drawn from a trailing four weeks of their + * stored intraday profiles (`intraday-cumulative-profile.ts`). Same estimator + * as the vitals baseline — median for the centre, median ± k·MAD·1.4826 for + * the band — so a reader who understands one understands the other. + * + * Everything the surface renders is decided HERE: the two curves, the delta, + * the percentage, the band edges and the verdict of where today sits. The copy + * is a deterministic template over these numbers. Nothing downstream computes a + * comparison, and the Coach may explain one that already exists but may never + * produce one. + * + * What it deliberately does not produce: a projected day total. Extrapolating + * a partial day is a guess, and a feature that exists to replace guesses with + * the person's own history has no business shipping one. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + PROFILE_HOURS, + readCumulativeDayProfiles, + localDateKey, + type CumulativeDayProfile, +} from "@/lib/measurements/intraday-cumulative-profile"; +import { hourOfDayForUserTz } from "@/lib/measurements/consolidation-tz"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +import { buildBaselineBand, median, type BaselineProfile } from "./baseline"; +import { buildInsufficient, buildOk, deriveCoverage } from "./coverage"; +import { + SAME_TIME_BASELINE_MIN_HISTORY_DAYS, + SAME_TIME_BASELINE_WINDOW_DAYS, +} from "./registry"; +import type { Derived } from "./types"; + +/** + * Raw per-sample rows a stored day must have been folded from before it counts + * towards the typical curve. + * + * A day the watch contributed two readings to is not the shape of that day — + * it is two moments — and letting it into the median would quietly claim the + * person walked nothing for twenty-two hours. Four is a low bar that a worn + * watch clears by two orders of magnitude and a barely-synced day does not. + */ +const MIN_PROFILE_SAMPLES = 4; + +/** + * Canonical fallback units, matching what the Apple Health mapping writes for + * each cumulative type. Only used when the account holds no row to read the + * unit off — which, given the profile exists, essentially never happens. + */ +const FALLBACK_UNIT: Partial> = { + ACTIVITY_STEPS: "steps", + ACTIVE_ENERGY_BURNED: "kcal", + WALKING_RUNNING_DISTANCE: "m", + FLIGHTS_CLIMBED: "flights", +}; + +/** Where today's running total sits against the typical band at this hour. */ +export type SameTimeBand = "below" | "within" | "above"; + +/** The successful `value` payload for a same-time baseline. */ +export interface SameTimeBaselineValue { + /** The cumulative metric this compares. */ + type: MeasurementType; + /** Canonical unit of every figure below. */ + unit: string; + /** IANA zone every "hour" here is expressed in. */ + timezone: string; + /** Local calendar day the comparison is for. */ + dateKey: string; + /** + * The last COMPLETED local hour (0–23). Both curves are `asOfHour + 1` + * points long and index-aligned: element `h` is the total through the end of + * local hour `h`. + */ + asOfHour: number; + /** Today's running total through the end of `asOfHour`. */ + todayValue: number; + /** The median of the window's days at that same hour. */ + typicalValue: number; + /** Band lower edge — median − k·MAD·1.4826, floored at zero. */ + typicalLow: number; + /** Band upper edge — median + k·MAD·1.4826. */ + typicalHigh: number; + /** Signed difference, today − typical. */ + delta: number; + /** + * Today as a percentage of typical, rounded. `null` when the typical total + * at this hour is zero — which is the normal state at 05:00 and is an + * undefined ratio, not a hundred percent and not a failure. + */ + percentOfTypical: number | null; + /** The server's verdict of where today sits against the band. */ + band: SameTimeBand; + /** Today's cumulative curve, hours 0 … `asOfHour`. */ + todayCurve: number[]; + /** The typical cumulative curve over the same hours. */ + typicalCurve: number[]; + /** Days that actually backed the typical curve. */ + baselineDays: number; + /** Trailing window the days were drawn from. */ + windowDays: number; +} + +export interface SameTimeBaselineOpts { + /** The cumulative metric to compare. */ + type: MeasurementType; + /** Trailing window override (days). */ + windowDays?: number; + now?: Date; +} + +/** + * Compute the same-time baseline for one cumulative metric. + * + * `profile` is accepted for signature parity with every other engine in this + * directory — the dispatcher hands each one the same once-loaded profile — but + * a same-time comparison is purely against the person's own history and reads + * nothing off it. The timezone it does need is read here, because a comparison + * whose whole subject is "at this hour" cannot borrow anybody else's clock. + */ +export async function computeSameTimeBaseline( + userId: string, + _profile: BaselineProfile, + opts: SameTimeBaselineOpts, +): Promise> { + const now = opts.now ?? new Date(); + const type = opts.type; + const windowDays = opts.windowDays ?? SAME_TIME_BASELINE_WINDOW_DAYS; + const computedAt = now.toISOString(); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const dateKey = localDateKey(now, timezone); + + const gate = (reason: string, historyDays: number) => + buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: historyDays > 0 ? 1 : 0, + historyDays, + missing: historyDays > 0 ? [] : [type], + }, + provenance: { + inputs: [type], + source: historyDays > 0 ? "live" : "none", + windowDays, + computedAt, + }, + reason, + }); + + // The comparison is anchored to the last hour that has FINISHED. Comparing + // against an hour still in progress would put a partial hour of today + // against a whole one of every other day and manufacture a shortfall out of + // nothing. Before 01:00 there is no completed hour, and the honest answer is + // that the day is too young to say anything about. + const asOfHour = hourOfDayForUserTz(now, timezone) - 1; + if (asOfHour < 0) return gate("day_too_young", 0); + + const profiles = await readCumulativeDayProfiles({ + userId, + type, + timezone, + endDateKey: dateKey, + windowDays, + }); + + const today = profiles.find((p) => p.dateKey === dateKey) ?? null; + const history = profiles.filter( + (p) => p.dateKey !== dateKey && isUsableHistoryDay(p), + ); + + // No sub-daily rows for today at all. This is the permanent state for every + // account whose activity arrives as a daily total — Fitbit, Withings, Polar + // all write one row per day and never had an intraday stream. It is honest + // absence and must never render as a failure. + if (!today) return gate("no_intraday_today", history.length); + + if (history.length < SAME_TIME_BASELINE_MIN_HISTORY_DAYS) { + return gate("learning_usual_day", history.length); + } + + // Both curves run hour 0 … asOfHour, index-aligned. + const hours = asOfHour + 1; + const todayCurve = today.hourlyCumulative.slice(0, hours); + const typicalCurve: number[] = []; + for (let h = 0; h < hours; h += 1) { + typicalCurve.push(median(history.map((p) => p.hourlyCumulative[h]))); + } + + const todayValue = todayCurve[asOfHour]; + const typicalValue = typicalCurve[asOfHour]; + + // The band comes off the same estimator the vitals baseline uses, applied to + // the window's totals AT THIS HOUR rather than to daily means. Floored at + // zero: a cumulative total cannot be negative, and a band edge below zero + // would widen the "within" verdict on the low side for free. + const atHour = history.map((p) => p.hourlyCumulative[asOfHour]); + const spread = buildBaselineBand(atHour); + const typicalLow = Math.max(0, spread ? spread.low : typicalValue); + const typicalHigh = spread ? spread.high : typicalValue; + + const band: SameTimeBand = + todayValue < typicalLow + ? "below" + : todayValue > typicalHigh + ? "above" + : "within"; + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: history.length, + missing: [], + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { + type, + unit: await resolveUnit(userId, type), + timezone, + dateKey, + asOfHour, + todayValue, + typicalValue, + typicalLow, + typicalHigh, + delta: todayValue - typicalValue, + percentOfTypical: + typicalValue > 0 ? Math.round((todayValue / typicalValue) * 100) : null, + band, + todayCurve, + typicalCurve, + baselineDays: history.length, + windowDays, + }, + coverage, + confidence, + provenance: { + inputs: [type], + // The profiles are their own tier, neither a rollup bucket nor a live + // measurement read, so the honest label for the dominant read is `live`. + source: "live", + windowDays, + computedAt, + }, + }); +} + +/** A stored day dense enough to describe the shape of that day. */ +function isUsableHistoryDay(p: CumulativeDayProfile): boolean { + return ( + p.sampleCount >= MIN_PROFILE_SAMPLES && + p.hourlyCumulative.length === PROFILE_HOURS + ); +} + +/** + * The unit the account's own rows for this type carry. Read rather than + * assumed, so a source that stores distance in a different unit than Apple + * Health does labels its own numbers correctly. + */ +async function resolveUnit( + userId: string, + type: MeasurementType, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { unit: true }, + }); + return row?.unit ?? FALLBACK_UNIT[type] ?? "count"; +} From 6bd8d387ae5fcee1108e27b7da2aea35a1a2a208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:32:22 +0200 Subject: [PATCH 3/4] feat(insights): surface the same-time baseline on Today and in the grid The rail gains a same_time_baseline kind and a server builder, which is the growth path that type documents for itself. It speaks only when today is actually outside the typical band: a card that reports doing exactly what you usually do teaches people to stop reading the rail. Dismissing it is keyed by day and metric, not by the hour, so it stays dismissed instead of coming back every sixty minutes. The Insights grid gains a section of four tiles, one per cumulative metric, each honestly quiet until the history accrues. The two-trajectory chart it needs did not exist, so DeltaSparkline gains a second mode rather than a second chart family: the comparison curve rides the same container, the same fixed height and the same shared y-domain as a dashed muted line behind the live one, which is the only way the two can be read against each other. Six locales. The wipe and backup plans learn about the new table: the profile cannot be rebuilt once the samples are gone, so it is carried, not derived. --- docs/api/openapi.yaml | 3 + messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/pl.json | 24 +- prisma/schema.prisma | 18 +- src/components/daily/priority-card.tsx | 2 + .../insights/derived/delta-sparkline.tsx | 48 ++- .../insights/derived/sparkline-delta-tile.tsx | 29 +- .../insights/derived/use-dashboard-derived.ts | 22 +- .../insights/derived/vitals-dashboard.tsx | 185 ++++++++++++ src/lib/daily/__tests__/digest.test.ts | 124 ++++++++ src/lib/daily/digest.ts | 96 ++++++ src/lib/daily/load-digest.ts | 67 ++++- src/lib/daily/priority-item-key.ts | 16 + src/lib/daily/priority-item.ts | 5 +- src/lib/data-wipe/wipe-plan.ts | 5 + src/lib/export/backup-plan.ts | 6 + .../derived/__tests__/dispatch.test.ts | 3 +- .../__tests__/same-time-baseline.test.ts | 278 ++++++++++++++++++ .../insights/derived/same-time-baseline.ts | 8 +- .../intraday-cumulative-profile.test.ts | 177 +++++++++++ 24 files changed, 1204 insertions(+), 32 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/same-time-baseline.test.ts create mode 100644 src/lib/measurements/__tests__/intraday-cumulative-profile.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58dc9d0be..022f7dc08 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7396,6 +7396,7 @@ paths: - STAIR_DESCENT_SPEED_BASELINE - SIX_MINUTE_WALK_BAND - TRAJECTORY + - SAME_TIME_BASELINE description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`." @@ -27283,6 +27284,7 @@ components: - STAIR_DESCENT_SPEED_BASELINE - SIX_MINUTE_WALK_BAND - TRAJECTORY + - SAME_TIME_BASELINE description: Echoes the requested derived-metric id (tags the union). status: type: string @@ -32913,6 +32915,7 @@ components: - milestone - ecg_new_recording - tension_window + - same_time_baseline description: Closed rail-item kind. itemKey: description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / diff --git a/messages/de.json b/messages/de.json index e633cfc99..b0d537416 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben — mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.", "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends — keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt." + }, + "SAME_TIME_BASELINE": { + "method": "Dein heutiger laufender Gesamtwert im Vergleich zum Median derselben Ortszeit-Stunde aus deinen letzten vier Wochen, mit einem robusten Median-±-MAD-Band darum. Nur Tage mit ausreichend aufgezeichneten Details zählen, und es wird kein Vergleich gezeigt, bevor mindestens vierzehn davon vorliegen.", + "caveat": "Ein Vergleich mit deiner eigenen jüngeren Vergangenheit, kein Ziel. Er sagt, wo der heutige Tag zu dieser Stunde gegenüber deinem üblichen Muster steht — und nichts darüber, ob dieses Muster für dich das richtige ist." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.", "insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.", "daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann." + }, + "sameTime": { + "sectionTitle": "Heute bisher", + "sectionSubtitle": "Deine laufenden Summen im Vergleich zu dem, was zu dieser Stunde für dich üblich ist", + "learning": "Lernt deinen üblichen Tag — {count} von {target} Tagen", + "framing": "Üblich bis {time}: {typical} {unit}", + "comparisonLabel": "Dein üblicher Tag" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "keinen unregelmäßigen Rhythmus", "inconclusive": "ein nicht eindeutiges Ergebnis" } + }, + "sameTimeBaseline": { + "title": { + "below": "Hinter deinem üblichen Tempo", + "above": "Vor deinem üblichen Tempo" + }, + "body": { + "below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.", + "above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Loslassen", "viewMilestone": "Ansehen", "viewPulse": "Puls ansehen", - "viewEcg": "Aufzeichnung ansehen" + "viewEcg": "Aufzeichnung ansehen", + "viewSteps": "Schritte ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index d7183b5c9..3260c0737 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "A least-squares line fitted to your recent daily values and projected forward over a short horizon, with the textbook prediction interval that widens the further it reaches. Computed only when the recent trend is strong enough and your history is long and current enough; otherwise no line is drawn.", "caveat": "A projection of your own recent trend — not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades." + }, + "SAME_TIME_BASELINE": { + "method": "Your running total for today set against the median of the same local hour across your last four weeks, with a robust median ± MAD band around it. Only days with enough recorded detail count towards it, and no comparison is shown until at least fourteen of them are in hand.", + "caveat": "A comparison with your own recent history, not a target. It says where today sits against your usual pattern at this hour and nothing about whether that pattern is right for you." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.", "insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.", "daysNeeded": "About {count} more days of tracking needed before a projection can appear." + }, + "sameTime": { + "sectionTitle": "Today so far", + "sectionSubtitle": "Your running totals against what is usual for you at this hour", + "learning": "Learning your usual day — {count} of {target} days", + "framing": "Typical by {time}: {typical} {unit}", + "comparisonLabel": "Your typical day" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "no irregular rhythm", "inconclusive": "an inconclusive result" } + }, + "sameTimeBaseline": { + "title": { + "below": "Behind your usual pace", + "above": "Ahead of your usual pace" + }, + "body": { + "below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.", + "above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Let it go", "viewMilestone": "View", "viewPulse": "View pulse", - "viewEcg": "View recording" + "viewEcg": "View recording", + "viewSteps": "View steps" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 642747c38..0007667d1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "Una recta de mínimos cuadrados ajustada a tus valores diarios recientes y proyectada hacia delante en un horizonte corto, con el intervalo de predicción clásico que se ensancha cuanto más lejos llega. Solo se calcula cuando la tendencia reciente es lo bastante marcada y tu historial es lo bastante largo y actual; de lo contrario no se traza ninguna línea.", "caveat": "Una proyección de tu propia tendencia reciente, no una predicción, un objetivo ni un pronóstico clínico. Solo se cumple si el patrón reciente continúa; la banda sombreada muestra con qué rapidez se desvanece esa certeza." + }, + "SAME_TIME_BASELINE": { + "method": "Tu total acumulado de hoy frente a la mediana de esa misma hora local durante tus últimas cuatro semanas, con una banda robusta de mediana ± MAD alrededor. Solo cuentan los días con suficiente detalle registrado, y no se muestra ninguna comparación hasta tener al menos catorce.", + "caveat": "Una comparación con tu propio historial reciente, no un objetivo. Indica dónde se sitúa hoy frente a tu patrón habitual a esta hora, y nada sobre si ese patrón es el adecuado para ti." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.", "insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado.", "daysNeeded": "Faltan unos {count} días más de registro para que pueda aparecer una proyección." + }, + "sameTime": { + "sectionTitle": "Hoy hasta ahora", + "sectionSubtitle": "Tus totales acumulados frente a lo que es habitual en ti a esta hora", + "learning": "Aprendiendo tu día habitual — {count} de {target} días", + "framing": "Habitual a las {time}: {typical} {unit}", + "comparisonLabel": "Tu día habitual" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "ningún ritmo irregular", "inconclusive": "un resultado no concluyente" } + }, + "sameTimeBaseline": { + "title": { + "below": "Por detrás de tu ritmo habitual", + "above": "Por delante de tu ritmo habitual" + }, + "body": { + "below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.", + "above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Dejarlo ir", "viewMilestone": "Ver", "viewPulse": "Ver pulso", - "viewEcg": "Ver registro" + "viewEcg": "Ver registro", + "viewSteps": "Ver pasos" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 604b61659..0b6d1f49c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "Une droite des moindres carrés ajustée à vos valeurs journalières récentes et projetée sur un court horizon, avec l’intervalle de prédiction classique qui s’élargit à mesure qu’il s’éloigne. Calculée uniquement lorsque la tendance récente est suffisamment marquée et que votre historique est assez long et récent ; sinon aucune ligne n’est tracée.", "caveat": "Une projection de votre propre tendance récente — ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe." + }, + "SAME_TIME_BASELINE": { + "method": "Votre total cumulé du jour comparé à la médiane de cette même heure locale sur vos quatre dernières semaines, avec une bande robuste médiane ± MAD autour. Seuls les jours suffisamment détaillés comptent, et aucune comparaison n'apparaît avant d'en avoir au moins quatorze.", + "caveat": "Une comparaison avec votre propre historique récent, pas un objectif. Elle dit où se situe aujourd'hui par rapport à votre habitude à cette heure, et rien sur le fait que cette habitude vous convienne." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.", "insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.", "daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître." + }, + "sameTime": { + "sectionTitle": "Aujourd'hui jusqu'ici", + "sectionSubtitle": "Vos totaux cumulés face à ce qui est habituel chez vous à cette heure", + "learning": "Apprentissage de votre journée habituelle — {count} jours sur {target}", + "framing": "Habituel à {time} : {typical} {unit}", + "comparisonLabel": "Votre journée habituelle" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "aucun rythme irrégulier", "inconclusive": "un résultat non concluant" } + }, + "sameTimeBaseline": { + "title": { + "below": "En retrait de votre rythme habituel", + "above": "En avance sur votre rythme habituel" + }, + "body": { + "below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.", + "above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Laisser tomber", "viewMilestone": "Voir", "viewPulse": "Voir le pouls", - "viewEcg": "Voir l’enregistrement" + "viewEcg": "Voir l’enregistrement", + "viewSteps": "Voir les pas" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index 715a74ce6..a8952c247 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "Una retta ai minimi quadrati adattata ai tuoi valori giornalieri recenti e proiettata in avanti su un breve orizzonte, con l’intervallo di previsione classico che si allarga man mano che si estende. Calcolata solo quando la tendenza recente è abbastanza marcata e la tua cronologia è abbastanza lunga e aggiornata; altrimenti non viene tracciata alcuna linea.", "caveat": "Una proiezione della tua tendenza recente — non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce." + }, + "SAME_TIME_BASELINE": { + "method": "Il tuo totale progressivo di oggi confrontato con la mediana della stessa ora locale nelle ultime quattro settimane, con una banda robusta mediana ± MAD attorno. Contano solo i giorni con abbastanza dettaglio registrato, e nessun confronto viene mostrato finché non ce ne sono almeno quattordici.", + "caveat": "Un confronto con la tua storia recente, non un obiettivo. Dice dove si colloca oggi rispetto alla tua abitudine a quest'ora, e nulla sul fatto che quell'abitudine sia quella giusta per te." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.", "insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.", "daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione." + }, + "sameTime": { + "sectionTitle": "Oggi finora", + "sectionSubtitle": "I tuoi totali progressivi rispetto a ciò che per te è abituale a quest'ora", + "learning": "Sto imparando la tua giornata tipo — {count} di {target} giorni", + "framing": "Abituale entro le {time}: {typical} {unit}", + "comparisonLabel": "La tua giornata tipo" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "nessun ritmo irregolare", "inconclusive": "un risultato non conclusivo" } + }, + "sameTimeBaseline": { + "title": { + "below": "Sotto il tuo ritmo abituale", + "above": "Sopra il tuo ritmo abituale" + }, + "body": { + "below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.", + "above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Lascia andare", "viewMilestone": "Vedi", "viewPulse": "Vedi battito", - "viewEcg": "Vedi registrazione" + "viewEcg": "Vedi registrazione", + "viewSteps": "Vedi i passi" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index 927e9b7e5..7478892a9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3640,6 +3640,10 @@ "TRAJECTORY": { "method": "Prosta dopasowana metodą najmniejszych kwadratów do Twoich ostatnich wartości dziennych i rzutowana w przód na krótki horyzont, z klasycznym przedziałem predykcji, który poszerza się tym bardziej, im dalej sięga. Obliczana tylko wtedy, gdy ostatni trend jest dość wyraźny, a Twoja historia wystarczająco długa i aktualna; w przeciwnym razie linia nie jest rysowana.", "caveat": "Rzut Twojego własnego ostatniego trendu — nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje." + }, + "SAME_TIME_BASELINE": { + "method": "Twoja dzisiejsza suma narastająca zestawiona z medianą tej samej godziny lokalnej z ostatnich czterech tygodni, z odpornym pasmem mediana ± MAD wokół niej. Liczą się tylko dni z wystarczającą liczbą zapisów, a porównanie nie pojawia się, dopóki nie ma ich co najmniej czternastu.", + "caveat": "Porównanie z twoją własną niedawną historią, a nie cel. Mówi, gdzie dziś jesteś względem swojego zwykłego wzorca o tej godzinie — i nic o tym, czy ten wzorzec jest dla ciebie właściwy." } }, "scores": { @@ -3673,6 +3677,13 @@ "caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.", "insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.", "daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut." + }, + "sameTime": { + "sectionTitle": "Dziś do tej pory", + "sectionSubtitle": "Twoje sumy narastające względem tego, co o tej godzinie jest u ciebie zwykłe", + "learning": "Uczę się twojego zwykłego dnia — {count} z {target} dni", + "framing": "Zwykle do {time}: {typical} {unit}", + "comparisonLabel": "Twój zwykły dzień" } }, "cardioFitness": { @@ -8979,6 +8990,16 @@ "notDetected": "brak nieregularnego rytmu", "inconclusive": "wynik niejednoznaczny" } + }, + "sameTimeBaseline": { + "title": { + "below": "Poniżej swojego zwykłego tempa", + "above": "Powyżej swojego zwykłego tempa" + }, + "body": { + "below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.", + "above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze." + } } }, "action": { @@ -8990,7 +9011,8 @@ "checkinLetGo": "Odpuść", "viewMilestone": "Zobacz", "viewPulse": "Zobacz tętno", - "viewEcg": "Zobacz zapis" + "viewEcg": "Zobacz zapis", + "viewSteps": "Zobacz kroki" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4cbf49763..abdf1e1fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1983,34 +1983,34 @@ model MeasurementRollup { // still inside the grace window have no row yet and are folded live // from the raw rows on read, so the current day is always available. model IntradayCumulativeProfile { - id String @id @default(cuid()) - userId String @map("user_id") - type MeasurementType + id String @id @default(cuid()) + userId String @map("user_id") + type MeasurementType /// Local calendar day the profile describes (`YYYY-MM-DD`), in the /// user's timezone at fold time. Lexicographically ordered, so a /// trailing-window read and the retention prune are both range scans. - dateKey String @map("date_key") @db.VarChar(10) + dateKey String @map("date_key") @db.VarChar(10) /// Twenty-four running totals in canonical units — element `h` is the /// value accumulated from local midnight through the END of local hour /// `h`, so the array is non-decreasing and `[23]` equals `dayTotal`. /// A DST day still has exactly 24 slots: the repeated hour folds into /// its slot and a skipped hour repeats its predecessor's running total, /// which keeps every stored day index-comparable with every other. - hourlyCumulative Float[] @map("hourly_cumulative") + hourlyCumulative Float[] @map("hourly_cumulative") /// The day's total — `hourlyCumulative[23]`, denormalised so a coverage /// check never has to read the array. - dayTotal Float @map("day_total") + dayTotal Float @map("day_total") /// How many raw per-sample rows backed this profile. A day folded from /// two rows is not a shape; the baseline reader uses this to refuse such /// a day rather than let it drag the median. - sampleCount Int @map("sample_count") + sampleCount Int @map("sample_count") /// The IANA zone the day was bucketed in. A user who later moves zones /// leaves behind profiles cut on the old clock; carrying the zone lets /// the reader drop the mismatched days instead of silently comparing /// hours that mean different things. timezone String - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index c264b9795..c1b8bc31e 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -22,6 +22,7 @@ import { Activity, Award, CalendarClock, + Footprints, HeartPulse, MessageCircle, Pill, @@ -53,6 +54,7 @@ const KIND_ICON: Record = { milestone: Award, ecg_new_recording: HeartPulse, tension_window: Activity, + same_time_baseline: Footprints, }; /** diff --git a/src/components/insights/derived/delta-sparkline.tsx b/src/components/insights/derived/delta-sparkline.tsx index 7d9500ce0..fd1f0a1c4 100644 --- a/src/components/insights/derived/delta-sparkline.tsx +++ b/src/components/insights/derived/delta-sparkline.tsx @@ -10,20 +10,49 @@ import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; * (`@/components/charts/chart-runtime`); its fixed 40 px container is owned * by the tile, so the async gap paints an empty band of identical size — * zero layout shift, pixel-identical once mounted. + * + * Two modes, one chart. + * + * - **Trend** (the original): one series, area-filled under a sentiment + * stroke. Every baseline tile in the vitals grid uses this. + * - **Comparison** (v1.34.0): the same series against a second one, drawn + * index-aligned behind it as a dashed muted line with no fill. The + * same-time baseline needs two trajectories on one pair of axes — today's + * running total and the person's typical one — and nothing in the tree + * drew that. It is a second mode here rather than a new chart family + * because it is the same picture with one more line: same container, same + * fixed height, same shared y-domain, so the two curves are read against + * each other and not against two different scales. + * + * The comparison line renders as an `` with no fill rather than a + * `` so the chart stays a plain `AreaChart` and both series contribute + * to the `dataMin`/`dataMax` domain automatically. */ export function DeltaSparkline({ data, strokeVar, + comparisonLabel, }: { - /** `{ i, v }` points, already length-guarded (≥2) by the tile. */ - data: Array<{ i: number; v: number }>; + /** + * `{ i, v }` points, already length-guarded (≥2) by the tile. An optional + * `b` on a point is the comparison series' value at the same index — + * present on every point or on none. + */ + data: Array<{ i: number; v: number; b?: number }>; /** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */ strokeVar: string; + /** + * Accessible name for the comparison curve, when one is present. Passed + * through to the series so a screen reader reaching the chart is told what + * the second line is rather than meeting an unnamed shape. + */ + comparisonLabel?: string; }) { // A stable per-instance id for the gradient . Deriving it from a // label slug collides when two tiles share a localized label (the gradient // fill on the second tile would not resolve); useId() is collision-free. const fillId = useId(); + const hasComparison = data.some((point) => point.b !== undefined); return ( @@ -34,6 +63,21 @@ export function DeltaSparkline({ + {/* The comparison curve sits UNDER the live one so today's line is + never occluded by the reference it is being judged against. */} + {hasComparison ? ( + + ) : null} = 2 ? series.map((v, i) => ({ i, v })) : null; + series && series.length >= 2 + ? series.map((v, i) => (paired ? { i, v, b: paired[i] } : { i, v })) + : null; // The sparkline tints with the trend sentiment so it reads "same signal" // as the arrow; neutral metrics ride the muted-foreground line. @@ -199,7 +220,11 @@ export function SparklineDeltaTile({ className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark" > - + ) : null} diff --git a/src/components/insights/derived/use-dashboard-derived.ts b/src/components/insights/derived/use-dashboard-derived.ts index 02fdc00c0..8cf4e49f4 100644 --- a/src/components/insights/derived/use-dashboard-derived.ts +++ b/src/components/insights/derived/use-dashboard-derived.ts @@ -42,12 +42,25 @@ export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [ { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, ]; +/** + * v1.34.0 — the cumulative metrics that carry a same-time baseline. Order is + * the order the section renders in: steps leads because it is the number + * people actually check, and the one a "how am I doing" question is usually + * about. + */ +export const SECTION_CUMULATIVE: string[] = [ + "ACTIVITY_STEPS", + "ACTIVE_ENERGY_BURNED", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", +]; + /** * The full set of tokens the overview reads in one batch — the five wellness * scores + the four derived re-frames + one baseline per vital (minus HRV, - * which has its own balance tile) + the mobility/body metrics. The - * coincident-deviation flag is read by the dedicated "Today's signal" card, - * not here. + * which has its own balance tile) + the mobility/body metrics + one same-time + * baseline per cumulative metric. The coincident-deviation flag is read by the + * dedicated "Today's signal" card, not here. */ export function dashboardTokens(): DerivedBatchToken[] { const tokens: DerivedBatchToken[] = [ @@ -69,6 +82,9 @@ export function dashboardTokens(): DerivedBatchToken[] { for (const { metric } of SECTION_MOBILITY) { tokens.push({ metric }); } + for (const type of SECTION_CUMULATIVE) { + tokens.push({ metric: "SAME_TIME_BASELINE", type }); + } return tokens; } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index a1bfb06cd..ea58b4a1a 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -23,8 +23,10 @@ import { type DerivedBatchRead } from "./use-derived-metric"; import { SECTION_VITALS, SECTION_MOBILITY, + SECTION_CUMULATIVE, type DashboardDerived, } from "./use-dashboard-derived"; +import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry"; import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. @@ -34,6 +36,7 @@ import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age" import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance"; import type { BmiValue } from "@/lib/insights/derived/bmi"; import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk"; +import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline"; import type { DerivedProvenance } from "@/lib/insights/derived/types"; /** @@ -129,6 +132,14 @@ const MOBILITY_TILE_LAYOUT_ID: Record = { WRIST_TEMPERATURE: "wrist-temperature", }; +/** Same-time-baseline `MeasurementType` → layout tile id (routed slug). */ +const CUMULATIVE_TILE_LAYOUT_ID: Record = { + ACTIVITY_STEPS: "steps", + ACTIVE_ENERGY_BURNED: "active-energy", + WALKING_RUNNING_DISTANCE: "walking-distance", + FLIGHTS_CLIMBED: "flights-climbed", +}; + /** * v1.15.11 W2c — `true` when the layout marks this tile id visible (or the * layout does not enumerate the id, in which case the tile is always-on). A @@ -308,6 +319,118 @@ function BaselineTile({ ); } +/** + * v1.34.0 — the same-time baseline tile for one cumulative metric. + * + * Every other tile in this grid frames a latest reading against a band. A + * cumulative metric has no latest reading, so this one frames today's running + * total against the person's own typical total at the same local hour, and the + * sparkline carries both trajectories on one pair of axes rather than one. + * + * The states are the states the rest of the grid already has, with one + * addition that matters more here than anywhere else: a baseline built from + * three days is not a baseline, so below the floor the tile says it is still + * learning and shows how far along it is. It never renders a number it does + * not have the history for. + */ +function SameTimeBaselineTile({ + type, + read, + isLoading, +}: TileProps & { + type: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const unitDisplay = useUnitDisplay(); + const data = read({ + metric: "SAME_TIME_BASELINE", + type, + }); + + if (isLoading || !data) return null; + + const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Footprints; + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; + const label = labelKey ? t(labelKey) : type; + const tileId = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type; + + if (data.status === "insufficient") { + // Absent, not gated. No sub-daily rows today (every daily-total source — + // Fitbit, Withings, Polar — is permanently here), an unsupported type, or + // a day whose first hour has not finished yet. None of these is a state + // the user can act on, so the tile does not render at all rather than + // occupying the grid with an apology. + if (data.reason !== "learning_usual_day") return null; + return ( +
+ +

+ {t("insights.derived.sameTime.learning", { + count: data.coverage.historyDays, + target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS, + })} +

+ +
+ ); + } + + const v = data.value!; + const unit = unitDisplay.unitFor(type); + const toShown = (raw: number) => unitDisplay.toDisplay(type, raw); + // A transformed distance reads in kilometres and wants a decimal; a step, + // flight or kilocalorie count is a whole number and a decimal on it is noise. + const precision = unitDisplay.isTransformed(type) ? 1 : 0; + // The comparison is anchored to the END of `asOfHour`, which is the top of + // the following hour on the clock. 24-hour form, built from the number the + // server already resolved — deriving a locale-formatted instant on the + // client from a server-side local hour is how a hydration mismatch starts. + const byClock = `${String(v.asOfHour + 1).padStart(2, "0")}:00`; + + return ( +
+ + } + footer={} + /> +
+ ); +} + /** * Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough * re-frame). Surfaces the device's estimated distance + trend always; the @@ -632,6 +755,29 @@ function hasRenderableMobility( return false; } +/** + * Whether the batch holds at least one same-time tile worth painting. Mirrors + * the tile's own gate exactly: only an `ok` value or the honest "still + * learning" state counts, so the section heading never strands over a grid + * that turned out to be empty (every daily-total source lands there). + */ +function hasRenderableSameTime( + read: DerivedBatchRead, + layout: InsightsLayout | undefined, +): boolean { + for (const type of SECTION_CUMULATIVE) { + if (!tileVisible(layout, CUMULATIVE_TILE_LAYOUT_ID[type] ?? type)) continue; + const data = read({ + metric: "SAME_TIME_BASELINE", + type, + }); + if (!data) continue; + if (data.status === "ok") return true; + if (data.reason === "learning_usual_day") return true; + } + return false; +} + export function VitalsDashboard({ batch, layout, className }: DashboardProps) { const { t } = useTranslations(); @@ -724,6 +870,20 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout, read, isLoading]); + const cumulativeTiles = useMemo(() => { + return SECTION_CUMULATIVE.map((type) => { + const id = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type; + return { + id, + order: tileOrder(layout, id), + node: , + }; + }) + .filter((e) => tileVisible(layout, e.id)) + .sort((a, b) => a.order - b.order); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout, read, isLoading]); + // A failed batch (retry:0 + the 8 s client ceiling) must read as an error, // not as "no data" — otherwise the whole Vitals + Wellness surface silently // vanishes. Surface a compact message + a Retry that refetches the one @@ -761,6 +921,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { // pops in below it — the CLS this pass removes. const showSection = isLoading || hasRenderableVital(read, layout); const showMobility = !isLoading && hasRenderableMobility(read, layout); + const showSameTime = !isLoading && hasRenderableSameTime(read, layout); return (
)} + {showSameTime && ( +
+ +
1 && "sm:grid-cols-2 lg:grid-cols-3", + )} + > + {cumulativeTiles.map((tile) => tile.node)} +
+
+ )} {showMobility && (
{ }); }); +describe("buildDailyDigest — same_time_baseline item", () => { + const sameTimeItem = (d: ReturnType) => + d.worthALook.find((i) => i.kind === "same_time_baseline"); + + function sameTime( + over: Partial = {}, + ): DailyDigestSameTime { + return { + type: "ACTIVITY_STEPS", + band: "below", + asOfHour: 20, + todayValue: 1834, + typicalValue: 5111, + todayLabel: "1,834", + typicalLabel: "5,111", + ...over, + }; + } + + it("names both totals and the hour they are compared at", () => { + const d = buildDailyDigest(input({ sameTime: sameTime() }), t); + const item = sameTimeItem(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("info"); + // The comparison covers everything through the END of hour 20, which + // reads on the clock as 21:00. + expect(item?.body).toContain("21:00"); + expect(item?.body).toContain("1,834"); + expect(item?.body).toContain("5,111"); + expect(item?.actions[0].intent).toBe("steps.view"); + expect(item?.actions[0].href).toBe("/insights/steps"); + }); + + it("says nothing about a day that is tracking its own normal", () => { + // A rail that reports "exactly as usual" every afternoon teaches people to + // stop reading the rail. + const d = buildDailyDigest( + input({ sameTime: sameTime({ band: "within" }) }), + t, + ); + expect(sameTimeItem(d)).toBeUndefined(); + }); + + it("says nothing when the engine returned no comparison", () => { + expect( + sameTimeItem(buildDailyDigest(input({ sameTime: null }), t)), + ).toBeUndefined(); + expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined(); + }); + + it("stays silent when the insights module is off", () => { + const d = buildDailyDigest( + input({ modules: { insights: false }, sameTime: sameTime() }), + t, + ); + expect(sameTimeItem(d)).toBeUndefined(); + }); + + it("uses a different sentence when today is ahead", () => { + const below = buildDailyDigest(input({ sameTime: sameTime() }), t); + const above = buildDailyDigest( + input({ sameTime: sameTime({ band: "above" }) }), + t, + ); + expect(sameTimeItem(above)?.title).not.toBe(sameTimeItem(below)?.title); + expect(sameTimeItem(above)?.body).not.toBe(sameTimeItem(below)?.body); + }); + + it("keys the dismissal by day and metric, never by the hour", () => { + // Folding the hour in would undo the dismissal every sixty minutes. + const d = buildDailyDigest( + input({ sameTime: sameTime(), todayLocalDate: "2026-07-16" }), + t, + ); + expect(sameTimeItem(d)?.itemKey).toBe( + sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"), + ); + // The same key whatever the clock says — that is the whole point. + const later = buildDailyDigest( + input({ + sameTime: sameTime({ asOfHour: 9 }), + todayLocalDate: "2026-07-16", + }), + t, + ); + expect(sameTimeItem(later)?.itemKey).toBe(sameTimeItem(d)?.itemKey); + }); + + it("is dropped once dismissed, and stays dropped as the hour moves on", () => { + const dismissed = new Set([ + sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"), + ]); + for (const asOfHour of [9, 14, 20]) { + const d = buildDailyDigest( + input({ + sameTime: sameTime({ asOfHour }), + todayLocalDate: "2026-07-16", + dismissedItemKeys: dismissed, + }), + t, + ); + expect(sameTimeItem(d)).toBeUndefined(); + } + }); + + it("yields the bounded rail to time-sensitive actions first", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "nightscout", state: "parked" }, + ], + sameTime: sameTime(), + }), + t, + ); + expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); + expect(sameTimeItem(d)).toBeUndefined(); + }); +}); + describe("buildDailyDigest — ecg_new_recording (S10)", () => { const ecgItem = (d: ReturnType) => d.worthALook.find((i) => i.kind === "ecg_new_recording"); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 5477055ee..3d550ce8e 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -31,6 +31,7 @@ import { import { ecgItemKey, milestoneItemKey, + sameTimeBaselineItemKey, tensionWindowItemKey, } from "@/lib/daily/priority-item-key"; import { @@ -127,6 +128,37 @@ export interface DailyDigestTensionWindow { * DTO carries no samples). The builder decides "new" from `recordedAt`; the * card attributes any verdict to the recording device. */ +/** + * v1.34.0 — the day's same-time activity read, already computed and gated by + * `computeSameTimeBaseline`. The builder formats it and decides nothing: the + * band verdict, the two totals and the hour are the server's, and the copy is + * a deterministic template over them. + * + * Null whenever the engine returned its `insufficient` arm — still learning + * the usual day, no intraday rows today, or the day too young to compare. The + * rail stays quiet in every one of those cases rather than guessing. + */ +export interface DailyDigestSameTime { + /** The cumulative metric compared (`ACTIVITY_STEPS`). */ + type: string; + /** Where today sits against the typical band at this hour. */ + band: "below" | "within" | "above"; + /** The last completed local hour the comparison is anchored to (0–23). */ + asOfHour: number; + /** Today's running total through the end of `asOfHour`, already rounded. */ + todayValue: number; + /** The typical total at the same hour, already rounded. */ + typicalValue: number; + /** + * The same two figures grouped for the reader's locale. The copy + * interpolates these because the server translator substitutes raw strings + * and "1834 steps" is not how anybody writes it; the numbers above stay so a + * consumer that wants to compute rather than read still can. + */ + todayLabel: string; + typicalLabel: string; +} + export interface DailyDigestEcg { recordedAt: Date; /** The recording device's OWN verdict, or null when unclassified. */ @@ -213,6 +245,13 @@ export interface DailyDigestInput { milestone?: Milestone | null; /** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */ tensionWindow: DailyDigestTensionWindow | null; + /** + * v1.34.0 — today's cumulative-activity read against this person's own + * typical total at the same local hour, or null. Optional so a consumer that + * predates it stays valid; the builder treats a missing value as "nothing to + * say about today's activity yet". + */ + sameTime?: DailyDigestSameTime | null; /** * S10 — the freshest ECG recording (device verdict + recordedAt only), or * null. Optional so consumers that predate the ECG weave stay valid; the @@ -514,6 +553,53 @@ function buildTensionWindowItem( }; } +/** + * v1.34.0 — the same-time activity card. + * + * A cumulative metric has no meaningful latest reading, so this is the one + * rail item whose whole content is a comparison: today's running total against + * the person's own typical total at this hour. Every number in it was computed + * and gated server-side; the builder only chooses which of two sentences to + * render and fills the blanks. + * + * It speaks only when today is actually OUTSIDE the typical band. A day + * tracking its own normal is not worth a card, and a rail that says "you are + * doing exactly what you usually do" every afternoon teaches people to stop + * reading the rail. + */ +function buildSameTimeBaselineItem( + sameTime: DailyDigestSameTime | null | undefined, + modules: DigestModuleMap, + todayLocalDate: string, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "insights")) return null; + if (!sameTime) return null; + if (sameTime.band === "within") return null; + // The comparison is against everything accumulated through the END of + // `asOfHour`, which reads on the clock as the top of the next hour. + const time = `${String(sameTime.asOfHour + 1).padStart(2, "0")}:00`; + return { + kind: "same_time_baseline", + itemKey: sameTimeBaselineItemKey(todayLocalDate, sameTime.type), + title: t(`daily.item.sameTimeBaseline.title.${sameTime.band}`), + body: t(`daily.item.sameTimeBaseline.body.${sameTime.band}`, { + time, + today: sameTime.todayLabel, + typical: sameTime.typicalLabel, + }), + status: "info", + actions: [ + { + labelKey: "daily.action.viewSteps", + intent: "steps.view", + href: "/insights/steps", + }, + ], + moduleKey: "insights", + }; +} + /** * The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate` * wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep @@ -779,6 +865,16 @@ export function buildDailyDigest( t, ); if (tension) worthALook.push(tension); + // v1.34.0 — the same-time activity read sits last for the same reason the + // tension marker does: it is context about the day so far, not an action + // that expires, so anything time-sensitive wins the bounded rail ahead of it. + const sameTime = buildSameTimeBaselineItem( + input.sameTime, + input.modules, + input.todayLocalDate, + t, + ); + if (sameTime) worthALook.push(sameTime); // Defence-in-depth: no card ever exceeds the P1 action cap. for (const item of worthALook) { diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 0bd7b90ec..45e98373b 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -33,12 +33,14 @@ import { type DailyDigestEcg, type DailyDigestPreventiveDue, type DailyDigestScore, + type DailyDigestSameTime, type DailyDigestSyncIssue, type DailyDigestTensionWindow, } from "@/lib/daily/digest"; import { ecgItemKey, milestoneItemKey, + sameTimeBaselineItemKey, tensionWindowItemKey, } from "@/lib/daily/priority-item-key"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; @@ -52,6 +54,7 @@ import { type Milestone, } from "@/lib/daily/milestones"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { computeSameTimeBaseline } from "@/lib/insights/derived/same-time-baseline"; /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; @@ -166,8 +169,25 @@ const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; interface DailyDigestExtras { milestone: Milestone | null; tensionWindow: DailyDigestTensionWindow | null; + /** + * The cached cell holds the same-time read WITHOUT its locale-formatted + * labels: the cell is keyed on (user, local day) and a locale-specific + * string in it would be served to a reader who has since switched language. + * The labels are grouped at the call site, where the locale is resolved. + */ + sameTime: Omit | null; } +/** + * v1.34.0 — the rail's same-time read is STEPS only. + * + * All four cumulative metrics carry a same-time baseline and all four have a + * tile in the Insights grid. The rail is a glance of at most three items, so + * putting four activity cards on it would crowd out an overdue dose to say the + * same thing four ways. Steps is the number people actually check. + */ +const SAME_TIME_RAIL_TYPE = "ACTIVITY_STEPS"; + function digestExtrasCacheKey(userId: string, dateKey: string): string { return `${userId}|daily-digest-extras|${dateKey}`; } @@ -186,13 +206,35 @@ async function loadDailyDigestExtrasCached( // revision ran them sequentially). Each is already fault-isolated // internally (a milestone read-hiccup or a tension-read failure leaves // its own field quiet rather than breaking the other). - const [milestone, tensionWindow] = await Promise.all([ + const [milestone, tensionWindow, sameTime] = await Promise.all([ gatherFreshMilestone(userId, now), loadIntradayPulse(userId, timezone, todayLocalDate) .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) .catch(() => null), + // The engine gates itself — it returns its `insufficient` arm while it + // is still learning the usual day, when the account has no intraday + // rows at all (every daily-total source), and before the day's first + // hour has finished. Only the `ok` arm reaches the rail. Passing no + // profile because the engine reads none; fault-isolated like its + // siblings, since the digest is a must-not-fail path. + computeSameTimeBaseline(userId, null, { + type: SAME_TIME_RAIL_TYPE, + now, + }) + .then((d): DailyDigestExtras["sameTime"] => + d.status === "ok" + ? { + type: d.value.type, + band: d.value.band, + asOfHour: d.value.asOfHour, + todayValue: Math.round(d.value.todayValue), + typicalValue: Math.round(d.value.typicalValue), + } + : null, + ) + .catch(() => null), ]); - return { milestone, tensionWindow }; + return { milestone, tensionWindow, sameTime }; }, annotate, DIGEST_EXTRAS_CACHE_TTL_MS, @@ -435,14 +477,14 @@ export async function loadDailyDigest( // per-sample tension reads on every request — see its docblock for the // cache/invalidation contract. const insightsEnabled = modules.insights !== false; - const { milestone, tensionWindow } = insightsEnabled + const { milestone, tensionWindow, sameTime } = insightsEnabled ? await loadDailyDigestExtrasCached( user.id, user.timezone, todayLocalDate, now, ) - : { milestone: null, tensionWindow: null }; + : { milestone: null, tensionWindow: null, sameTime: null }; // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so @@ -454,6 +496,7 @@ export async function loadDailyDigest( tensionWindow ? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay) : null, + sameTime ? sameTimeBaselineItemKey(todayLocalDate, sameTime.type) : null, ].filter((k): k is string => k !== null); const dismissedRows = @@ -465,7 +508,20 @@ export async function loadDailyDigest( : []; const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey)); - const { t } = getServerTranslator(resolveLocale(locale)); + const resolvedLocale = resolveLocale(locale); + const { t } = getServerTranslator(resolvedLocale); + + // Group the two figures for the reader. `Intl.NumberFormat` is the only + // locale-aware step the digest takes on its own; everything else it renders + // comes through the translator. + const groupNumber = new Intl.NumberFormat(resolvedLocale).format; + const sameTimeItem: DailyDigestSameTime | null = sameTime + ? { + ...sameTime, + todayLabel: groupNumber(sameTime.todayValue), + typicalLabel: groupNumber(sameTime.typicalValue), + } + : null; const digest = buildDailyDigest( { @@ -481,6 +537,7 @@ export async function loadDailyDigest( coachPlans, milestone, tensionWindow, + sameTime: sameTimeItem, latestEcg, todayLocalDate, dismissedItemKeys, diff --git a/src/lib/daily/priority-item-key.ts b/src/lib/daily/priority-item-key.ts index 0b14e3c57..ba2d93419 100644 --- a/src/lib/daily/priority-item-key.ts +++ b/src/lib/daily/priority-item-key.ts @@ -39,3 +39,19 @@ export function tensionWindowItemKey( ): string { return `tension_window:${localDayKey}:${partOfDay}`; } + +/** + * `same_time_baseline::` — one key per metric + * per local day. + * + * Deliberately NOT keyed on the hour the comparison was anchored to. The card + * refreshes as the day goes on, and folding the hour into the key would mean a + * dismissal at 14:00 was undone at 15:00 — the user would have to swat the + * same card away nine times before bed. Dismissing it means "not today". + */ +export function sameTimeBaselineItemKey( + localDayKey: string, + type: string, +): string { + return `same_time_baseline:${localDayKey}:${type}`; +} diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts index afbb5fe5b..3957ecada 100644 --- a/src/lib/daily/priority-item.ts +++ b/src/lib/daily/priority-item.ts @@ -36,13 +36,15 @@ export const PRIORITY_ITEM_KINDS = [ "milestone", "ecg_new_recording", "tension_window", + "same_time_baseline", ] as const; export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number]; /** * The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an - * elevated-at-rest window. These carry no pending action; the user can only + * elevated-at-rest window, a same-time activity read. These carry no pending + * action; the user can only * acknowledge them, so they're the only kinds the Today rail lets you * dismiss. The remaining kinds (`dose_window`, `sync_issue`, * `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their @@ -54,6 +56,7 @@ export const DISMISSIBLE_PRIORITY_ITEM_KINDS = [ "milestone", "ecg_new_recording", "tension_window", + "same_time_baseline", ] as const satisfies readonly PriorityItemKind[]; export type DismissiblePriorityItemKind = diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 7df2dcda6..694de4540 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -49,6 +49,11 @@ export const WIPE_MODELS = [ // The rollup tier is a per-day, per-metric reconstruction of the same // history. A wipe that leaves it behind is not a wipe. "MeasurementRollup", + // The intraday cumulative profile is the hourly shape of a day that the + // fold kept after deleting the samples it came from. It is that day's + // record and nothing else holds it, so a wipe that leaves it behind leaves + // behind an account's activity pattern. + "IntradayCumulativeProfile", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index e65a4b572..88d1f1a8f 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -52,6 +52,12 @@ export const BACKED_UP_MODELS = [ // ── Measurements ────────────────────────────────────────────────────────── "Measurement", + // Not derived, despite looking it. The hourly shape of a cumulative day is + // folded out of the per-sample rows in the same transaction that deletes + // them, so past the 36-hour grace window there is nothing left to rebuild it + // from. A restore without it silently resets the same-time baseline to + // "still learning" for two weeks. + "IntradayCumulativeProfile", // ── Medications, and everything hanging off one ─────────────────────────── // The parent alone restores a drug list with no schedule, no history and no diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index cce1ee5b6..bec915336 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -85,7 +85,8 @@ describe("registry", () => { expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE"); expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND"); expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY"); - expect(DERIVED_METRIC_IDS.length).toBe(16); + expect(DERIVED_METRIC_IDS).toContain("SAME_TIME_BASELINE"); + expect(DERIVED_METRIC_IDS.length).toBe(17); }); }); diff --git a/src/lib/insights/derived/__tests__/same-time-baseline.test.ts b/src/lib/insights/derived/__tests__/same-time-baseline.test.ts new file mode 100644 index 000000000..ddf2a18be --- /dev/null +++ b/src/lib/insights/derived/__tests__/same-time-baseline.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUniqueUser = vi.fn(); +const findFirstMeasurement = vi.fn(); +const findManyProfiles = vi.fn(); +const findManyMeasurements = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (...a: unknown[]) => findUniqueUser(...a) }, + measurement: { + findFirst: (...a: unknown[]) => findFirstMeasurement(...a), + findMany: (...a: unknown[]) => findManyMeasurements(...a), + }, + intradayCumulativeProfile: { + findMany: (...a: unknown[]) => findManyProfiles(...a), + }, + }, +})); + +import { computeSameTimeBaseline } from "../same-time-baseline"; +import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "../registry"; + +/** + * The honesty contract, pinned. + * + * A same-time comparison is only worth anything if it refuses to speak when it + * cannot. Most of what follows tests silence, on purpose: the failure this + * feature must never have is a confident "you are behind" derived from three + * days of history, or from an account that has no intraday stream at all and + * therefore has today's total wrong. + */ + +const TZ = "UTC"; +// 20:30 UTC, so the last COMPLETED local hour is 19. +const NOW = new Date("2026-06-10T20:30:00Z"); +const TODAY = "2026-06-10"; + +/** A curve that reaches `total` linearly across the day. */ +function curve(total: number): number[] { + return Array.from({ length: 24 }, (_, h) => + Math.round((total * (h + 1)) / 24), + ); +} + +function profileRow(dateKey: string, total: number, sampleCount = 200) { + return { + dateKey, + timezone: TZ, + hourlyCumulative: curve(total), + dayTotal: total, + sampleCount, + }; +} + +/** `days` historical profiles, newest first, ending the day before today. */ +function history(days: number, total: number, sampleCount = 200) { + return Array.from({ length: days }, (_, i) => { + const d = new Date(`${TODAY}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - (i + 1)); + return profileRow(d.toISOString().slice(0, 10), total, sampleCount); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + findUniqueUser.mockResolvedValue({ timezone: TZ }); + findFirstMeasurement.mockResolvedValue({ unit: "steps" }); + // No live rows by default — every day under test comes off a stored profile. + findManyMeasurements.mockResolvedValue([]); +}); + +describe("computeSameTimeBaseline", () => { + it("refuses to speak below the history floor", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 4000), + ...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("insufficient"); + if (d.status !== "insufficient") throw new Error("unreachable"); + expect(d.reason).toBe("learning_usual_day"); + expect(d.coverage.historyDays).toBe( + SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1, + ); + }); + + it("speaks once the floor is reached", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 4000), + ...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(d.value.baselineDays).toBe(SAME_TIME_BASELINE_MIN_HISTORY_DAYS); + expect(d.value.asOfHour).toBe(19); + // Both curves cover hour 0 … 19 and are index-aligned. + expect(d.value.todayCurve).toHaveLength(20); + expect(d.value.typicalCurve).toHaveLength(20); + expect(d.value.todayValue).toBe(d.value.todayCurve[19]); + expect(d.value.typicalValue).toBe(d.value.typicalCurve[19]); + expect(d.value.delta).toBe(d.value.todayValue - d.value.typicalValue); + expect(d.value.band).toBe("below"); + expect(d.value.unit).toBe("steps"); + }); + + it("says nothing when the account has no intraday rows today", async () => { + // The permanent state for every daily-total source. Honest absence, and + // it must never be confused with a day of zero activity. + findManyProfiles.mockResolvedValue(history(20, 10_000)); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("insufficient"); + if (d.status !== "insufficient") throw new Error("unreachable"); + expect(d.reason).toBe("no_intraday_today"); + }); + + it("says nothing before the day's first hour has finished", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 100), + ...history(20, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: new Date("2026-06-10T00:20:00Z"), + }); + + expect(d.status).toBe("insufficient"); + if (d.status !== "insufficient") throw new Error("unreachable"); + expect(d.reason).toBe("day_too_young"); + }); + + it("drops days that were cut on another timezone", async () => { + // After a move, "by 21:00" means a different moment. Averaging the two + // would quietly compare unlike hours. + const moved = history(20, 10_000).map((p) => ({ + ...p, + timezone: "Pacific/Auckland", + })); + findManyProfiles.mockResolvedValue([profileRow(TODAY, 4000), ...moved]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("insufficient"); + if (d.status !== "insufficient") throw new Error("unreachable"); + // Twenty stored days, none of them usable, and the metric says so rather + // than comparing hours that mean different things. + expect(d.reason).toBe("learning_usual_day"); + expect(d.coverage.historyDays).toBe(0); + }); + + it("drops days too sparsely sampled to be a shape", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 4000), + ...history(20, 10_000, 2), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("insufficient"); + if (d.status !== "insufficient") throw new Error("unreachable"); + expect(d.reason).toBe("learning_usual_day"); + expect(d.coverage.historyDays).toBe(0); + }); + + it("calls a day inside its own band 'within'", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 10_000), + ...history(20, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(d.value.band).toBe("within"); + expect(d.value.percentOfTypical).toBe(100); + }); + + it("calls a busy day 'above'", async () => { + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 30_000), + ...history(20, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(d.value.band).toBe("above"); + expect(d.value.delta).toBeGreaterThan(0); + }); + + it("never puts the band's lower edge below zero", async () => { + // A cumulative total cannot be negative, and a sub-zero edge would widen + // the "within" verdict on the low side for free. + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 0), + ...history(20, 200), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(d.value.typicalLow).toBeGreaterThanOrEqual(0); + }); + + it("reports no percentage rather than a fabricated one at an empty hour", async () => { + // At 05:00 the typical total is legitimately zero. A ratio against zero is + // undefined, not a hundred percent. + findManyProfiles.mockResolvedValue([ + { ...profileRow(TODAY, 0), hourlyCumulative: new Array(24).fill(0) }, + ...history(20, 0).map((p) => ({ + ...p, + hourlyCumulative: new Array(24).fill(0), + })), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(d.value.percentOfTypical).toBeNull(); + }); + + it("ships no projected day total", async () => { + // Extrapolating a partial day is a guess, and this feature exists to + // replace guesses with the person's own history. + findManyProfiles.mockResolvedValue([ + profileRow(TODAY, 4000), + ...history(20, 10_000), + ]); + + const d = await computeSameTimeBaseline("u1", null, { + type: "ACTIVITY_STEPS", + now: NOW, + }); + + expect(d.status).toBe("ok"); + if (d.status !== "ok") throw new Error("unreachable"); + expect(Object.keys(d.value)).not.toContain("projectedDayTotal"); + }); +}); diff --git a/src/lib/insights/derived/same-time-baseline.ts b/src/lib/insights/derived/same-time-baseline.ts index cea9da85f..6bd74b813 100644 --- a/src/lib/insights/derived/same-time-baseline.ts +++ b/src/lib/insights/derived/same-time-baseline.ts @@ -128,12 +128,14 @@ export interface SameTimeBaselineOpts { * `profile` is accepted for signature parity with every other engine in this * directory — the dispatcher hands each one the same once-loaded profile — but * a same-time comparison is purely against the person's own history and reads - * nothing off it. The timezone it does need is read here, because a comparison - * whose whole subject is "at this hour" cannot borrow anybody else's clock. + * nothing off it. It is therefore nullable, so a caller outside the dispatcher + * (the daily digest) does not have to issue a profile read it has no use for. + * The timezone the engine does need is read here, because a comparison whose + * whole subject is "at this hour" cannot borrow anybody else's clock. */ export async function computeSameTimeBaseline( userId: string, - _profile: BaselineProfile, + _profile: BaselineProfile | null, opts: SameTimeBaselineOpts, ): Promise> { const now = opts.now ?? new Date(); diff --git a/src/lib/measurements/__tests__/intraday-cumulative-profile.test.ts b/src/lib/measurements/__tests__/intraday-cumulative-profile.test.ts new file mode 100644 index 000000000..ac67c8626 --- /dev/null +++ b/src/lib/measurements/__tests__/intraday-cumulative-profile.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; + +import { + PROFILE_HOURS, + foldHourlyCumulative, +} from "../intraday-cumulative-profile"; +import type { PerSampleRow } from "../consolidation-tz"; + +/** + * The pure fold is the whole substrate: everything the same-time baseline + * compares is a slice of what this function produced, months earlier, in the + * transaction that deleted the rows it read. If the fold is wrong nothing + * downstream can notice, because the evidence is gone. + */ + +const TZ = "Europe/Berlin"; + +function row( + iso: string, + value: number, + externalId: string | null = null, +): PerSampleRow { + return { + id: `row-${iso}-${value}`, + type: "ACTIVITY_STEPS", + value, + measuredAt: new Date(iso), + externalId, + }; +} + +describe("foldHourlyCumulative", () => { + it("accumulates into the local hour each sample fell in", () => { + // 07:30 and 07:50 Berlin (= 05:30 / 05:50 UTC in June) land in hour 7; + // 21:10 Berlin lands in hour 21. + const fold = foldHourlyCumulative( + [ + row("2026-06-10T05:30:00Z", 400), + row("2026-06-10T05:50:00Z", 600), + row("2026-06-10T19:10:00Z", 1000), + ], + "2026-06-10", + TZ, + ); + + expect(fold).not.toBeNull(); + expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS); + expect(fold!.hourlyCumulative[6]).toBe(0); + expect(fold!.hourlyCumulative[7]).toBe(1000); + expect(fold!.hourlyCumulative[20]).toBe(1000); + expect(fold!.hourlyCumulative[21]).toBe(2000); + expect(fold!.hourlyCumulative[23]).toBe(2000); + expect(fold!.dayTotal).toBe(2000); + expect(fold!.sampleCount).toBe(3); + }); + + it("never decreases", () => { + const fold = foldHourlyCumulative( + [ + row("2026-06-10T06:00:00Z", 120), + row("2026-06-10T10:00:00Z", 80), + row("2026-06-10T16:00:00Z", 300), + ], + "2026-06-10", + TZ, + ); + const curve = fold!.hourlyCumulative; + for (let h = 1; h < curve.length; h += 1) { + expect(curve[h]).toBeGreaterThanOrEqual(curve[h - 1]); + } + }); + + it("skips an already-collapsed daily total", () => { + // The trap this guards: a `stats:` day-sum row would dump the whole day + // into whichever hour it happens to sit in and invent a shape that never + // happened. Only the per-sample row may contribute. + const fold = foldHourlyCumulative( + [ + row( + "2026-06-10T10:00:00Z", + 12_000, + "stats:HKQuantityTypeIdentifierStepCount:2026-06-10", + ), + row("2026-06-10T06:00:00Z", 500), + ], + "2026-06-10", + TZ, + ); + expect(fold!.dayTotal).toBe(500); + expect(fold!.sampleCount).toBe(1); + }); + + it("skips rows from another local day", () => { + // 23:30 UTC on the 9th is already the 10th in Berlin, and 22:30 UTC on the + // 10th is already the 11th — a caller may hand over a padded window. + const fold = foldHourlyCumulative( + [ + row("2026-06-09T21:00:00Z", 999), + row("2026-06-10T06:00:00Z", 500), + row("2026-06-10T22:30:00Z", 777), + ], + "2026-06-10", + TZ, + ); + expect(fold!.dayTotal).toBe(500); + expect(fold!.sampleCount).toBe(1); + }); + + it("returns null for a day with no usable sample", () => { + // Absence is absence. A zero-filled profile would claim the person walked + // nothing all day, which is a different statement from "nobody measured". + expect(foldHourlyCumulative([], "2026-06-10", TZ)).toBeNull(); + expect( + foldHourlyCumulative( + [row("2026-06-10T10:00:00Z", 9000, "stats:X:2026-06-10")], + "2026-06-10", + TZ, + ), + ).toBeNull(); + }); + + it("keeps a measured day of genuine zeros as a real profile", () => { + const fold = foldHourlyCumulative( + [row("2026-06-10T06:00:00Z", 0), row("2026-06-10T14:00:00Z", 0)], + "2026-06-10", + TZ, + ); + expect(fold).not.toBeNull(); + expect(fold!.dayTotal).toBe(0); + expect(fold!.sampleCount).toBe(2); + }); + + it("still produces 24 slots across a DST transition", () => { + // 2026-03-29 is the spring-forward day in Berlin: 02:00 local never + // happens. The profile must still be 24 slots so every stored day stays + // index-comparable with every other. + const fold = foldHourlyCumulative( + [row("2026-03-29T00:30:00Z", 100), row("2026-03-29T09:00:00Z", 400)], + "2026-03-29", + "Europe/Berlin", + ); + expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS); + expect(fold!.dayTotal).toBe(500); + }); + + it("folds a fall-back day's repeated hour into one slot", () => { + // 2026-10-25 in Berlin runs 02:00–02:59 twice (00:30 and 01:30 UTC). + const fold = foldHourlyCumulative( + [row("2026-10-25T00:30:00Z", 10), row("2026-10-25T01:30:00Z", 20)], + "2026-10-25", + "Europe/Berlin", + ); + expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS); + expect(fold!.hourlyCumulative[2]).toBe(30); + expect(fold!.dayTotal).toBe(30); + expect(fold!.sampleCount).toBe(2); + }); + + it("adds element-wise to the same curve a single fold would produce", () => { + // The property the additive merge in the drain relies on: a cumulative sum + // is linear, so folding two halves and adding equals folding the union. + const first = [row("2026-06-10T06:00:00Z", 500)]; + const second = [row("2026-06-10T16:00:00Z", 700)]; + + const a = foldHourlyCumulative(first, "2026-06-10", TZ)!; + const b = foldHourlyCumulative(second, "2026-06-10", TZ)!; + const union = foldHourlyCumulative( + [...first, ...second], + "2026-06-10", + TZ, + )!; + + const merged = a.hourlyCumulative.map((v, h) => v + b.hourlyCumulative[h]); + expect(merged).toEqual(union.hourlyCumulative); + expect(a.sampleCount + b.sampleCount).toBe(union.sampleCount); + }); +}); From b56f5721a2b66a03aec68a15fac17c6d0ceb5c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:39:37 +0200 Subject: [PATCH 4/4] test(measurements): pin that the day's shape is written before the delete The drain's existing transaction mock had no profile delegate, so the fold could have thrown inside the per-day error boundary and every assertion would still have passed. Cover the merge directly: the upsert lands before the deleteMany, the stored curve matches where the samples actually fell, and a serialised tick that finds the rows already drained writes nothing. --- .../drain-per-sample-cumulative.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 0d1ba4e8b..39da25dc6 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -788,3 +788,154 @@ describe("drainPerSampleCumulative — concurrent fold does not double-count (F2 expect(update).not.toHaveBeenCalled(); }); }); + +/** The columns the drain writes onto one stored profile. */ +interface StoredProfile { + dateKey: string; + hourlyCumulative: number[]; + dayTotal: number; + sampleCount: number; + timezone: string; +} + +describe("drainPerSampleCumulative — the day's shape survives the delete", () => { + // The whole point of the fold: the per-sample rows are the ONLY thing an + // hourly curve can be derived from, and the next statement deletes them. + // A test that lets the merge fail silently is worse than no test, because + // the evidence it was supposed to keep is gone by the time anybody looks. + + function buildShapeMock(options: { drainedByAnotherTick?: boolean } = {}) { + const findManyUser = vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]); + + // Two samples on 2026-05-16 Berlin: 08:00 local (06:00 UTC) and + // 20:00 local (18:00 UTC). + const rows = [ + { + id: "samp-1", + type: "ACTIVITY_STEPS" as const, + value: 1200, + measuredAt: new Date("2026-05-16T06:00:00.000Z"), + externalId: "hk-uuid-1", + }, + { + id: "samp-2", + type: "ACTIVITY_STEPS" as const, + value: 800, + measuredAt: new Date("2026-05-16T18:00:00.000Z"), + externalId: "hk-uuid-2", + }, + ]; + + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => + args.where.type === "ACTIVITY_STEPS" ? rows : [], + ); + + const order: string[] = []; + const create = vi.fn(async () => { + order.push("create"); + return { id: "stats-row-new" }; + }); + const update = vi.fn().mockResolvedValue({ id: "stats-row" }); + const deleteMany = vi.fn(async () => { + order.push("deleteMany"); + return { count: rows.length }; + }); + const findFirst = vi.fn( + async (args: { where: Record }) => { + if ("externalId" in args.where) return null; + if ("measuredAt" in args.where) return null; + return { unit: "steps" }; + }, + ); + // The in-transaction re-read of the contributing rows, under the lock. The + // serialised loser of two overlapping ticks sees none of them. + const findMany = vi + .fn() + .mockResolvedValue(options.drainedByAnotherTick ? [] : rows); + const $queryRaw = vi.fn().mockResolvedValue([{ locked: 1 }]); + + const profileFindUnique = vi.fn().mockResolvedValue(null); + let storedProfile: StoredProfile | null = null; + const profileUpsert = vi.fn(async (args: { create: StoredProfile }) => { + order.push("profileUpsert"); + storedProfile = args.create; + return {}; + }); + + const tx = { + $queryRaw, + measurement: { update, create, deleteMany, findFirst, findMany }, + intradayCumulativeProfile: { + findUnique: profileFindUnique, + upsert: profileUpsert, + }, + }; + const prisma = { + user: { findMany: findManyUser }, + measurement: { findMany: findManyMeasurement }, + intradayCumulativeProfile: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + $transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient; + + return { + prisma, + profileUpsert, + order, + deleteMany, + storedProfile: () => storedProfile, + }; + } + + it("writes the hourly curve before deleting the rows it came from", async () => { + const { prisma, profileUpsert, order, deleteMany } = buildShapeMock(); + + const summary = await drainPerSampleCumulative(prisma, { log: () => {} }); + + expect(summary.totals.daysFailed).toBe(0); + expect(profileUpsert).toHaveBeenCalledTimes(1); + expect(deleteMany).toHaveBeenCalledTimes(1); + // Order inside the one transaction is the whole guarantee. + expect(order.indexOf("profileUpsert")).toBeLessThan( + order.indexOf("deleteMany"), + ); + }); + + it("stores a running total that matches where the samples actually fell", async () => { + const { prisma, storedProfile } = buildShapeMock(); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + const stored = storedProfile(); + expect(stored).not.toBeNull(); + expect(stored!.dateKey).toBe("2026-05-16"); + expect(stored!.timezone).toBe("Europe/Berlin"); + expect(stored!.sampleCount).toBe(2); + expect(stored!.dayTotal).toBe(2000); + expect(stored!.hourlyCumulative).toHaveLength(24); + // 08:00 and 20:00 Berlin — nothing before hour 8, nothing added between. + expect(stored!.hourlyCumulative[7]).toBe(0); + expect(stored!.hourlyCumulative[8]).toBe(1200); + expect(stored!.hourlyCumulative[19]).toBe(1200); + expect(stored!.hourlyCumulative[20]).toBe(2000); + }); + + it("writes no profile when the re-read finds the rows already drained", async () => { + // The serialised loser of two overlapping nightly ticks. It contributes + // nothing to the canonical total and must contribute nothing to the shape, + // or the additive merge would double the day. + const { prisma, profileUpsert } = buildShapeMock({ + drainedByAnotherTick: true, + }); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + expect(profileUpsert).not.toHaveBeenCalled(); + }); +});