refactor(dashboard): move the summary builder out of its route

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 15:25:48 +02:00
1 parent 1b32217844
commit 356d947ab0
12 files changed
+975 -913

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -57,7 +57,7 @@ const INTAKE_READ =
*/
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"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",
@@ -148,7 +148,7 @@ const TS_READERS: Record<string, Entry> = {
"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",
},
@@ -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",
],
},
+7 -901
View File
@@ -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<Record<SleepStage, number>> | 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<MetricKind, string> = {
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<TrendVerdictContext, "referenceValue">,
): 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<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",
mood: "dashboard.metric.title.mood",
bmi: "dashboard.metric.title.bmi",
};
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",
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<SummaryBuilderContext> {
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<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 — 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<string>(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<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 — 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,
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<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,
// 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(),
};
}
+383
View File
@@ -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<SummaryBuilderContext> {
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<ReturnType<typeof buildMoodDailySeries>>,
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<ReturnType<typeof computeBmi>>,
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 = <T>(label: string, builder: () => Promise<T>) => Promise<T>;
/** 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<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;
};
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<string>(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(),
};
}
+307
View File
@@ -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<Record<SleepStage, number>> | 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<MetricKind, string> = {
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<TrendVerdictContext, "referenceValue">,
): 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<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",
mood: "dashboard.metric.title.mood",
bmi: "dashboard.metric.title.bmi",
};
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",
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<Record<SleepStage, number>> | 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<readonly [MeasurementType, MetricKind]>;
+86
View File
@@ -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<ReturnType<typeof readLatestEver>>;
sparkBuckets: Awaited<ReturnType<typeof readSparkBuckets>>;
allTimeAggregate: Awaited<ReturnType<typeof readAllTimeCounts>>;
}): 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<MeasurementType, { value: number; at: Date }>();
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<MeasurementType, number[]>();
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,
};
},
};
}
@@ -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<typeof summarizeSleepNights>;
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<Record<SleepStage, number>> | 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;
}