Files
HealthLog/src/app/api/dashboard/summary/route.ts
T
Marc-André Bombeck ebeb16daa3 fix(sleep): emit the per-night total, not a single stage, on the sleep tile and series
SLEEP_DURATION is stored in minutes, one row per sleep stage per night
(IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit
category-sample ingest landed. The value is not cumulative, so reading
the single most-recent row surfaced one stage's minutes — never the
night. The dashboard sleep tile and the sleep series therefore showed a
fragment of the night rather than the night.

Add a shared `summarizeSleepNights` helper that groups the per-stage
rows by the wake-day calendar date in the user's timezone and sums the
asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row),
excluding IN_BED and AWAKE per the AASM convention already used by the
sleep score. Wire it into:

- the dashboard summary tile, which now emits last night's time asleep
  in hours with an explicit `unit: "h"` and a per-stage breakdown for a
  future detail view;
- the sleep series, which now returns one point per night (time asleep
  in hours) plus a top-level `unit` for every kind so clients never
  infer the unit;
- the slim summaries slice, so the web dashboard tile and the insights
  overview read per-night totals (kept in canonical minutes there; the
  web tile converts to hours) — strictly more correct than averaging
  across stage rows, and parity with the iOS value.
2026-06-04 16:44:53 +02:00

898 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GET /api/dashboard/summary
*
* Aggregator endpoint for the iOS DashboardSummary view. Combines
* greeting, intake-day streaks, today's medication compliance, the
* highlighted insight, and per-metric latest+sparkline+trend.
*
* 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.).
*
* Cold-mount performance (v1.4.38 W-F)
* ------------------------------------
* The legacy shape ran an unbounded `prisma.measurement.findMany` over
* the trailing 7 days plus a second unbounded `findMany` over the
* trailing 365 days for the streak-day set. On a power-user account
* (Apple Health step samples ≈ thousands per day) those two queries
* dominated the wall-clock at ~4.6 s cold even though the DOWNSTREAM
* JS code only ever needed:
* - the latest value per type within the 7-day window
* - a small sparkline (≤7 daily aggregates) per type
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
*
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
* - `DISTINCT ON (type)` → one row per type carrying the latest
* value + measuredAt (≤ N_metrics rows). REG-11 (v1.4.44) dropped
* the trailing-7-day window from the WHERE clause; the tile now
* surfaces the all-time-latest reading per type so a 60-day-old BP
* measurement still feeds the tile (paired with the stale-caption
* hint built off `lastSeenAt`).
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
* Sparkline points become the bucket means rather than individual
* raw samples, which is a *smoother* trend signal for high-volume
* metrics like ACTIVITY_STEPS and bounded for every other metric.
* REG-11 (v1.4.44) switched the calendar-window filter for a
* `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start
* DESC)` window so the sparkline takes the last `SPARK_DAYS`
* buckets per type regardless of age.
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
*
* The whole response is wrapped in the 60 s analytics LRU cache so
* subsequent iOS polls inside the window hit memory. Invalidation runs
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
* `invalidateUserMedications` hooks.
*/
import { apiHandler, requireAuth } 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 { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
summarizeSleepNights,
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import type { SleepStage } from "@/generated/prisma/client";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
type MetricKind =
| "weight"
| "bloodPressure"
| "pulse"
| "bodyFat"
| "glucose"
| "sleep"
| "steps"
| "totalBodyWater"
| "boneMass"
| "oxygenSaturation";
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<Record<SleepStage, number>> | null;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
/**
* v1.4.33 maintainer-item-1 — 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 maintainer-item-1 — 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.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<MetricKind, string>`).
*/
const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
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",
};
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
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",
};
/**
* 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): number[] {
return reconstructSleepNights(rows, tz)
.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";
}
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in the user's display timezone) plus the
* longest streak in the last `STREAK_WINDOW_DAYS` days.
*
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
* Auckland user gets their Auckland-day streak rather than the Berlin
* one. The activity-day Set entries are already produced in the same
* zone by the caller (via `userDayKey`), so the cursor walk only needs
* the same zone here to align. */
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
let longest = 1;
let run = 1;
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
if (cur - prev === 86_400_000) {
run += 1;
longest = Math.max(longest, run);
} else {
run = 1;
}
}
// Current streak: walk back from today (user's tz).
const todayKey = userDayKey(new Date(), userTz);
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(userDayKey(cursor, userTz))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(userDayKey(cursor, userTz))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
return { currentDays, longest };
}
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the single most recent
* reading per measurement type, irrespective of age. One row per
* metric the user has ever touched; replaces the legacy unbounded
* `prisma.measurement.findMany`.
*
* REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE
* clause. The old shape returned `latestValue: null` + an empty
* sparkline for accounts whose last BP / pulse reading was older than
* 7 days, leaving the iOS tile blank even though the historical row
* was still in the database. The all-time aggregate (`groupBy` on
* `measurements`) already proves the row exists; the row count is
* still bounded by `|measurementTypes|` via `DISTINCT ON`. */
interface LatestEverRow {
type: MeasurementType;
value: number;
measured_at: Date;
}
/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard
* sparkline. At most 7 buckets per metric × N metrics — bounded by
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count.
*
* v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile
* (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket
* MEAN. Spot metrics ignore the column; cumulative tiles fall back to
* `mean * count` when the legacy NULL hits (boot-backfill convergence
* window).
*
* REG-11 (v1.4.44): switched from a calendar-window filter
* (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION
* BY type ORDER BY bucket_start DESC)` window so the sparkline takes
* the last `SPARK_DAYS` daily buckets per type regardless of age. An
* account whose last BP reading is 60 days old now still gets the
* trailing 7 days of historical buckets feeding the tile chart. */
interface SparklineRow {
type: MeasurementType;
bucket_start: Date;
mean: number;
count: number;
sum_value: number | null;
}
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
* The route only needs YYYY-MM-DD strings, so the aggregate runs
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
* the day-keys directly. At most 365 rows; vs. the legacy `findMany`
* which could return tens of thousands. */
interface ActivityDayRow {
day_key: string;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
// v1.4.25 W7b — anchor every day-bucket call to the user's display
// timezone. Falls back to Europe/Berlin when the column is somehow
// missing (defensive — the schema's NOT NULL default normally pins
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU.
// Subsequent iOS polls inside the TTL hit memory; measurement /
// mood / medication writes invalidate the user-bucket via the
// existing `invalidateUserMeasurements` + the v1.4.38-extended
// `invalidateUserMedications` hooks.
const body = await cached(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildDashboardSummary>
>>,
`${user.id}|dashboard-summary`,
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
annotate,
);
return apiSuccess(body);
});
interface SummaryBuilderContext {
greetingName: string;
locale: Locale;
}
function buildContext(
user: { displayName: string | null; username: string; locale: string | null },
): SummaryBuilderContext {
const greetingName = user.displayName ?? user.username;
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
// so the greeting + streak label resolve against the user's
// configured language. The legacy narrowing dropped fr/es/it/pl back
// to the default; that bug is masked for the title/unit fields by
// the new key-based wire shape but still mattered for the strings
// that stay translated server-side.
const locale: Locale = (locales as readonly string[]).includes(
user.locale ?? "",
)
? (user.locale as Locale)
: defaultLocale;
return { greetingName, locale };
}
/**
* v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently
* backfill any missing `MedicationIntakeEvent` rows via the shared
* helper, then re-read the today-window so the caller sees both
* pre-existing rows + the freshly minted ones.
*
* Mirrors the behaviour of `/api/medications/intake?scope=today` (same
* helper) so the iOS Dashboard tile (fed by this route) and the iOS
* Erfassen sheet (fed by the intake route) converge on the same row
* set the moment a daily med becomes active.
*/
async function projectAndReadTodaysIntakes(
userId: string,
userTz: string,
todayStart: Date,
todayEnd: Date,
): Promise<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd,
});
return prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from the today tile.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
});
}
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,
);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 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 W-F — 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<string, number> = {};
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
const t0 = Date.now();
const result = await builder();
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
return result;
};
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
// unbounded ones. Row counts are now:
// - latestEver: ≤ N metric types (one row per type via DISTINCT ON,
// 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 (unchanged)
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
// - streakActivity: ≤ daily intake count × 365 (unchanged)
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
const [
latestEver,
sparkBuckets,
allTimeAggregate,
todaysIntakes,
streakActivity,
measurementStreakDays,
sleepStageRows,
] = await Promise.all([
time("latestEver", () =>
prisma.$queryRaw<LatestEverRow[]>`
SELECT DISTINCT ON (m."type")
m."type" AS type,
m."value"::double precision AS value,
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
),
time("sparkline", () =>
prisma.$queryRaw<SparklineRow[]>`
SELECT type, bucket_start, mean, count, sum_value
FROM (
SELECT
r."type" AS type,
r."bucket_start" AS bucket_start,
r."mean"::double precision AS mean,
r."count"::int AS count,
r."sum_value"::double precision AS sum_value,
ROW_NUMBER() OVER (
PARTITION BY r."type"
ORDER BY r."bucket_start" DESC
) AS rn
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
) sub
WHERE rn <= ${SPARK_DAYS}
ORDER BY type, bucket_start ASC
`,
),
time("allTime", () =>
prisma.measurement.groupBy({
by: ["type"],
where: { userId, type: { in: measurementTypes }, deletedAt: null },
_count: { _all: true },
_max: { measuredAt: true },
}),
),
// v1.4.39 W-SERVER-FIX-2 — 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. Re-uses the same
// helper + idempotent `createMany` (with `skipDuplicates: true`
// so a concurrent intake-route hit can't race a duplicate row in
// before the existence probe converges).
time("todaysIntakes", () =>
projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd),
),
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 },
}),
),
// v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany`
// that pulled every raw row in the window only to collapse them to
// day-keys in JS. The SQL `date_trunc` returns at most 365 rows
// even on accounts with millions of step samples. The `userTz`
// conversion is folded server-side so the day boundary matches
// `userDayKey`'s output (it formats the timestamp in the user's
// tz before slicing YYYY-MM-DD).
time("streakDays", () =>
prisma.$queryRaw<ActivityDayRow[]>`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
'YYYY-MM-DD'
) AS day_key
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${streakWindowStart}
AND m."deleted_at" IS NULL
`,
),
// 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" },
select: { value: true, measuredAt: true, sleepStage: true },
}),
),
]);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
// always `number` per Prisma's runtime contract; defending against
// undefined keeps the helper's narrow signature stable.
const allTimeByType = new Map<
MeasurementType,
{ allTimeCount: number; lastSeenAt: Date | null }
>();
for (const row of allTimeAggregate) {
allTimeByType.set(row.type, {
allTimeCount: row._count?._all ?? 0,
lastSeenAt: row._max?.measuredAt ?? 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 W-F — activity-day set assembled from the bounded streak
// queries. The measurement side already arrives as YYYY-MM-DD keys
// from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical
// to what `userDayKey(measuredAt, userTz)` would produce in JS.
const activityDays = new Set<string>();
for (const row of measurementStreakDays) {
if (row.day_key) activityDays.add(row.day_key);
}
for (const e of streakActivity) {
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
}
const streak = computeStreak(activityDays, userTz);
// v1.4.38 W-F — 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<MeasurementType, { value: number; at: Date }>();
for (const row of latestEver) {
latestByType.set(row.type, {
value: Number(row.value),
at: new Date(row.measured_at),
});
}
// v1.4.39 W-SUM — 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<MeasurementType, number[]>();
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,
);
const metrics: MetricCard[] = [];
// v1.4.33 maintainer-item-1 — 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,
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,
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,
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,
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,
);
const stageHours: Partial<Record<SleepStage, number>> | 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,
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,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
allTimeCount: meta.allTimeCount,
lastSeenAt: meta.lastSeenAt,
});
}
const scheduledToday = todaysIntakes.length;
const takenToday = todaysIntakes.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const t = getServerTranslator(ctx.locale).t;
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,
lastUpdated: now.toISOString(),
};
}