diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 78c590442..35b565bec 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,9 +1,8 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 103, - "total": 119146, + "count": 102, + "total": 118114, "entries": { - "app/api/dashboard/summary/route.ts": 1032, "app/api/insights/generate/route.ts": 1214, "app/medications/page-client.tsx": 839, "app/page-client.tsx": 2324, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 04bda5dac..2c9399f8d 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 370, - "total": 128945, + "count": 369, + "total": 128339, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -15,7 +15,6 @@ "app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230, "app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213, "app/api/cycle/insights/route.ts :: apiHandler(arg)": 226, - "app/api/dashboard/summary/route.ts :: buildDashboardSummary": 606, "app/api/devices/route.ts :: apiHandler(arg)": 231, "app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 207, "app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 97ad4bf34..0f00d7b84 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2911, - "total": 9684, + "total": 9658, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -220,7 +220,7 @@ "app/api/dashboard/snapshot/__tests__/route.test.ts": 2, "app/api/dashboard/snapshot/route.ts": 1, "app/api/dashboard/summary/__tests__/route.test.ts": 16, - "app/api/dashboard/summary/route.ts": 31, + "app/api/dashboard/summary/route.ts": 5, "app/api/dashboard/widgets/__tests__/route.test.ts": 4, "app/api/dashboard/widgets/route.ts": 14, "app/api/devices/__tests__/route.test.ts": 1, diff --git a/src/__tests__/dashboard-summary-read-plan-guard.test.ts b/src/__tests__/dashboard-summary-read-plan-guard.test.ts index ce7035eb5..6b582f7fe 100644 --- a/src/__tests__/dashboard-summary-read-plan-guard.test.ts +++ b/src/__tests__/dashboard-summary-read-plan-guard.test.ts @@ -25,7 +25,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; const READS = join(process.cwd(), "src/lib/dashboard/summary-reads.ts"); -const ROUTE = join(process.cwd(), "src/app/api/dashboard/summary/route.ts"); +/** The summary's builder, moved out of its route; it makes the reads. */ +const ROUTE = join(process.cwd(), "src/lib/dashboard/summary/build.ts"); /** * Source with every comment removed, so a guard matches the SQL the diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index ae7bbfe66..0558a4887 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -57,7 +57,7 @@ const INTAKE_READ = */ const INTAKE_MAY_SEE_EVERY_ROW: Record = { "app/api/admin/status/route.ts": "instance row counts", - "app/api/dashboard/summary/route.ts": + "lib/dashboard/summary/build.ts": "engagement streak of days with any logged action, no rate", "app/api/export/medications/route.ts": "export of the recorded history", "app/api/export/route.ts": "export of the recorded history", diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index d51ae0742..4f27170cb 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -148,7 +148,7 @@ const TS_READERS: Record = { "lib/analytics/correlations-fast-path.ts": { exempt: "mood mean; pulse enters as the resting series, never a day mean", }, - "app/api/dashboard/summary/route.ts": { + "lib/dashboard/summary/cards.ts": { exempt: "places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once", }, diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index 1458ec216..ce04c11d1 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -359,7 +359,7 @@ const V143_FIELDS: ResolvedFieldEntry[] = [ sameResolver: { pattern: VERDICT_RESOLVER, files: [ - "src/app/api/dashboard/summary/route.ts", + "src/lib/dashboard/summary/cards.ts", "src/lib/dashboard/snapshot.ts", ], }, diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4526bf946..1603472de 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -52,284 +52,21 @@ * belongs to a disabled module never ships. The decision comes from the same * map the shared snapshot builder uses (`disabledSummaryTypes`); the filter * runs OUTSIDE the cache because a module toggle does not evict it. + * + * The builder lives in `src/lib/dashboard/summary/`. */ -import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; -import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { - resolveTrendVerdict, - trendSentimentOfTrend, - type TrendSentiment, - type TrendVerdict, - type TrendVerdictContext, -} from "@/lib/targets/trend-verdict"; -import { type Locale } from "@/lib/i18n/config"; -import { resolveJobLocale } from "@/lib/i18n/job-locale"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { validTimezoneOr } from "@/lib/tz/format"; -import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; -import { computeStreak } from "@/lib/dashboard/streak"; -import { - readActivityDayKeys, - readAllTimeCounts, - readLatestEver, - readSparkBuckets, -} from "@/lib/dashboard/summary-reads"; import { resolveModuleMap } from "@/lib/modules/gate"; import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules"; import { - summarizeSleepNights, - reconstructSleepNights, - type SleepSourceDiscrepancy, - type SleepStageRow, -} from "@/lib/analytics/sleep-night"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import type { SleepStage } from "@/generated/prisma/client"; -import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; -import { computeBmi } from "@/lib/insights/derived/bmi"; -import { isDerivedOk } from "@/lib/insights/derived/types"; -import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; -import { - reconstructNights, - sleepNeedMinutes, -} from "@/lib/insights/derived/sleep-score"; -import { - computeSleepRhythmFromNights, - type SleepRhythmDto, -} from "@/lib/insights/derived/sleep-rhythm"; - -const SPARK_DAYS = 7; -const STREAK_WINDOW_DAYS = 365; - -type MetricKind = - | "weight" - | "bloodPressure" - | "pulse" - | "bodyFat" - | "glucose" - | "sleep" - | "steps" - | "totalBodyWater" - | "boneMass" - | "oxygenSaturation" - | "mood" - | "bmi"; - -interface MetricCard { - id: string; - kind: MetricKind; - /** - * v1.5.x — i18n key for the metric's display title (e.g. - * `dashboard.metric.title.weight`). Clients resolve the key against - * their own locale bundle: the PWA via `useTranslations()`, the iOS - * client via its bundled `Localizable.xcstrings`. The previous wire - * shape carried a German string literal which injected DE into an - * English-locale iOS UI. - */ - titleKey: string; - latestValue: number | null; - secondaryValue: number | null; - /** - * v1.5.x — i18n key for the metric's display unit (e.g. - * `dashboard.metric.unit.weight`). Same resolution contract as - * `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are - * identical across every shipped locale; per-kind keys keep the - * door open for locale-specific overrides (e.g. Imperial units) - * without another wire-shape change. - */ - unitKey: string; - /** - * v1.11.4 — explicit unit token for clients that need to know the - * value's unit without inferring it from the metric kind. Most tiles - * leave this `null` (the `unitKey` i18n key already carries the - * display unit). The `sleep` tile sets it to `"h"` because its - * `latestValue` is a per-NIGHT total expressed in HOURS (a float), - * not the canonical `SLEEP_DURATION` minutes — see the sleep block - * below for why the night total replaced the single-stage value. - */ - unit: string | null; - /** - * v1.11.4 — per-stage minutes for the headline night, sleep tile only. - * `null` for every other kind and for a sleep night with no - * stage-tagged rows (a legacy bare-duration night). Additive: a future - * sleep detail view can render the breakdown without changing the - * headline `latestValue`. - */ - sleepStages: Partial> | null; - /** - * v1.28.x — source-discrepancy annotation for the headline night's MAIN - * session, sleep tile only; `null` for every other kind and for a night - * whose writers agree. Additive (the established wire pattern for the - * iOS-locked shape): observational only — `latestValue` stays the - * winning writer's total; clients render a discreet "sources disagree" - * hint next to the number. Minutes are whole (rounded), matching the - * `/api/sleep/night` serialisation. - */ - sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; - trend: "up" | "down" | "flat" | "unknown"; - sparkline: number[]; - updatedAt: string | null; - /** - * v1.4.33 — total readings the user has ever - * logged for this metric, irrespective of the 7-day sparkline - * window. The dashboard tile keeps showing whenever `allTimeCount > 0` - * so a user with valid historical data isn't surprised by a tile - * disappearing during a logging gap. Distinct from `sparkline.length` - * which only carries the trailing-7-day points. - */ - allTimeCount: number; - /** - * v1.4.33 — ISO timestamp of the metric's single - * most recent reading. When `allTimeCount > 0` but the latest - * reading is older than 7 days, the iOS tile renders a muted - * "last reading N days ago" caption so the user understands the - * value isn't stale silently. `null` when the metric has no readings - * at all. - */ - lastSeenAt: string | null; - /** - * v1.43 (ios #116 item 3) — which way a change of this metric counts for - * the person, against their own target or a profile default - * (`src/lib/targets/trend-verdict.ts`). - */ - direction?: TrendVerdict | null; - /** v1.43 — `direction` applied to this card's `trend`. */ - trendSentiment?: TrendSentiment; -} - -/** - * The verdict key behind each card: the measurement type it reads, or the - * derived key for the two cards that are not one. - */ -const VERDICT_KEY: Record = { - weight: "WEIGHT", - bloodPressure: "BLOOD_PRESSURE_SYS", - pulse: "PULSE", - bodyFat: "BODY_FAT", - glucose: "BLOOD_GLUCOSE", - sleep: "SLEEP_DURATION", - steps: "ACTIVITY_STEPS", - totalBodyWater: "TOTAL_BODY_WATER", - boneMass: "BONE_MASS", - oxygenSaturation: "OXYGEN_SATURATION", - mood: "MOOD", - bmi: "BMI", -}; - -/** - * Lay the verdict over each card. The reference placed against the band is - * the mean of the card's own seven-day sparkline, else its latest value, the - * seven-day average the web tiles place. Sleep cards are in hours and the - * verdict reads minutes. - */ -function withVerdicts( - cards: MetricCard[], - ctx: Omit, -): MetricCard[] { - const referenceOf = (card: MetricCard | undefined): number | null => { - if (!card) return null; - const spark = card.sparkline.filter((v) => Number.isFinite(v)); - return spark.length > 0 - ? spark.reduce((sum, v) => sum + v, 0) / spark.length - : card.latestValue; - }; - // BMI is judged by the weight it is derived from (the target is a weight). - const weightReference = referenceOf(cards.find((c) => c.kind === "weight")); - return cards.map((card) => { - const reference = card.kind === "bmi" ? weightReference : referenceOf(card); - const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], { - ...ctx, - referenceValue: - reference !== null && card.kind === "sleep" - ? reference * 60 - : reference, - }); - return { - ...card, - direction, - trendSentiment: trendSentimentOfTrend(card.trend, direction), - }; - }); -} - -/** - * v1.5.x — wire i18n keys, not translated strings. - * - * The legacy maps emitted German literals like "Gewicht" / "Schritte" - * which injected into an English iOS UI once the iOS app flipped its - * source language. Clients now resolve these keys against their own - * locale bundle (the PWA via `useTranslations()`, iOS via - * `Localizable.xcstrings`). The server payload becomes language-neutral - * and the response shrinks slightly. - * - * One key per `MetricKind` — symmetric maps so a new kind has to add - * both a title and a unit key (Type-checked: `Record`). - */ -const METRIC_TITLE_KEYS: Record = { - weight: "dashboard.metric.title.weight", - bloodPressure: "dashboard.metric.title.bloodPressure", - pulse: "dashboard.metric.title.pulse", - bodyFat: "dashboard.metric.title.bodyFat", - glucose: "dashboard.metric.title.glucose", - sleep: "dashboard.metric.title.sleep", - steps: "dashboard.metric.title.steps", - totalBodyWater: "dashboard.metric.title.totalBodyWater", - boneMass: "dashboard.metric.title.boneMass", - oxygenSaturation: "dashboard.metric.title.oxygenSaturation", - mood: "dashboard.metric.title.mood", - bmi: "dashboard.metric.title.bmi", -}; - -const METRIC_UNIT_KEYS: Record = { - weight: "dashboard.metric.unit.weight", - bloodPressure: "dashboard.metric.unit.bloodPressure", - pulse: "dashboard.metric.unit.pulse", - bodyFat: "dashboard.metric.unit.bodyFat", - glucose: "dashboard.metric.unit.glucose", - sleep: "dashboard.metric.unit.sleep", - steps: "dashboard.metric.unit.steps", - totalBodyWater: "dashboard.metric.unit.totalBodyWater", - boneMass: "dashboard.metric.unit.boneMass", - oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", - mood: "dashboard.metric.unit.mood", - bmi: "dashboard.metric.unit.bmi", -}; - -/** - * v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in - * hours, reconstructed per night from the raw stage rows. The generic - * DAY-bucket rollup sparkline can't be used for sleep because it means - * across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE - * row average to 150) rather than summing them into a night total. - */ -function buildSleepSparkline( - rows: SleepStageRow[], - tz: string, - priorityJson: unknown, -): number[] { - return reconstructSleepNights(rows, tz, priorityJson) - .filter((n) => n.asleepMinutes > 0) - .map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100) - .slice(-SPARK_DAYS); -} - -function trendOf(values: number[]): MetricCard["trend"] { - if (values.length < 2) return "unknown"; - const first = values[0]; - const last = values[values.length - 1]; - const delta = last - first; - const epsilon = Math.max(1, Math.abs(first) * 0.01); - if (Math.abs(delta) < epsilon) return "flat"; - return delta > 0 ? "up" : "down"; -} + buildDashboardSummary, + buildSummaryContext, +} from "@/lib/dashboard/summary/build"; export const GET = apiHandler(async () => { // v1.37.0 — MANAGE-level read: computed over the whole record, with no @@ -365,7 +102,7 @@ export const GET = apiHandler(async () => { >, `${user.id}|dashboard-summary`, async () => - buildDashboardSummary(user.id, userTz, await buildContext(user)), + buildDashboardSummary(user.id, userTz, await buildSummaryContext(user)), annotate, ); @@ -399,634 +136,3 @@ export const GET = apiHandler(async () => { return apiSuccess({ ...body, metrics }); }); - -interface SummaryBuilderContext { - greetingName: string; - locale: Locale; -} - -async function buildContext(user: { - displayName: string | null; - username: string; - locale: string | null; -}): Promise { - const greetingName = user.displayName ?? user.username; - // The greeting salutation and the streak label are the two strings this - // route still translates server-side, and the native client is its only - // caller — a Bearer request carries no locale cookie and no meaningful - // Accept-Language, so the stored column is the only per-user signal. - // That is exactly the shape `resolveJobLocale` exists for: stored user - // locale, then the operator's configured default, then English. The - // hand-rolled coercion this replaces stopped at English, so a German - // instance whose user never touched the language picker greeted them in - // English while every other string on the same screen was German. - const locale: Locale = await resolveJobLocale(user.locale); - return { greetingName, locale }; -} - -async function buildDashboardSummary( - userId: string, - userTz: string, - ctx: SummaryBuilderContext, -) { - const now = new Date(); - // REG-11 (v1.4.44): the trailing-7-day pivot used to anchor the - // latest-reading + sparkline filters is gone — both sub-queries now - // take the most recent N rows per type regardless of age. Only the - // streak window still anchors on a calendar slice. - const streakWindowStart = new Date( - now.getTime() - STREAK_WINDOW_DAYS * 86_400_000, - ); - - // Derived from canonical enum so a new measurement type is auto-included - // (V3 audit: enum drift cousins). Per-kind display blocks below decide - // which types render as MetricCards. - const measurementTypes = [ - ...measurementTypeEnum.options, - ] as MeasurementType[]; - - // v1.4.38 — per-sub-query wall-clock timing for prod observability. - // Captured on the cache-miss path; the cache-hit path skips this whole - // builder. Reported under `meta.dashboard.sub_*_ms` so the next - // perf-verify can attribute a regression to a specific sub-query - // without re-instrumenting the route. - const timings: Record = {}; - const time = async ( - label: string, - builder: () => Promise, - ): Promise => { - const t0 = Date.now(); - const result = await builder(); - timings[`dashboard.sub_${label}_ms`] = Date.now() - t0; - return result; - }; - - // v1.4.38 — six bounded sub-queries replace the legacy 4 - // unbounded ones. Row counts are now: - // - latestEver: ≤ N metric types (one indexed lookup per type via - // LATERAL, REG-11: dropped the 7-day window so a - // stale-but-valid historical reading still feeds the tile) - // - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80, - // REG-11: taken via ROW_NUMBER window, no calendar - // filter, so a 60-day-old metric still paints a chart) - // - allTimeAggregate: ≤ N metric types, summed from the DAY rollups - // - medsToday: today-window tally via the shared projector - // (≤ daily intake schedule count, unchanged semantics) - // - streakActivity: ≤ daily intake count × 365 (unchanged) - // - measurementStreakDays: ≤ 365 (was: every raw row in 365d) - const [ - latestEver, - sparkBuckets, - allTimeAggregate, - medsToday, - streakActivity, - measurementStreakDays, - sleepStageRows, - ] = await Promise.all([ - // Both reads live in `@/lib/dashboard/summary-reads` — the SQL is a - // contract the integration suite exercises directly against Postgres, - // and each carries the reason it names the type list at its - // definition. Short version: PostgreSQL 16 has no index skip scan, so - // "one row per type" has to be driven FROM the type list or it - // degrades to a full pass over the account. - time("latestEver", () => readLatestEver(prisma, userId, measurementTypes)), - time("sparkline", () => - readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS), - ), - // Counted from the DAY rollups where they cover the days, not by one - // index entry per reading (see `readAllTimeCounts`). - time("allTime", () => - readAllTimeCounts(prisma, userId, measurementTypes, now), - ), - // v1.4.39 — the dashboard compliance tile pulled - // `MedicationIntakeEvent` rows for the today-window only. Daily - // schedules (`daysOfWeek: null`) had no row to read until the - // reminder worker entered the RED phase at the end of the dose - // window, so the iOS Dashboard tile fell to "Heute nichts geplant" - // even when the intake route (post-`expandTodayIntakes` fix) was - // already projecting + backfilling correctly. The shared - // `buildMedsTodayBlock` runs the same idempotent projection - // (`skipDuplicates: true` so a concurrent intake-route hit can't - // race a duplicate row in) before its today-window tally, so this - // route and the dashboard snapshot count the identical row set. - time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)), - time("streakIntakes", () => - prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows from streak counting. - deletedAt: null, - scheduledFor: { gte: streakWindowStart }, - OR: [{ takenAt: { not: null } }, { skipped: true }], - }, - select: { takenAt: true, scheduledFor: true }, - }), - ), - time("streakDays", () => - readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now), - ), - // v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total - // tile. `SLEEP_DURATION` is stored one row per STAGE per night - // (minutes), so the single-most-recent row (the `latestEver` path - // above) is just ONE stage, not the night. The sleep tile instead - // sums the asleep stages of the latest night via - // `summarizeSleepNights`. Bounded to the streak window (≈ a year of - // ~5 rows/night) so the read stays small; the headline only needs - // the most-recent night but the window also feeds the iOS sparkline - // night totals. - time("sleepNights", () => - prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION", - deletedAt: null, - measuredAt: { gte: streakWindowStart }, - }, - orderBy: { measuredAt: "asc" }, - // Writer-level collapse: two HealthKit apps behind one source - // (watch stages vs phone in-bed) must not blend into one night. - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }), - ), - ]); - - // v1.11.4 — the user's sleep source-priority ladder, used to collapse a - // dual-source night (e.g. WHOOP + Apple Health) to one canonical source - // before the per-night reconstruction sums it. v1.17.0 — the baseline - // profile rides along (one read) so the sleep-rhythm block resolves the - // age-based sleep need the cumulative-debt deficit uses. - const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([ - loadUserSourcePriority(userId), - loadBaselineProfile(prisma, userId), - // The mood card reads the SAME canonical daily series the web mood - // analytics and the dashboard snapshot read (`buildMoodDailySeries`), - // rollup fast-path included — never a second aggregation. - time("moodSeries", () => buildMoodDailySeries(userId, prisma)), - ]); - - // BMI is derived, not measured: it needs the profile height loaded above, - // so it cannot join the round before it. `computeBmi` is the one canonical - // implementation (the same one the web vitals dashboard renders through - // the derived-metric dispatch) — the route resolves the figure and the - // client consumes it, rather than every client re-deriving weight / m². - const bmiDerived = await time("bmi", () => - computeBmi(userId, sleepProfile, { now }), - ); - - // Per-type metadata lookup — typed Map so a metric with no readings - // at all falls through `metaForType` to the `{ allTimeCount: 0, - // lastSeenAt: null }` default. - const allTimeByType = new Map< - MeasurementType, - { allTimeCount: number; lastSeenAt: Date | null } - >(); - // The newest reading is the one `latestEver` already read per type; its - // instant is the metric's last-seen time. - const lastSeenByType = new Map( - latestEver.map((row) => [row.type, row.measured_at]), - ); - for (const row of allTimeAggregate) { - allTimeByType.set(row.type, { - allTimeCount: row.count, - lastSeenAt: lastSeenByType.get(row.type) ?? null, - }); - } - function metaForType(type: MeasurementType): { - allTimeCount: number; - lastSeenAt: string | null; - } { - const slot = allTimeByType.get(type); - return { - allTimeCount: slot?.allTimeCount ?? 0, - lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null, - }; - } - - // v1.4.38 — activity-day set assembled from the bounded streak - // queries. The measurement side arrives as local YYYY-MM-DD keys, the - // same keys `userDayKey(measuredAt, userTz)` produces for the intakes. - const activityDays = new Set(measurementStreakDays); - for (const e of streakActivity) { - activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz)); - } - - const streak = computeStreak(activityDays, userTz); - - // v1.4.38 — per-type latest (one row per type) + sparkline - // (bucket means per day) assembled from the two new SQL aggregates. - // REG-11 (v1.4.44): `latestEver` carries the most recent reading - // regardless of age — the iOS tile shows the historical value plus a - // muted "Letzter Wert vor Xd" caption rather than nothing at all. - const latestByType = new Map(); - for (const row of latestEver) { - latestByType.set(row.type, { - value: Number(row.value), - at: new Date(row.measured_at), - }); - } - // v1.4.39 — cumulative tiles paint the daily SUM; spot tiles - // keep the daily MEAN. Reading `sum_value` directly off the rollup - // row eliminates the legacy `mean * count` reconstruction; the - // fallback covers pre-v1.4.39 NULL rows during the boot-backfill - // convergence window. - const sparkByType = new Map(); - for (const row of sparkBuckets) { - const list = sparkByType.get(row.type) ?? []; - // QA Simplifier (v1.4.39): flatten the cumulative-vs-spot ternary - // into a named branch per the CLAUDE.md "no nested ternaries" - // rule. Cumulative tiles paint the daily SUM; spot tiles keep the - // daily MEAN. The legacy NULL fallback covers pre-v1.4.39 rollup - // rows the boot-backfill hasn't refreshed yet. - const cumulativePoint = - row.sum_value !== null - ? Number(row.sum_value) - : Number(row.mean) * Number(row.count); - const point = CUMULATIVE_HK_TYPES.has(row.type) - ? cumulativePoint - : Number(row.mean); - list.push(point); - sparkByType.set(row.type, list); - } - - function latestOf(type: MeasurementType): { value: number; at: Date } | null { - return latestByType.get(type) ?? null; - } - - function sparkOf(type: MeasurementType): number[] { - return sparkByType.get(type) ?? []; - } - - // v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night - // asleep totals. The sleep tile's headline is last night's TIME ASLEEP - // (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS - // with an explicit `unit: "h"`, replacing the single-stage minutes the - // `latestEver` read used to surface. - const sleepSummary = summarizeSleepNights( - sleepStageRows as SleepStageRow[], - userTz, - sleepPriorityJson, - ); - - // v1.17.0 — server-authoritative sleep-rhythm DTO (sleep-debt + chronotype) - // off the SAME canonical reconstruction the Sleep page + Sleep Score read, - // so iOS renders identical values. Reuses the rows already loaded above — - // `reconstructNights` adapts them into the asleep + wall-clock-midpoint - // shape the foundation modules consume, then `computeSleepRhythmFromNights` - // forwards them to `computeSleepDebt` / `computeChronotype` (the one place - // the math lives). Both signals carry a calm partial / learning state below - // their night thresholds — never an assertion off thin data. - const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights( - reconstructNights( - sleepStageRows as SleepStageRow[], - userTz, - sleepPriorityJson, - ), - sleepNeedMinutes(sleepProfile.ageYears), - "COMPUTED", - await loadFreeWeekdaySpans(userId), - ); - - const metrics: MetricCard[] = []; - - // v1.4.33 — every emitted card now carries - // `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep - // a metric visible during a logging gap (gate on `allTimeCount > 0`) - // and paint a muted "Letzter Wert vor Xd" caption when the most - // recent reading is older than the 7-day sparkline window. - - // Weight - { - const latest = latestOf("WEIGHT"); - const spark = sparkOf("WEIGHT"); - const meta = metaForType("WEIGHT"); - metrics.push({ - id: "weight", - kind: "weight", - titleKey: METRIC_TITLE_KEYS.weight, - latestValue: latest?.value ?? null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS.weight, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: meta.lastSeenAt, - }); - } - - // Blood pressure (paired sys/dia) — REG-11 (v1.4.44): gate the emit - // on `latestSys || latestDia || bpAllTimeCount > 0` so an account - // that has never logged BP doesn't get an empty placeholder tile. - // Mirrors the body-fat gate below. - { - const latestSys = latestOf("BLOOD_PRESSURE_SYS"); - const latestDia = latestOf("BLOOD_PRESSURE_DIA"); - const sysSpark = sparkOf("BLOOD_PRESSURE_SYS"); - const sysMeta = metaForType("BLOOD_PRESSURE_SYS"); - const diaMeta = metaForType("BLOOD_PRESSURE_DIA"); - // BP is a paired metric — the tile is "alive" whenever either side - // of the pair has history. Sum the count to reflect total readings - // and pick the most recent `_max` so the staleness hint follows - // whichever side is freshest. - const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount; - const bpLastSeenAt = ((): string | null => { - const sysAt = sysMeta.lastSeenAt; - const diaAt = diaMeta.lastSeenAt; - if (!sysAt) return diaAt; - if (!diaAt) return sysAt; - return sysAt >= diaAt ? sysAt : diaAt; - })(); - if (latestSys || latestDia || bpAllTimeCount > 0) { - metrics.push({ - id: "bp", - kind: "bloodPressure", - titleKey: METRIC_TITLE_KEYS.bloodPressure, - latestValue: latestSys?.value ?? null, - secondaryValue: latestDia?.value ?? null, - unitKey: METRIC_UNIT_KEYS.bloodPressure, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(sysSpark), - sparkline: sysSpark, - updatedAt: - latestSys?.at?.toISOString() ?? - latestDia?.at?.toISOString() ?? - bpLastSeenAt, - allTimeCount: bpAllTimeCount, - lastSeenAt: bpLastSeenAt, - }); - } - } - - // Pulse — REG-11 (v1.4.44): gate the emit on `latest || allTimeCount - // > 0` so an account that has never logged pulse doesn't get an empty - // placeholder tile. Mirrors the body-fat gate below. - { - const latest = latestOf("PULSE"); - const spark = sparkOf("PULSE"); - const meta = metaForType("PULSE"); - if (latest || meta.allTimeCount > 0) { - metrics.push({ - id: "pulse", - kind: "pulse", - titleKey: METRIC_TITLE_KEYS.pulse, - latestValue: latest?.value ?? null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS.pulse, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: meta.lastSeenAt, - }); - } - } - - // Body fat — v1.4.33 widens the emit gate from `latestOf` (only - // fires when the 7-day window has a reading) to `allTimeCount > 0` - // so a tile doesn't disappear during a fortnight without a fresh - // reading. - { - const latest = latestOf("BODY_FAT"); - const spark = sparkOf("BODY_FAT"); - const meta = metaForType("BODY_FAT"); - if (latest || meta.allTimeCount > 0) { - metrics.push({ - id: "bodyFat", - kind: "bodyFat", - titleKey: METRIC_TITLE_KEYS.bodyFat, - latestValue: latest?.value ?? null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS.bodyFat, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: meta.lastSeenAt, - }); - } - } - - // Optional cards — emitted whenever the user has *ever* logged the - // metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires - // when the trailing 7-day window has a reading); v1.4.33 widens to - // the all-time count so a glucose tile that hasn't been touched in - // 10 days still renders with the historical value visible to the - // iOS client (paired with the lastSeenAt caption). - for (const [type, kind] of [ - ["BLOOD_GLUCOSE", "glucose"], - ["SLEEP_DURATION", "sleep"], - ["ACTIVITY_STEPS", "steps"], - ["TOTAL_BODY_WATER", "totalBodyWater"], - ["BONE_MASS", "boneMass"], - ["OXYGEN_SATURATION", "oxygenSaturation"], - ] as const) { - const latest = latestOf(type); - const meta = metaForType(type); - if (!latest && meta.allTimeCount === 0) continue; - - // v1.11.4 — sleep is night-aggregated, not single-stage. Emit the - // latest night's TIME ASLEEP in HOURS (float) with an explicit - // `unit: "h"`, the per-stage breakdown, and a sparkline of the - // trailing nights' asleep hours. Every other kind keeps the - // single-row latest value + canonical-unit i18n key. - if (kind === "sleep") { - const night = sleepSummary.latestNight; - const toHours = (minutes: number): number => - Math.round((minutes / 60) * 100) / 100; - // Sparkline = trailing nights' asleep hours from the night - // reconstruction (the DAY-bucket rollup sparkline blends stages - // and would be misleading for sleep). - const nightSpark = buildSleepSparkline( - sleepStageRows as SleepStageRow[], - userTz, - sleepPriorityJson, - ); - const stageHours: Partial> | null = night - ? Object.fromEntries( - Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]), - ) - : null; - metrics.push({ - id: kind, - kind, - titleKey: METRIC_TITLE_KEYS[kind], - latestValue: night ? toHours(night.asleepMinutes) : null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS[kind], - unit: "h", - sleepStages: - stageHours && Object.keys(stageHours).length > 0 ? stageHours : null, - // Additive, observational: the headline night's MAIN session saw - // two writer buckets with clearly different asleep totals. Round - // to whole minutes, matching the `/api/sleep/night` serialisation. - sleepSourceDiscrepancy: night?.sourceDiscrepancy - ? { - deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes), - sources: night.sourceDiscrepancy.sources.map((b) => ({ - source: b.source, - deviceType: b.deviceType, - asleepMinutes: Math.round(b.asleepMinutes), - })), - } - : null, - trend: trendOf(nightSpark), - sparkline: nightSpark, - updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, - }); - continue; - } - - const spark = sparkOf(type); - metrics.push({ - id: kind, - kind, - titleKey: METRIC_TITLE_KEYS[kind], - latestValue: latest?.value ?? null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS[kind], - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, - allTimeCount: meta.allTimeCount, - lastSeenAt: meta.lastSeenAt, - }); - } - - // Mood — the one metric on this payload that is not a `Measurement`. The - // daily series is the canonical `buildMoodDailySeries` output (rollup - // fast-path, 1..5 scale, one entry per day with an average score), the - // same series the web mood analytics and the dashboard snapshot render. - // Emitted only when the account has ever logged a mood, matching the - // `allTimeCount === 0` gate every optional card above uses. - if (moodSeries.entryCount > 0) { - const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); - const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; - // The series carries the user's LOCAL day keys (`MoodEntry.date`), not - // timestamps, so the only honest instant is the first instant of that - // day in the user's zone. Writing the key as `T00:00Z` instead put the - // instant on the previous calendar day for anyone west of UTC, and a - // client formatting it in its own zone showed today's entry as - // yesterday's. - const latestAt = latestEntry - ? startOfLocalDayKey(latestEntry.date, userTz).toISOString() - : null; - metrics.push({ - id: "mood", - kind: "mood", - titleKey: METRIC_TITLE_KEYS.mood, - latestValue: latestEntry?.score ?? null, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS.mood, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - updatedAt: latestAt, - allTimeCount: moodSeries.entryCount, - lastSeenAt: latestAt, - }); - } - - // BMI — derived from the latest weight at the profile height by - // `computeBmi`, the single implementation the web vitals dashboard also - // renders. The card is omitted when the derivation is insufficient - // (no height on the profile, or no weight inside its window): the card - // shape has no "unknown" state to express honestly, and a card with a - // null value would read as a logging gap rather than a missing profile - // field. Clients that want to prompt for a height should do so from the - // profile surface, not from an empty dashboard tile. - if (isDerivedOk(bmiDerived)) { - const spark = bmiDerived.value.series.slice(-SPARK_DAYS); - metrics.push({ - id: "bmi", - kind: "bmi", - titleKey: METRIC_TITLE_KEYS.bmi, - latestValue: bmiDerived.value.bmi, - secondaryValue: null, - unitKey: METRIC_UNIT_KEYS.bmi, - unit: null, - sleepStages: null, - sleepSourceDiscrepancy: null, - trend: trendOf(spark), - sparkline: spark, - // BMI moves exactly when weight does, so the weight metadata is the - // honest recency signal — there is no separate BMI reading. - updatedAt: metaForType("WEIGHT").lastSeenAt, - allTimeCount: metaForType("WEIGHT").allTimeCount, - lastSeenAt: metaForType("WEIGHT").lastSeenAt, - }); - } - - // Shared builder carries the exact tally semantics this tile always - // had (scheduled = every today-window row; taken = `takenAt` set and - // not skipped). The wire shape of `compliance` is locked for iOS. - const scheduledToday = medsToday.scheduledToday; - const takenToday = medsToday.takenToday; - - const t = getServerTranslator(ctx.locale).t; - - // v1.43 — the record's profile and own targets, for the card verdicts. - const verdictProfile = await prisma.user.findUnique({ - where: { id: userId }, - select: { - dateOfBirth: true, - gender: true, - heightCm: true, - thresholdsJson: true, - }, - }); - - annotate({ meta: timings }); - - return { - greeting: { - salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }), - date: now.toISOString(), - }, - streak: { - currentDays: streak.currentDays, - longest: streak.longest, - label: t("dashboard.streakLabel"), - }, - compliance: { - scheduledToday, - takenToday, - }, - highlightInsight: null, - metrics: withVerdicts(metrics, { - profile: { - dateOfBirth: verdictProfile?.dateOfBirth ?? null, - gender: verdictProfile?.gender ?? null, - heightCm: verdictProfile?.heightCm ?? null, - }, - thresholdsJson: verdictProfile?.thresholdsJson ?? null, - }), - // v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values - // the Sleep page's `/api/sleep/rhythm` read returns. - sleepRhythm, - lastUpdated: now.toISOString(), - }; -} diff --git a/src/lib/dashboard/summary/build.ts b/src/lib/dashboard/summary/build.ts new file mode 100644 index 000000000..0cd828a42 --- /dev/null +++ b/src/lib/dashboard/summary/build.ts @@ -0,0 +1,383 @@ +/** + * The body of `GET /api/dashboard/summary`, built on a cache miss: greeting, + * intake-day streak, today's medication compliance, and the metric cards + * with latest value, sparkline, trend and verdict, plus the sleep rhythm. + * + * Every per-type read is bounded (see `@/lib/dashboard/summary-reads`): one + * row per type for the latest reading, at most `SPARK_DAYS` DAY-rollup + * buckets per type for the sparkline, the all-time counts from the DAY + * rollups, and the set of local day keys with activity in the streak window. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { type Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; +import { userDayKey } from "@/lib/tz/resolver"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { computeStreak } from "@/lib/dashboard/streak"; +import { + readActivityDayKeys, + readAllTimeCounts, + readLatestEver, + readSparkBuckets, +} from "@/lib/dashboard/summary-reads"; +import { + summarizeSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { computeBmi } from "@/lib/insights/derived/bmi"; +import { isDerivedOk } from "@/lib/insights/derived/types"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances"; +import { + reconstructNights, + sleepNeedMinutes, +} from "@/lib/insights/derived/sleep-score"; +import { + computeSleepRhythmFromNights, + type SleepRhythmDto, +} from "@/lib/insights/derived/sleep-rhythm"; + +import { plainCard, SPARK_DAYS, withVerdicts, type MetricCard } from "./cards"; +import { summaryLookups, type SummaryLookups } from "./lookups"; +import { measurementCards } from "./measurement-cards"; + +const STREAK_WINDOW_DAYS = 365; + +export interface SummaryBuilderContext { + greetingName: string; + locale: Locale; +} + +export async function buildSummaryContext(user: { + displayName: string | null; + username: string; + locale: string | null; +}): Promise { + const greetingName = user.displayName ?? user.username; + // The greeting salutation and the streak label are the two strings this + // route still translates server-side, and the native client is its only + // caller: a Bearer request carries no locale cookie and no meaningful + // Accept-Language, so the stored column is the only per-user signal. That + // is exactly the shape `resolveJobLocale` exists for: stored user locale, + // then the operator's configured default, then English. + const locale: Locale = await resolveJobLocale(user.locale); + return { greetingName, locale }; +} + +/** + * Mood is the one metric on this payload that is not a `Measurement`. The + * daily series is the canonical `buildMoodDailySeries` output (rollup + * fast-path, 1..5 scale, one entry per day with an average score), the same + * series the web mood analytics and the dashboard snapshot render. Emitted + * only when the account has ever logged a mood. + */ +function moodCard( + moodSeries: Awaited>, + userTz: string, +): MetricCard | null { + if (moodSeries.entryCount === 0) return null; + const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); + const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; + // The series carries the user's LOCAL day keys (`MoodEntry.date`), not + // timestamps, so the only honest instant is the first instant of that day + // in the user's zone. Writing the key as `T00:00Z` instead put the instant + // on the previous calendar day for anyone west of UTC. + const latestAt = latestEntry + ? startOfLocalDayKey(latestEntry.date, userTz).toISOString() + : null; + return plainCard("mood", "mood", { + latestValue: latestEntry?.score ?? null, + spark, + updatedAt: latestAt, + allTimeCount: moodSeries.entryCount, + lastSeenAt: latestAt, + }); +} + +/** + * BMI, derived from the latest weight at the profile height by + * `computeBmi`, the single implementation the web vitals dashboard also + * renders. Omitted when the derivation is insufficient (no height on the + * profile, or no weight inside its window): the card shape has no "unknown" + * state to express honestly, and a card with a null value would read as a + * logging gap rather than a missing profile field. + */ +function bmiCard( + bmiDerived: Awaited>, + lookups: SummaryLookups, +): MetricCard | null { + if (!isDerivedOk(bmiDerived)) return null; + const spark = bmiDerived.value.series.slice(-SPARK_DAYS); + // BMI moves exactly when weight does, so the weight metadata is the honest + // recency signal: there is no separate BMI reading. + return plainCard("bmi", "bmi", { + latestValue: bmiDerived.value.bmi, + spark, + updatedAt: lookups.metaForType("WEIGHT").lastSeenAt, + allTimeCount: lookups.metaForType("WEIGHT").allTimeCount, + lastSeenAt: lookups.metaForType("WEIGHT").lastSeenAt, + }); +} + +type Timed = (label: string, builder: () => Promise) => Promise; + +/** The summary's reads, in their three rounds, each timed under its label. */ +async function readSummaryInputs( + userId: string, + userTz: string, + now: Date, + time: Timed, +) { + // The latest-reading and sparkline reads take the most recent N rows per + // type regardless of age; only the streak window anchors on a calendar + // slice. + const streakWindowStart = new Date( + now.getTime() - STREAK_WINDOW_DAYS * 86_400_000, + ); + // Derived from the canonical enum so a new measurement type is + // auto-included. The card builders decide which types render as cards. + const measurementTypes = [ + ...measurementTypeEnum.options, + ] as MeasurementType[]; + + const [ + latestEver, + sparkBuckets, + allTimeAggregate, + medsToday, + streakActivity, + measurementStreakDays, + sleepStageRows, + ] = await Promise.all([ + // Both reads live in `@/lib/dashboard/summary-reads`: the SQL is a + // contract the integration suite exercises directly against Postgres, + // and each carries the reason it names the type list at its definition. + // Short version: PostgreSQL 16 has no index skip scan, so "one row per + // type" has to be driven FROM the type list or it degrades to a full + // pass over the account. + time("latestEver", () => readLatestEver(prisma, userId, measurementTypes)), + time("sparkline", () => + readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS), + ), + // Counted from the DAY rollups where they cover the days, not by one + // index entry per reading (see `readAllTimeCounts`). + time("allTime", () => + readAllTimeCounts(prisma, userId, measurementTypes, now), + ), + // The shared `buildMedsTodayBlock` runs the same idempotent projection + // the intake route runs (`skipDuplicates: true` so a concurrent + // intake-route hit can't race a duplicate row in) before its + // today-window tally, so this route and the dashboard snapshot count the + // identical row set. Daily schedules have no row to read until the + // projection writes one. + time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)), + time("streakIntakes", () => + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + // Tombstoned rows do not count towards a streak. + deletedAt: null, + scheduledFor: { gte: streakWindowStart }, + OR: [{ takenAt: { not: null } }, { skipped: true }], + }, + select: { takenAt: true, scheduledFor: true }, + }), + ), + time("streakDays", () => + readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now), + ), + // Raw per-stage SLEEP_DURATION rows for the night-total tile. + // `SLEEP_DURATION` is stored one row per STAGE per night (minutes), so + // the single most recent row (the `latestEver` path) is just ONE stage, + // not the night. Bounded to the streak window (about a year of ~5 + // rows/night); the headline only needs the most recent night but the + // window also feeds the sparkline night totals. + time("sleepNights", () => + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: streakWindowStart }, + }, + orderBy: { measuredAt: "asc" }, + // Writer-level collapse: two HealthKit apps behind one source + // (watch stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }), + ), + ]); + + // The user's sleep source-priority ladder collapses a dual-source night + // (e.g. WHOOP + Apple Health) to one canonical source before the per-night + // reconstruction sums it. The baseline profile rides along (one read) so + // the sleep-rhythm block resolves the age-based sleep need. + const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([ + loadUserSourcePriority(userId), + loadBaselineProfile(prisma, userId), + // The mood card reads the SAME canonical daily series the web mood + // analytics and the dashboard snapshot read, rollup fast-path included: + // never a second aggregation. + time("moodSeries", () => buildMoodDailySeries(userId, prisma)), + ]); + + // BMI is derived, not measured: it needs the profile height loaded above, + // so it cannot join the round before it. + const bmiDerived = await time("bmi", () => + computeBmi(userId, sleepProfile, { now }), + ); + + return { + latestEver, + sparkBuckets, + allTimeAggregate, + medsToday, + streakActivity, + measurementStreakDays, + sleepStageRows, + sleepPriorityJson, + sleepProfile, + moodSeries, + bmiDerived, + }; +} + +export async function buildDashboardSummary( + userId: string, + userTz: string, + ctx: SummaryBuilderContext, +) { + const now = new Date(); + // Per-sub-query wall-clock timing, reported under + // `meta.dashboard.sub_*_ms` so a regression can be attributed to a + // specific sub-query without re-instrumenting the route. Captured on the + // cache-miss path only; a cache hit skips this whole builder. + const timings: Record = {}; + const time = async ( + label: string, + builder: () => Promise, + ): Promise => { + const t0 = Date.now(); + const result = await builder(); + timings[`dashboard.sub_${label}_ms`] = Date.now() - t0; + return result; + }; + + const { + latestEver, + sparkBuckets, + allTimeAggregate, + medsToday, + streakActivity, + measurementStreakDays, + sleepStageRows, + sleepPriorityJson, + sleepProfile, + moodSeries, + bmiDerived, + } = await readSummaryInputs(userId, userTz, now, time); + + const lookups = summaryLookups({ + latestEver, + sparkBuckets, + allTimeAggregate, + }); + + // The activity-day set from the bounded streak reads. The measurement + // side arrives as local YYYY-MM-DD keys, the same keys + // `userDayKey(measuredAt, userTz)` produces for the intakes. + const activityDays = new Set(measurementStreakDays); + for (const e of streakActivity) { + activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz)); + } + const streak = computeStreak(activityDays, userTz); + + const sleepRows = sleepStageRows as SleepStageRow[]; + const sleepSummary = summarizeSleepNights( + sleepRows, + userTz, + sleepPriorityJson, + ); + + // Server-authoritative sleep-rhythm DTO (sleep debt + chronotype) off the + // SAME canonical reconstruction the Sleep page + Sleep Score read, so iOS + // renders identical values. Both signals carry a calm partial / learning + // state below their night thresholds, never an assertion off thin data. + const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights( + reconstructNights(sleepRows, userTz, sleepPriorityJson), + sleepNeedMinutes(sleepProfile.ageYears), + "COMPUTED", + await loadFreeWeekdaySpans(userId), + ); + + const metrics = measurementCards(lookups, { + summary: sleepSummary, + rows: sleepRows, + tz: userTz, + priorityJson: sleepPriorityJson, + }); + const mood = moodCard(moodSeries, userTz); + if (mood) metrics.push(mood); + const bmi = bmiCard(bmiDerived, lookups); + if (bmi) metrics.push(bmi); + + const t = getServerTranslator(ctx.locale).t; + + // The record's profile and own targets, for the card verdicts. + const verdictProfile = await prisma.user.findUnique({ + where: { id: userId }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + thresholdsJson: true, + }, + }); + + annotate({ meta: timings }); + + return { + greeting: { + salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }), + date: now.toISOString(), + }, + streak: { + currentDays: streak.currentDays, + longest: streak.longest, + label: t("dashboard.streakLabel"), + }, + // The shared builder carries the exact tally semantics this tile always + // had (scheduled = every today-window row; taken = `takenAt` set and not + // skipped). The wire shape of `compliance` is locked for iOS. + compliance: { + scheduledToday: medsToday.scheduledToday, + takenToday: medsToday.takenToday, + }, + highlightInsight: null, + metrics: withVerdicts(metrics, { + profile: { + dateOfBirth: verdictProfile?.dateOfBirth ?? null, + gender: verdictProfile?.gender ?? null, + heightCm: verdictProfile?.heightCm ?? null, + }, + thresholdsJson: verdictProfile?.thresholdsJson ?? null, + }), + // Sleep debt + chronotype, server-authoritative. Same values the Sleep + // page's `/api/sleep/rhythm` read returns. + sleepRhythm, + lastUpdated: now.toISOString(), + }; +} diff --git a/src/lib/dashboard/summary/cards.ts b/src/lib/dashboard/summary/cards.ts new file mode 100644 index 000000000..7c0d61e35 --- /dev/null +++ b/src/lib/dashboard/summary/cards.ts @@ -0,0 +1,307 @@ +/** + * The metric cards of `GET /api/dashboard/summary`: their wire shape, the + * i18n keys they carry, the trend and verdict laid over them, and the + * builders for each card the summary emits. + * + * The shape is fixed for the iOS client and intentionally normalised: + * `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum + * (BLOOD_PRESSURE_SYS etc.). + */ +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { + resolveTrendVerdict, + trendSentimentOfTrend, + type TrendSentiment, + type TrendVerdict, + type TrendVerdictContext, +} from "@/lib/targets/trend-verdict"; +import { + reconstructSleepNights, + type SleepSourceDiscrepancy, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +export const SPARK_DAYS = 7; + +export type MetricKind = + | "weight" + | "bloodPressure" + | "pulse" + | "bodyFat" + | "glucose" + | "sleep" + | "steps" + | "totalBodyWater" + | "boneMass" + | "oxygenSaturation" + | "mood" + | "bmi"; + +export interface MetricCard { + id: string; + kind: MetricKind; + /** + * i18n key for the metric's display title (e.g. + * `dashboard.metric.title.weight`). Clients resolve the key against their + * own locale bundle: the PWA via `useTranslations()`, the iOS client via + * its bundled `Localizable.xcstrings`. The previous wire shape carried a + * German string literal which injected DE into an English-locale iOS UI. + */ + titleKey: string; + latestValue: number | null; + secondaryValue: number | null; + /** + * i18n key for the metric's display unit (e.g. + * `dashboard.metric.unit.weight`). Same resolution contract as + * `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are identical + * across every shipped locale; per-kind keys keep the door open for + * locale-specific overrides (e.g. Imperial units) without another + * wire-shape change. + */ + unitKey: string; + /** + * Explicit unit token for clients that need to know the value's unit + * without inferring it from the metric kind. Most tiles leave this `null` + * (the `unitKey` i18n key already carries the display unit). The `sleep` + * tile sets it to `"h"` because its `latestValue` is a per-NIGHT total + * expressed in HOURS (a float), not the canonical `SLEEP_DURATION` + * minutes. + */ + unit: string | null; + /** + * Per-stage hours for the headline night, sleep tile only. `null` for + * every other kind and for a sleep night with no stage-tagged rows (a + * legacy bare-duration night). Additive: a future sleep detail view can + * render the breakdown without changing the headline `latestValue`. + */ + sleepStages: Partial> | null; + /** + * Source-discrepancy annotation for the headline night's MAIN session, + * sleep tile only; `null` for every other kind and for a night whose + * writers agree. Additive (the established wire pattern for the + * iOS-locked shape): observational only; `latestValue` stays the winning + * writer's total; clients render a discreet "sources disagree" hint next + * to the number. Minutes are whole (rounded), matching the + * `/api/sleep/night` serialisation. + */ + sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; + trend: "up" | "down" | "flat" | "unknown"; + sparkline: number[]; + updatedAt: string | null; + /** + * Total readings the user has ever logged for this metric, irrespective + * of the 7-day sparkline window. The dashboard tile keeps showing + * whenever `allTimeCount > 0` so a user with valid historical data isn't + * surprised by a tile disappearing during a logging gap. Distinct from + * `sparkline.length` which only carries the trailing-7-day points. + */ + allTimeCount: number; + /** + * ISO timestamp of the metric's single most recent reading. When + * `allTimeCount > 0` but the latest reading is older than 7 days, the iOS + * tile renders a muted "last reading N days ago" caption so the user + * understands the value isn't stale silently. `null` when the metric has + * no readings at all. + */ + lastSeenAt: string | null; + /** + * Which way a change of this metric counts for the person, against their + * own target or a profile default (`src/lib/targets/trend-verdict.ts`). + */ + direction?: TrendVerdict | null; + /** `direction` applied to this card's `trend`. */ + trendSentiment?: TrendSentiment; +} + +/** + * The verdict key behind each card: the measurement type it reads, or the + * derived key for the two cards that are not one. + */ +const VERDICT_KEY: Record = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + totalBodyWater: "TOTAL_BODY_WATER", + boneMass: "BONE_MASS", + oxygenSaturation: "OXYGEN_SATURATION", + mood: "MOOD", + bmi: "BMI", +}; + +/** + * Lay the verdict over each card. The reference placed against the band is + * the mean of the card's own seven-day sparkline, else its latest value, the + * seven-day average the web tiles place. Sleep cards are in hours and the + * verdict reads minutes. + */ +export function withVerdicts( + cards: MetricCard[], + ctx: Omit, +): MetricCard[] { + const referenceOf = (card: MetricCard | undefined): number | null => { + if (!card) return null; + const spark = card.sparkline.filter((v) => Number.isFinite(v)); + return spark.length > 0 + ? spark.reduce((sum, v) => sum + v, 0) / spark.length + : card.latestValue; + }; + // BMI is judged by the weight it is derived from (the target is a weight). + const weightReference = referenceOf(cards.find((c) => c.kind === "weight")); + return cards.map((card) => { + const reference = card.kind === "bmi" ? weightReference : referenceOf(card); + const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], { + ...ctx, + referenceValue: + reference !== null && card.kind === "sleep" + ? reference * 60 + : reference, + }); + return { + ...card, + direction, + trendSentiment: trendSentimentOfTrend(card.trend, direction), + }; + }); +} + +/** + * Wire i18n keys, not translated strings. The legacy maps emitted German + * literals like "Gewicht" / "Schritte" which injected into an English iOS UI + * once the iOS app flipped its source language. Clients now resolve these + * keys against their own locale bundle (the PWA via `useTranslations()`, iOS + * via `Localizable.xcstrings`). The server payload becomes language-neutral + * and the response shrinks slightly. + * + * One key per `MetricKind`: symmetric maps so a new kind has to add both a + * title and a unit key (Type-checked: `Record`). + */ +const METRIC_TITLE_KEYS: Record = { + weight: "dashboard.metric.title.weight", + bloodPressure: "dashboard.metric.title.bloodPressure", + pulse: "dashboard.metric.title.pulse", + bodyFat: "dashboard.metric.title.bodyFat", + glucose: "dashboard.metric.title.glucose", + sleep: "dashboard.metric.title.sleep", + steps: "dashboard.metric.title.steps", + totalBodyWater: "dashboard.metric.title.totalBodyWater", + boneMass: "dashboard.metric.title.boneMass", + oxygenSaturation: "dashboard.metric.title.oxygenSaturation", + mood: "dashboard.metric.title.mood", + bmi: "dashboard.metric.title.bmi", +}; + +const METRIC_UNIT_KEYS: Record = { + weight: "dashboard.metric.unit.weight", + bloodPressure: "dashboard.metric.unit.bloodPressure", + pulse: "dashboard.metric.unit.pulse", + bodyFat: "dashboard.metric.unit.bodyFat", + glucose: "dashboard.metric.unit.glucose", + sleep: "dashboard.metric.unit.sleep", + steps: "dashboard.metric.unit.steps", + totalBodyWater: "dashboard.metric.unit.totalBodyWater", + boneMass: "dashboard.metric.unit.boneMass", + oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", + mood: "dashboard.metric.unit.mood", + bmi: "dashboard.metric.unit.bmi", +}; + +/** + * The sleep sparkline: the trailing nights' TIME-ASLEEP in hours, + * reconstructed per night from the raw stage rows. The generic DAY-bucket + * rollup sparkline can't be used for sleep because it means across the + * per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE row average to + * 150) rather than summing them into a night total. + */ +export function buildSleepSparkline( + rows: SleepStageRow[], + tz: string, + priorityJson: unknown, +): number[] { + return reconstructSleepNights(rows, tz, priorityJson) + .filter((n) => n.asleepMinutes > 0) + .map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100) + .slice(-SPARK_DAYS); +} + +export function trendOf(values: number[]): MetricCard["trend"] { + if (values.length < 2) return "unknown"; + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + const epsilon = Math.max(1, Math.abs(first) * 0.01); + if (Math.abs(delta) < epsilon) return "flat"; + return delta > 0 ? "up" : "down"; +} + +/** A card with no sleep extras, from its reading and its spark. */ +export function plainCard( + id: string, + kind: MetricKind, + card: { + latestValue: number | null; + secondaryValue?: number | null; + spark: number[]; + updatedAt: string | null; + allTimeCount: number; + lastSeenAt: string | null; + }, +): MetricCard { + return { + id, + kind, + titleKey: METRIC_TITLE_KEYS[kind], + latestValue: card.latestValue, + secondaryValue: card.secondaryValue ?? null, + unitKey: METRIC_UNIT_KEYS[kind], + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: trendOf(card.spark), + sparkline: card.spark, + updatedAt: card.updatedAt, + allTimeCount: card.allTimeCount, + lastSeenAt: card.lastSeenAt, + }; +} + +/** The sleep card: the latest night's TIME ASLEEP in hours. */ +export function sleepCard(card: { + latestValue: number | null; + sleepStages: Partial> | null; + sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; + spark: number[]; + updatedAt: string | null; + allTimeCount: number; + lastSeenAt: string | null; +}): MetricCard { + return { + id: "sleep", + kind: "sleep", + titleKey: METRIC_TITLE_KEYS.sleep, + latestValue: card.latestValue, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS.sleep, + unit: "h", + sleepStages: card.sleepStages, + sleepSourceDiscrepancy: card.sleepSourceDiscrepancy, + trend: trendOf(card.spark), + sparkline: card.spark, + updatedAt: card.updatedAt, + allTimeCount: card.allTimeCount, + lastSeenAt: card.lastSeenAt, + }; +} + +/** The measurement types of the optional cards, in emit order. */ +export const OPTIONAL_CARD_TYPES = [ + ["BLOOD_GLUCOSE", "glucose"], + ["SLEEP_DURATION", "sleep"], + ["ACTIVITY_STEPS", "steps"], + ["TOTAL_BODY_WATER", "totalBodyWater"], + ["BONE_MASS", "boneMass"], + ["OXYGEN_SATURATION", "oxygenSaturation"], +] as const satisfies ReadonlyArray; diff --git a/src/lib/dashboard/summary/lookups.ts b/src/lib/dashboard/summary/lookups.ts new file mode 100644 index 000000000..9645065b1 --- /dev/null +++ b/src/lib/dashboard/summary/lookups.ts @@ -0,0 +1,86 @@ +/** + * Per-type lookups over the summary's bounded reads: the latest reading, + * the sparkline points and the all-time metadata of each measurement type. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import type { + readAllTimeCounts, + readLatestEver, + readSparkBuckets, +} from "@/lib/dashboard/summary-reads"; + +export interface SummaryLookups { + latestOf(type: MeasurementType): { value: number; at: Date } | null; + sparkOf(type: MeasurementType): number[]; + metaForType(type: MeasurementType): { + allTimeCount: number; + lastSeenAt: string | null; + }; +} + +export function summaryLookups(reads: { + latestEver: Awaited>; + sparkBuckets: Awaited>; + allTimeAggregate: Awaited>; +}): SummaryLookups { + const { latestEver, sparkBuckets, allTimeAggregate } = reads; + // Per-type metadata lookup: a typed Map so a metric with no readings at + // all falls through `metaForType` to the `{ allTimeCount: 0, lastSeenAt: + // null }` default. + const allTimeByType = new Map< + MeasurementType, + { allTimeCount: number; lastSeenAt: Date | null } + >(); + // The newest reading is the one `latestEver` already read per type; its + // instant is the metric's last-seen time. + const lastSeenByType = new Map( + latestEver.map((row) => [row.type, row.measured_at]), + ); + for (const row of allTimeAggregate) { + allTimeByType.set(row.type, { + allTimeCount: row.count, + lastSeenAt: lastSeenByType.get(row.type) ?? null, + }); + } + + // Per-type latest (one row per type), carrying the most recent reading + // regardless of age: the iOS tile shows the historical value plus a muted + // "last reading N days ago" caption rather than nothing at all. + const latestByType = new Map(); + for (const row of latestEver) { + latestByType.set(row.type, { + value: Number(row.value), + at: new Date(row.measured_at), + }); + } + // Cumulative tiles paint the daily SUM; spot tiles keep the daily MEAN. + // Reading `sum_value` directly off the rollup row avoids a `mean * count` + // reconstruction; the fallback covers rollup rows written before the sum + // column existed, during the boot-backfill convergence window. + const sparkByType = new Map(); + for (const row of sparkBuckets) { + const list = sparkByType.get(row.type) ?? []; + const cumulativePoint = + row.sum_value !== null + ? Number(row.sum_value) + : Number(row.mean) * Number(row.count); + const point = CUMULATIVE_HK_TYPES.has(row.type) + ? cumulativePoint + : Number(row.mean); + list.push(point); + sparkByType.set(row.type, list); + } + + return { + latestOf: (type) => latestByType.get(type) ?? null, + sparkOf: (type) => sparkByType.get(type) ?? [], + metaForType: (type) => { + const slot = allTimeByType.get(type); + return { + allTimeCount: slot?.allTimeCount ?? 0, + lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null, + }; + }, + }; +} diff --git a/src/lib/dashboard/summary/measurement-cards.ts b/src/lib/dashboard/summary/measurement-cards.ts new file mode 100644 index 000000000..8fe8e5103 --- /dev/null +++ b/src/lib/dashboard/summary/measurement-cards.ts @@ -0,0 +1,181 @@ +/** + * The measurement-backed cards of the dashboard summary, in emit order: + * weight, blood pressure, pulse, body fat, then the optional cards. Every + * card carries `allTimeCount` + `lastSeenAt` so the iOS tile renderer can + * keep a metric visible during a logging gap (gate on `allTimeCount > 0`) + * and paint a muted "last reading N days ago" caption when the most recent + * reading is older than the 7-day sparkline window. + */ +import type { SleepStage } from "@/generated/prisma/client"; +import type { + summarizeSleepNights, + SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +import { + buildSleepSparkline, + OPTIONAL_CARD_TYPES, + plainCard, + sleepCard, + type MetricCard, +} from "./cards"; +import type { SummaryLookups } from "./lookups"; + +export interface SummarySleepInput { + summary: ReturnType; + rows: SleepStageRow[]; + tz: string; + priorityJson: unknown; +} + +/** + * Blood pressure is a paired metric: the tile is "alive" whenever either + * side of the pair has history, and an account that has never logged it + * gets no empty placeholder tile. The count sums both sides; the most + * recent last-seen instant of the two drives the staleness hint. + */ +function bloodPressureCard(lookups: SummaryLookups): MetricCard | null { + const latestSys = lookups.latestOf("BLOOD_PRESSURE_SYS"); + const latestDia = lookups.latestOf("BLOOD_PRESSURE_DIA"); + const sysSpark = lookups.sparkOf("BLOOD_PRESSURE_SYS"); + const sysMeta = lookups.metaForType("BLOOD_PRESSURE_SYS"); + const diaMeta = lookups.metaForType("BLOOD_PRESSURE_DIA"); + const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount; + const bpLastSeenAt = ((): string | null => { + const sysAt = sysMeta.lastSeenAt; + const diaAt = diaMeta.lastSeenAt; + if (!sysAt) return diaAt; + if (!diaAt) return sysAt; + return sysAt >= diaAt ? sysAt : diaAt; + })(); + if (!latestSys && !latestDia && bpAllTimeCount === 0) return null; + return plainCard("bp", "bloodPressure", { + latestValue: latestSys?.value ?? null, + secondaryValue: latestDia?.value ?? null, + spark: sysSpark, + updatedAt: + latestSys?.at?.toISOString() ?? + latestDia?.at?.toISOString() ?? + bpLastSeenAt, + allTimeCount: bpAllTimeCount, + lastSeenAt: bpLastSeenAt, + }); +} + +/** + * Sleep is night-aggregated, not single-stage: the latest night's TIME + * ASLEEP (CORE/light + DEEP + REM, excluding IN_BED + AWAKE) in HOURS, the + * per-stage breakdown, and a sparkline of the trailing nights' asleep hours + * from the night reconstruction (the DAY-bucket rollup sparkline blends + * stages and would be misleading for sleep). + */ +function sleepNightCard( + sleep: SummarySleepInput, + meta: { allTimeCount: number; lastSeenAt: string | null }, +): MetricCard { + const night = sleep.summary.latestNight; + const toHours = (minutes: number): number => + Math.round((minutes / 60) * 100) / 100; + const nightSpark = buildSleepSparkline( + sleep.rows, + sleep.tz, + sleep.priorityJson, + ); + const stageHours: Partial> | null = night + ? Object.fromEntries( + Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]), + ) + : null; + return sleepCard({ + latestValue: night ? toHours(night.asleepMinutes) : null, + sleepStages: + stageHours && Object.keys(stageHours).length > 0 ? stageHours : null, + // Additive, observational: the headline night's MAIN session saw two + // writer buckets with clearly different asleep totals. Rounded to whole + // minutes, matching the `/api/sleep/night` serialisation. + sleepSourceDiscrepancy: night?.sourceDiscrepancy + ? { + deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes), + sources: night.sourceDiscrepancy.sources.map((b) => ({ + source: b.source, + deviceType: b.deviceType, + asleepMinutes: Math.round(b.asleepMinutes), + })), + } + : null, + spark: nightSpark, + updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + }); +} + +export function measurementCards( + lookups: SummaryLookups, + sleep: SummarySleepInput, +): MetricCard[] { + const metrics: MetricCard[] = []; + + // Weight is always emitted. + { + const latest = lookups.latestOf("WEIGHT"); + const meta = lookups.metaForType("WEIGHT"); + metrics.push( + plainCard("weight", "weight", { + latestValue: latest?.value ?? null, + spark: lookups.sparkOf("WEIGHT"), + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, + }), + ); + } + + const bp = bloodPressureCard(lookups); + if (bp) metrics.push(bp); + + // Pulse and body fat: emitted whenever there is a latest reading or any + // history, so a tile doesn't disappear during a fortnight without a fresh + // reading and an account that never logged one gets no placeholder. + for (const [type, id, kind] of [ + ["PULSE", "pulse", "pulse"], + ["BODY_FAT", "bodyFat", "bodyFat"], + ] as const) { + const latest = lookups.latestOf(type); + const meta = lookups.metaForType(type); + if (latest || meta.allTimeCount > 0) { + metrics.push( + plainCard(id, kind, { + latestValue: latest?.value ?? null, + spark: lookups.sparkOf(type), + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, + }), + ); + } + } + + // Optional cards: emitted whenever the user has *ever* logged the metric, + // so a glucose tile that hasn't been touched in 10 days still renders with + // the historical value visible (paired with the lastSeenAt caption). + for (const [type, kind] of OPTIONAL_CARD_TYPES) { + const latest = lookups.latestOf(type); + const meta = lookups.metaForType(type); + if (!latest && meta.allTimeCount === 0) continue; + if (kind === "sleep") { + metrics.push(sleepNightCard(sleep, meta)); + continue; + } + metrics.push( + plainCard(kind, kind, { + latestValue: latest?.value ?? null, + spark: lookups.sparkOf(type), + updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, + }), + ); + } + return metrics; +}