mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
refactor(dashboard): move the summary builder out of its route
This commit is contained in:
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",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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]>;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user