Files
HealthLog/src/lib/daily/load-digest.ts
T
Marc-André Bombeck 6a422542f1 fix(digest): decide a milestone's day on the user's calendar
Today's key, each new record's day and the streak series were UTC days, so
through a New York evening a best set that afternoon counted as yesterday's
and dropped out of the digest. All three are now the user's own days; the
streak series is folded live on local days for this read.
2026-09-26 15:44:25 +02:00

738 lines
28 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 type { Locale } from "@/lib/i18n/config";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
aiCapabilityForRecord,
aiCapabilityToServe,
} from "@/lib/ai/capabilities/gate";
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
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 DailyDigestAi,
type DailyDigestArrival,
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestUpcomingVisit,
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";
import {
PRIORITY_ITEM_KINDS,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
/**
* How far ahead the rail mentions a booked appointment: the next 48 hours, on
* top of everything booked for the local today.
*
* The rail is a "what about today" surface, so anything further out is not a
* thing to act on this morning. Two days rather than one because an evening
* reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it
* from them.
*/
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
/** Defensive cap on how many planned visits the rail read returns. */
const UPCOMING_VISIT_READ_LIMIT = 10;
/** 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;
/**
* 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,
timezone: string,
now: Date,
): Promise<Milestone | null> {
// Every day key here is the user's own calendar day: today's key, the
// streak series (folded on local days) and each record's day. UTC keys
// named tomorrow "today" through a New York evening.
const todayKey = userDayKey(now, timezone);
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,
timezone,
);
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,
userDayKey(record.achievedAt, timezone),
),
);
}
} 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, timezone, 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,
};
}
/**
* 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;
},
/** Whether the record's `reactionLines` text may be shown. */
linesServable: boolean,
): 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.
// A model-written line is never served (nor decrypted) while
// `reactionLines` is unavailable for the record; the marker still is.
if (linesServable && 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 interface DailyDigestLoadOptions {
/**
* Overrides the dashboard's hero visibility for non-hero consumers.
* Passing `PRIORITY_ITEM_KINDS` keeps notification eligibility independent
* from which cards the user chose to display in the Today hero.
*/
enabledItemKinds?: readonly PriorityItemKind[];
/**
* A locale already resolved by a background caller (the morning briefing
* push). Request-driven readers leave it unset and the snapshot read
* resolves the locale from the request.
*/
locale?: Locale;
}
export async function loadDailyDigest(
user: User,
now: Date = new Date(),
options: DailyDigestLoadOptions = {},
): 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);
// First instant of the user's NEXT local day (profile tz) — bounds the
// dose-window rail to today's business. `getUserTodayBounds` returns the
// inclusive last millisecond of today; +1 ms is exactly tomorrow's start.
const todayBounds = getUserTodayBounds(now, user.timezone);
const todayEndExclusive = new Date(todayBounds.end.getTime() + 1);
const [
{ body: snapshot, locale },
modules,
syncRows,
dueReminders,
planRows,
ecgRow,
arrivalRows,
visitRows,
coachAi,
reactionLinesAi,
] = await Promise.all([
readDashboardSnapshotCached(user, undefined, { locale: options.locale }),
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({
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
//
// Read up to the end of the person's local day; the filter below keeps
// what is due now, plus what is due later today when it is a reminder
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
// reminder keeps its due date after it is sent, so the overdue ones
// stay here until they are satisfied, skipped or snoozed.
where: {
userId: user.id,
enabled: true,
deletedAt: null,
origin: { not: "ENCOUNTER" },
nextDueAt: { not: null, lt: todayEndExclusive },
},
select: {
label: true,
origin: true,
intervalDays: true,
rrule: true,
anchorDate: true,
notifyHour: true,
lastSatisfiedAt: true,
createdAt: true,
nextDueAt: 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,
},
}),
// Every PLANNED visit from the start of the local today to the end of the
// two-day horizon. Read straight off the visit table, never off the
// reminder engine — see the mapping below for why the exclusion above
// must stay untouched.
prisma.encounter.findMany({
where: {
userId: user.id,
deletedAt: null,
status: "PLANNED",
occurredAt: {
gte: todayBounds.start,
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
},
},
orderBy: { occurredAt: "asc" },
take: UPCOMING_VISIT_READ_LIMIT,
select: {
id: true,
kind: true,
occurredAt: true,
practitioner: { select: { name: true } },
},
}),
// The check-in opens the Coach, so it asks as the person in front of
// the screen; the reaction line is stored text, shown whenever the
// record's own state allows it, whoever is reading.
aiCapabilityForRecord(user.id, "coach"),
aiCapabilityToServe(user.id, "reactionLines"),
]);
// The snapshot read has already applied the `briefing` capability and
// publishes the state it applied; the fallback only covers an older cached
// body shape and fails closed.
const ai: DailyDigestAi = {
briefing: snapshot.briefingAi ?? {
available: false,
reason: "check_failed",
onDeviceAllowed: false,
},
coach: coachAi,
reactionLines: reactionLinesAi,
};
const score: DailyDigestScore | null = snapshot.healthScore
? {
value: snapshot.healthScore.score,
band: snapshot.healthScore.band,
delta: snapshot.healthScore.delta,
deltaReason: snapshot.healthScore.deltaReason,
scoreVersion: snapshot.healthScore.scoreVersion,
composition: snapshot.healthScore.composition,
configured: snapshot.healthScore.configured,
scoreBasis: snapshot.healthScore.scoreBasis,
}
: 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,
}));
/**
* The booked visits of the local today and the next 48 hours, read from the
* VISIT table.
*
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
* every preventive-care read, which is right — an appointment is not a
* checkup — but a notification about something the app never shows is worse
* than none. So the appointment reaches the rail from the record it lives
* in, and the exclusion above stays exactly as it is. A diff here that
* touches `origin` is the wrong fix.
*
* `occurredAt >= todayStart`, not `> now`: a visit's reminder fires at its
* start time, and a rail that dropped the visit at that very moment showed a
* notification about something the start page no longer mentioned. A visit
* that is still PLANNED stays on the rail until its day ends.
*/
const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({
id: row.id,
kind: row.kind,
occurredAt: row.occurredAt.toISOString(),
practitionerName: row.practitioner?.name ?? null,
dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone),
}));
// 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 while the Coach is available; the builder gates
// the check-in on the same capability, so it never surfaces otherwise.
const coachPlans: DailyDigestCoachPlan[] = coachAi.available
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
: [];
// S12 — the calm reward layer. S11 — the day's elevated-at-rest window,
// computed on demand from raw for TODAY only (read-swap, one bounded
// day-read; never persisted), fault-isolated: a tension-read failure must
// never break the digest (a hot, must-not-fail path), it just omits the calm
// marker. Both are computations over the record's own data, so neither
// depends on the `insights` module, which is the AI analysis opt-out.
//
// 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 { milestone, tensionWindow, sameTime } =
await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
);
// 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: Locale = locale;
const { t } = getServerTranslator(resolvedLocale);
// A Coach-suggested measurement cadence stores its label as a bundle key;
// the checkups page and the dashboard card translate it, and so does this
// line. A free-text label (every other row) is the person's own words.
//
// A reminder that stays due after it is sent (a cycle longer than a week,
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
// whole due day and keeps its place under the cap. A short-cycle one (a
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
// time has come, is not pinned, and its next reading moves it on.
const preventiveDue: DailyDigestPreventiveDue[] = [];
for (const row of dueReminders) {
if (row.nextDueAt === null) continue;
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
let label = row.label;
if (row.origin === "COACH") {
const translated = t(row.label);
if (translated !== row.label) label = translated;
}
preventiveDue.push({ label, staysDue });
}
// 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,
ai,
todayEndExclusive,
modules,
enabledHeroItemKinds:
options.enabledItemKinds ??
snapshot.layout.enabledHeroItemKinds ??
PRIORITY_ITEM_KINDS,
score,
briefing: snapshot.briefing,
medsToday: snapshot.medsToday,
sleepLastSeenDaysAgo,
morningRefreshedToday,
syncIssues,
preventiveDue,
upcomingVisits,
coachPlans,
milestone,
tensionWindow,
sameTime: sameTimeItem,
latestEcg,
todayLocalDate,
dismissedItemKeys,
arrivals: arrivalRows
.map((row) => toDigestArrival(row, reactionLinesAi.available))
.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;
}