Files
HealthLog/src/lib/daily/load-digest.ts
T
Marc-André Bombeck 6bd8d387ae feat(insights): surface the same-time baseline on Today and in the grid
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
2026-07-27 23:32:22 +02:00

571 lines
21 KiB
TypeScript

/**
* IO seam for the daily digest (P3) — gathers the ALREADY-CACHED inputs and
* hands them to the pure `buildDailyDigest` composer.
*
* The heavy read (health score, meds-today, sleep last-seen, the daily
* briefing lift) rides `readDashboardSnapshotCached` — the SAME SWR cell the
* dashboard already serves, so the digest never re-runs the snapshot builder
* within its TTL and never reaches an AI provider (the briefing is lifted
* read-only from `User.insightsCachedText`). Two light deterministic reads add
* the rail's non-snapshot inputs: broken integrations and overdue Vorsorge
* reminders. Module gating is inherited from the snapshot (already
* module-gated at the build layer) plus the resolved module map the item
* builders consult.
*/
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { userDayKey } from "@/lib/tz/format";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { isArrivalKind } from "@/lib/arrivals/types";
import {
buildDailyDigest,
type DailyDigest,
type DailyDigestArrival,
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSameTime,
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
import {
MILESTONE_SALIENT_TYPES,
milestoneFromRecord,
milestonesFromStreak,
selectFreshMilestone,
type Milestone,
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { computeSameTimeBaseline } from "@/lib/insights/derived/same-time-baseline";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
/** Defensive cap on how many overdue reminders we read for the rail summary. */
const PREVENTIVE_DUE_READ_LIMIT = 20;
/** Standing plan states that can carry a check-in (§2.3). */
const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const;
/** Cap on plans scanned for the (one/day) check-in candidate. */
const CHECKIN_PLAN_READ_LIMIT = 50;
/** Trailing window the milestone streak read uses (matches score-narrative). */
const MILESTONE_STREAK_WINDOW_DAYS = 30;
/** How far back to scan for a just-set personal best (a couple of days is ample). */
const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000;
/** UTC-ISO day key — the space the streak series + rollup tier already emit. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* S12 — gather today's single freshly-reached milestone (or null) from the
* engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim:
* - `detectStreak` over the salient vitals' day-mean series (the exact
* `score-narrative` pattern) → return-to-range + sustained-in-range states;
* - a bounded `PersonalRecord` scan (the `pr-detection` output) → new bests.
* The reached-once gate (`selectFreshMilestone`) admits at most one, only on the
* day it was reached. Any failure leaves the reward quiet rather than guessing.
*/
async function gatherFreshMilestone(
userId: string,
now: Date,
): Promise<Milestone | null> {
const todayKey = utcDayKey(now);
const candidates: Milestone[] = [];
const coverage = await probeRollupCoverage(userId).catch(() => null);
if (coverage) {
const perMetric = await Promise.all(
MILESTONE_SALIENT_TYPES.map(async (type) => {
try {
const { points } = await readDayMeanSeries(
userId,
type,
MILESTONE_STREAK_WINDOW_DAYS,
now,
coverage,
);
if (points.length === 0) return [] as Milestone[];
const series: StreakPoint[] = points.map((p) => ({
day: p.day,
value: p.mean,
}));
const latestDayKey = points[points.length - 1].day;
return milestonesFromStreak(type, detectStreak(series), latestDayKey);
} catch {
return [] as Milestone[];
}
}),
);
candidates.push(...perMetric.flat());
}
try {
const records = await prisma.personalRecord.findMany({
where: {
userId,
metricType: { in: [...MILESTONE_SALIENT_TYPES] },
achievedAt: {
gte: new Date(now.getTime() - MILESTONE_RECORD_LOOKBACK_MS),
},
},
select: { metricType: true, achievedAt: true },
orderBy: { achievedAt: "desc" },
take: 10,
});
for (const record of records) {
candidates.push(
milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)),
);
}
} catch {
// A read hiccup just leaves the reward quiet — never a fabricated one.
}
return selectFreshMilestone(candidates, todayKey);
}
/**
* P — the milestone gather (~8.5k rows via the streak/PR reads) and the
* intraday-tension read (a bounded but real per-sample day read) are the two
* heaviest inputs the digest computes on every request, yet both are usually
* null and change at most once per local day. Every open tab's 120 s poll
* (`DASHBOARD_REFETCH_INTERVAL_MS`) was re-deriving them from scratch.
*
* Cached together in ONE cell — keyed `${userId}|daily-digest-extras|${dateKey}`
* under the shared `analytics` bucket so the write-invalidation semantics
* `readDashboardSnapshotCached` already relies on cover this cell too: every
* measurement write sweeps the `${userId}|` prefix (mark-stale for a
* background sync, hard-evict for an interactive write — see
* `invalidateUserMeasurements`), so a fresh sleep/vitals landing still
* refreshes this within the same SWR contract the snapshot cell uses. TTL
* mirrors `SNAPSHOT_CACHE_TTL_MS` — strictly greater than the client poll
* interval so a scheduled refetch lands warm.
*/
const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
interface DailyDigestExtras {
milestone: Milestone | null;
tensionWindow: DailyDigestTensionWindow | null;
/**
* The cached cell holds the same-time read WITHOUT its locale-formatted
* labels: the cell is keyed on (user, local day) and a locale-specific
* string in it would be served to a reader who has since switched language.
* The labels are grouped at the call site, where the locale is resolved.
*/
sameTime: Omit<DailyDigestSameTime, "todayLabel" | "typicalLabel"> | null;
}
/**
* v1.34.0 — the rail's same-time read is STEPS only.
*
* All four cumulative metrics carry a same-time baseline and all four have a
* tile in the Insights grid. The rail is a glance of at most three items, so
* putting four activity cards on it would crowd out an overdue dose to say the
* same thing four ways. Steps is the number people actually check.
*/
const SAME_TIME_RAIL_TYPE = "ACTIVITY_STEPS";
function digestExtrasCacheKey(userId: string, dateKey: string): string {
return `${userId}|daily-digest-extras|${dateKey}`;
}
async function loadDailyDigestExtrasCached(
userId: string,
timezone: string,
todayLocalDate: string,
now: Date,
): Promise<DailyDigestExtras> {
return cachedSwr(
caches.analytics as ServerCache<DailyDigestExtras>,
digestExtrasCacheKey(userId, todayLocalDate),
async () => {
// S11 / S12 — independent reads, hoisted into one Promise.all (a prior
// revision ran them sequentially). Each is already fault-isolated
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
const [milestone, tensionWindow, sameTime] = await Promise.all([
gatherFreshMilestone(userId, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null),
// The engine gates itself — it returns its `insufficient` arm while it
// is still learning the usual day, when the account has no intraday
// rows at all (every daily-total source), and before the day's first
// hour has finished. Only the `ok` arm reaches the rail. Passing no
// profile because the engine reads none; fault-isolated like its
// siblings, since the digest is a must-not-fail path.
computeSameTimeBaseline(userId, null, {
type: SAME_TIME_RAIL_TYPE,
now,
})
.then((d): DailyDigestExtras["sameTime"] =>
d.status === "ok"
? {
type: d.value.type,
band: d.value.band,
asOfHour: d.value.asOfHour,
todayValue: Math.round(d.value.todayValue),
typicalValue: Math.round(d.value.typicalValue),
}
: null,
)
.catch(() => null),
]);
return { milestone, tensionWindow, sameTime };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
);
}
/** S10 — how recently an ECG recording counts as "new" for the rail read. */
const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000;
interface CoachPlanRow {
id: string;
status: string;
reviewDate: Date | null;
createdAt: Date;
updatedAt: Date;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
}
/**
* Decrypt a plan's if→then prose fault-isolated (an undecryptable row keeps a
* null text, never throws) so the check-in card can echo the user's own words.
*/
function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan {
let planText: string | null = null;
try {
planText = `${decryptFromBytes(row.ifCueEncrypted)} → ${decryptFromBytes(
row.thenActionEncrypted,
)}`;
} catch {
planText = null;
}
return {
id: row.id,
status: row.status,
reviewDate: row.reviewDate,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
planText,
};
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Today's arrival markers, decrypted fault-isolated.
*
* One indexed read on `[userId, localDate]` — the exact shape of the model's
* `@@index`, and at most one row per arrival kind, so the result is bounded by
* the closed `ARRIVAL_KINDS` enum rather than by anything the user can grow.
*
* A line that fails to decrypt (a key rotated out from under the row) yields a
* null line, never a throw: the digest is a hot, must-not-fail path, and the
* marker itself — which is what actually drives the "just in" chip — survives
* a lost sentence intact. Same discipline as `toCoachPlanCandidate` above.
*/
function toDigestArrival(row: {
kind: string;
occurredAt: Date;
arrivedAt: Date;
lineEncrypted: Uint8Array | null;
generatedAt: Date | null;
}): DailyDigestArrival | null {
// A kind this build does not know about (a row written by a newer version)
// is dropped rather than widened — the DTO's kind union is closed.
if (!isArrivalKind(row.kind)) return null;
let line: string | null = null;
// The ciphertext rides only once the generation actually COMMITTED. A row
// mid-generation carries no `generatedAt`, and its line must not surface.
if (row.generatedAt !== null && row.lineEncrypted !== null) {
try {
line = decryptFromBytes(row.lineEncrypted);
} catch {
line = null;
}
}
return {
kind: row.kind,
occurredAt: row.occurredAt,
arrivedAt: row.arrivedAt,
line,
};
}
export async function loadDailyDigest(
user: User,
now: Date = new Date(),
): Promise<DailyDigest> {
// Computed before the fan-out because the arrival read is keyed on it. Pure
// (a tz format of `now`), so hoisting it costs nothing.
const todayLocalDate = userDayKey(now, user.timezone);
const [
{ body: snapshot, locale },
modules,
syncRows,
dueReminders,
planRows,
ecgRow,
arrivalRows,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
prisma.integrationStatus.findMany({
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
select: { integration: true, state: true },
orderBy: { integration: "asc" },
}),
prisma.measurementReminder.findMany({
where: {
userId: user.id,
enabled: true,
deletedAt: null,
nextDueAt: { not: null, lte: now },
},
select: { label: true },
orderBy: { nextDueAt: "asc" },
take: PREVENTIVE_DUE_READ_LIMIT,
}),
// Standing plans for the (one/day) check-in candidate. The plaintext
// columns decide due-ness; the encrypted prose is only decrypted below,
// and only for a coach-enabled account (disabled-module data never
// enters the digest — the builder also drops it, this skips the decrypt).
prisma.coachPlan.findMany({
where: {
userId: user.id,
deletedAt: null,
status: { in: [...CHECKIN_PLAN_STATES] },
},
select: {
id: true,
status: true,
reviewDate: true,
createdAt: true,
updatedAt: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
},
orderBy: { updatedAt: "desc" },
take: CHECKIN_PLAN_READ_LIMIT,
}),
// S10 — the freshest ECG recording within the last-day window, for the
// `ecg_new_recording` rail item. Descriptors only (verdict + recordedAt) —
// the encrypted waveform is never selected, so no decrypt and no trace
// crosses into the digest. The builder gates on the `insights` module.
prisma.ecgRecording.findFirst({
where: {
userId: user.id,
recordedAt: { gte: new Date(now.getTime() - ECG_NEW_WINDOW_MS) },
},
orderBy: { recordedAt: "desc" },
select: { recordedAt: true, rhythmClassification: true },
}),
// The arrival spine's markers for the user's CURRENT local day. At most
// one row per arrival kind by the model's unique constraint, so this is a
// bounded indexed read, not a scan.
prisma.arrivalReaction.findMany({
where: { userId: user.id, localDate: todayLocalDate },
select: {
kind: true,
occurredAt: true,
arrivedAt: true,
lineEncrypted: true,
generatedAt: true,
},
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
? {
value: snapshot.healthScore.score,
band: snapshot.healthScore.band,
delta: snapshot.healthScore.delta,
}
: null;
const sleepSlot = snapshot.tiles.lastSeenByType["SLEEP_DURATION"];
const sleepLastSeenDaysAgo = sleepSlot ? sleepSlot.daysAgo : null;
// S4 freshness (§E) — the day is `final` once the sleep-arrival morning
// refresh has stamped `User.morningDigestRefreshedOn` with the user's current
// local date (profile tz). Read fresh off the row here, so it flips the
// instant the refresh job runs — ahead of the snapshot cache that feeds
// `sleepLastSeenDaysAgo`.
const morningRefreshedToday =
user.morningDigestRefreshedOn !== null &&
user.morningDigestRefreshedOn === todayLocalDate;
const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({
integration: row.integration,
state: row.state,
}));
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({
label: row.label,
}));
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
// (walking-steadiness / event codes) never apply, so anything else maps to
// null rather than leaking into the device-verdict copy.
const verdict = ecgRow?.rhythmClassification;
const latestEcg: DailyDigestEcg | null = ecgRow
? {
recordedAt: ecgRow.recordedAt,
deviceVerdict:
verdict === "IRREGULAR" ||
verdict === "NOT_DETECTED" ||
verdict === "INCONCLUSIVE"
? verdict
: null,
}
: null;
// Only decrypt plan prose for a coach-enabled account; the builder gates on
// the module too, so a disabled coach never surfaces a check-in either way.
const coachEnabled = modules.coach !== false;
const coachPlans: DailyDigestCoachPlan[] = coachEnabled
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
: [];
// S12 — the calm reward layer. Only gather when the insights module (the
// narrative layer that hosts the milestone card) is on; the builder gates on
// it too, so a disabled account never surfaces one either way.
//
// S11 — the day's elevated-at-rest window, computed on demand from raw for
// TODAY only (read-swap, one bounded day-read; never persisted). Gated on the
// insights module and fault-isolated: a tension-read failure must never break
// the digest (a hot, must-not-fail path), it just omits the calm marker.
//
// Both ride the cached, single-flight `loadDailyDigestExtrasCached` cell so
// a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak +
// per-sample tension reads on every request — see its docblock for the
// cache/invalidation contract.
const insightsEnabled = modules.insights !== false;
const { milestone, tensionWindow, sameTime } = insightsEnabled
? await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
)
: { milestone: null, tensionWindow: null, sameTime: null };
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
// only their candidate keys are worth looking up — never a full per-user
// history read. At most 3 keys, so this is a single indexed `IN (...)`.
const candidateItemKeys = [
milestone ? milestoneItemKey(milestone) : null,
latestEcg ? ecgItemKey(latestEcg.recordedAt) : null,
tensionWindow
? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay)
: null,
sameTime ? sameTimeBaselineItemKey(todayLocalDate, sameTime.type) : null,
].filter((k): k is string => k !== null);
const dismissedRows =
candidateItemKeys.length > 0
? await prisma.dismissedPriorityItem.findMany({
where: { userId: user.id, itemKey: { in: candidateItemKeys } },
select: { itemKey: true },
})
: [];
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
const resolvedLocale = resolveLocale(locale);
const { t } = getServerTranslator(resolvedLocale);
// Group the two figures for the reader. `Intl.NumberFormat` is the only
// locale-aware step the digest takes on its own; everything else it renders
// comes through the translator.
const groupNumber = new Intl.NumberFormat(resolvedLocale).format;
const sameTimeItem: DailyDigestSameTime | null = sameTime
? {
...sameTime,
todayLabel: groupNumber(sameTime.todayValue),
typicalLabel: groupNumber(sameTime.typicalValue),
}
: null;
const digest = buildDailyDigest(
{
now,
modules,
score,
briefing: snapshot.briefing,
medsToday: snapshot.medsToday,
sleepLastSeenDaysAgo,
morningRefreshedToday,
syncIssues,
preventiveDue,
coachPlans,
milestone,
tensionWindow,
sameTime: sameTimeItem,
latestEcg,
todayLocalDate,
dismissedItemKeys,
arrivals: arrivalRows
.map(toDigestArrival)
.filter((a): a is DailyDigestArrival => a !== null),
},
t,
);
annotate({
action: { name: "daily.digest.build" },
meta: {
daily_digest_phase: digest.phase,
daily_digest_sleep_pending: digest.sleepPending,
daily_digest_item_count: digest.worthALook.length,
daily_digest_has_score: digest.score !== null,
daily_digest_has_checkin: digest.worthALook.some(
(i) => i.kind === "coach_checkin",
),
daily_digest_has_milestone: digest.worthALook.some(
(i) => i.kind === "milestone",
),
daily_digest_just_in_kind: digest.justIn?.kind ?? null,
daily_digest_has_reaction_line: digest.reactionLine !== null,
},
});
return digest;
}