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"; +}