Files
HealthLog/src/lib/daily/digest.ts
T
Marc-André Bombeck 8dd04e0170 fix(ux): close medium UX-audit findings across flows, IA, and onboarding
Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
2026-07-18 05:06:24 +02:00

662 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* P3 — `buildDailyDigest`, the ONE data spine of the daily-value system.
*
* A pure, deterministic composer that assembles the day's read from data that
* ALREADY EXISTS — the nightly `insight-pregenerate` output lifted read-only
* from `User.insightsCachedText`, the dashboard-snapshot ingredients (health
* score, meds-today, sleep last-seen), plus two light deterministic reads
* (integration status, due Vorsorge reminders). It computes no analytics and
* NEVER triggers an AI/provider call — the warm-on-mount ban extends here. The
* IO that gathers its input lives in `./load-digest.ts`; this module is the
* tested spine.
*
* The emitted `DailyDigest` is the single source of truth every later consumer
* reads: the Today surface (S2), the daily push line (S5), and a future iOS
* widget — none recompute, none fork a second digest path.
*
* Freshness (§2.4): S1 derives `phase` / `sleepPending` DETERMINISTICALLY from
* whether last night's sleep is already in the record. The event-driven
* provisional→final refresh (sleep-arrival debounce) is S4's work; it will
* populate the same two fields the DTO already carries, so no consumer changes.
*/
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import {
MAX_PRIORITY_ACTIONS,
type PriorityItem,
} from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import {
milestoneCopy,
milestoneHref,
type Milestone,
} from "@/lib/daily/milestones";
import {
COACH_CHECKIN_REVIEW_DAYS,
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
COACH_CHECKIN_ADJUST_INTENT,
} from "@/lib/daily/coach-checkin-intents";
/** At most three rail items — a glance, never a wall (§2.5, never padded). */
export const MAX_WORTH_A_LOOK = 3;
/** Trim a briefing-lead sentence to a lock-screen-friendly length. */
const MAX_LINE_LENGTH = 160;
const MS_PER_DAY = 86_400_000;
/**
* S3 — coach check-in loop (§2.3). Days added to a plan's activation when the
* coach set no review date: every accepted plan earns a check-in, not only the
* ones the coach explicitly dated. The PATCH-to-`active` route defaults
* `reviewDate` to `+COACH_CHECKIN_REVIEW_DAYS`; this constant is the one source.
*/
// COACH_CHECKIN_REVIEW_DAYS lives in ./coach-checkin-intents (client-safe).
/**
* After a check-in comes due, it resurfaces for at most this many days before
* it stops appearing on its own — the calm inversion of a streak (§2.3.3): an
* ignored check-in sits quiet after ~two cycles rather than nagging forever,
* and the plan's status is NEVER changed behind the user's back. Only an
* explicit keep / let-go moves the plan; silence just retires the card.
*/
export const COACH_CHECKIN_RESURFACE_DAYS = 14;
/**
* Closed allowlist of the check-in card's two MUTATING intents (§2.3.2). The
* generic, id-less `PriorityCard` forwards only a single `intent` string, so
* the target plan id is appended after the ":" — the Today handler recovers it
* and PATCHes the existing plan-lifecycle route. "Adjust" is navigation (an
* `href` into the coach), so it carries no plan id and never mutates here.
*/
// The check-in intents live in ./coach-checkin-intents (client-safe) and are
// imported above, so the client Today surface can use them without pulling this
// server-side builder into its bundle.
type Translate = ServerTranslator["t"];
/** Module map in the registry's DISABLED-allowlist shape (missing = enabled). */
export type DigestModuleMap = Partial<Record<ModuleKey, boolean>>;
function moduleEnabled(map: DigestModuleMap, key: ModuleKey): boolean {
return map[key] !== false;
}
export interface DailyDigestScore {
value: number;
band: string;
delta: number | null;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
export interface DailyDigestSyncIssue {
/** Integration token (`withings`, `moodlog`, …). */
integration: string;
/** The failure state the row carries (`error_reauth`, `disconnected`, …). */
state: string;
}
/** A Vorsorge / measurement reminder whose next-due instant has passed. */
export interface DailyDigestPreventiveDue {
label: string;
}
/**
* S11 — a confident elevated-at-rest ("tension") window for the day, already
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
* The builder only formats it; the signal correctness lives in the analytics
* layer, and a null here means the honest "no window" — nothing is emitted.
*/
export interface DailyDigestTensionWindow {
/** Part of day the window's midpoint fell in — drives the cautious copy. */
partOfDay: "morning" | "afternoon" | "evening" | "night";
}
/**
* S10 — the latest ECG recording, for the `ecg_new_recording` rail item. Only
* ever the DEVICE's verdict + when it was recorded — NEVER the waveform (the
* DTO carries no samples). The builder decides "new" from `recordedAt`; the
* card attributes any verdict to the recording device.
*/
export interface DailyDigestEcg {
recordedAt: Date;
/** The recording device's OWN verdict, or null when unclassified. */
deviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
}
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
* needs a fresh AI call, reading only the existing plan lifecycle. `planText`
* is the plan's own if→then prose (decrypted fault-isolated in the IO seam, or
* null when its key rotated out) so the card can echo the user's own words.
*/
export interface DailyDigestCoachPlan {
id: string;
/** Lifecycle status — only `active` / `reviewed` reach the builder. */
status: string;
/** The coach-pinned review checkpoint, or null (then defaulted, see below). */
reviewDate: Date | null;
createdAt: Date;
updatedAt: Date;
/** Decrypted "if cue → then action" prose, or null when undecryptable. */
planText: string | null;
}
/** The fully-resolved, IO-free input the composer folds into a digest. */
export interface DailyDigestInput {
now: Date;
modules: DigestModuleMap;
score: DailyDigestScore | null;
/** The cached daily briefing (paragraph + signals-of-day), or null. */
briefing: DailyBriefing | null;
medsToday: MedsTodayBlock;
/** Days since the freshest sleep reading; null when never recorded. */
sleepLastSeenDaysAgo: number | null;
/**
* S4 freshness (§E) — whether the event-driven morning refresh has already
* run for the user's CURRENT local date. Derived in the IO seam by comparing
* `User.morningDigestRefreshedOn` against today's local date (profile tz):
* `true` once last night's sleep arrived AND the sleep-dependent generation
* was re-run with it. This is the authoritative `final` signal — it flips
* immediately on the refresh, before the snapshot cache (which feeds
* `sleepLastSeenDaysAgo`) has expired.
*/
morningRefreshedToday: boolean;
syncIssues: DailyDigestSyncIssue[];
preventiveDue: DailyDigestPreventiveDue[];
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
coachPlans: DailyDigestCoachPlan[];
/**
* S12 — the single freshly-reached durable milestone for today, or null. The
* IO seam gathers candidates from the existing streak / personal-record
* engines and applies the reached-once gate (`selectFreshMilestone`), so the
* builder only ever sees a milestone worth celebrating today.
*/
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
/**
* S10 — the freshest ECG recording (device verdict + recordedAt only), or
* null. Optional so consumers that predate the ECG weave stay valid; the
* builder treats a missing value as "no recent recording".
*/
latestEcg?: DailyDigestEcg | null;
/**
* The caller's local calendar day (profile tz), used ONLY to stamp the
* tension-window item's dismiss key (`tensionWindowItemKey`) — the same day
* space `loadIntradayPulse` already computed the window against.
*/
todayLocalDate: string;
/**
* Item keys the user has already dismissed (`DismissedPriorityItem`),
* scoped to today's OWN candidate keys by the IO seam — never a full
* history read. A dismissed item is dropped from `worthALook` entirely,
* never rendered with its actions suppressed.
*/
dismissedItemKeys: ReadonlySet<string>;
}
export interface DailyDigest {
generatedAt: string;
/** §2.4 freshness lifecycle — `final` once last night's sleep is in. */
phase: "provisional" | "final";
/** Honest-degradation flag: sleep enabled but last night not yet in. */
sleepPending: boolean;
score: DailyDigestScore | null;
/**
* The clinical-priority top signal, lifted from the cached briefing's
* `signalsOfDay[0]`. Typed as the CACHED `DailyBriefingSignal` (not the raw
* `SignalOfDay`): only the grounded, already-localised projection is
* persisted — re-deriving the raw signal would mean a fresh measurement
* fan-out on every read, exactly the warm-on-mount load the plan forbids.
*/
topSignal: DailyBriefingSignal | null;
/** First sentence of the cached briefing paragraph, read-only. */
briefingLead: string | null;
/** The push / lock-screen line (cached-AI lead with a deterministic floor). */
line: string;
/** Bounded 0–3 rail items, never padded. */
worthALook: PriorityItem[];
}
/** First sentence of a paragraph, trimmed; null when empty. */
function firstSentence(paragraph: string | null | undefined): string | null {
if (!paragraph) return null;
const trimmed = paragraph.trim();
if (!trimmed) return null;
const match = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/);
let sentence = (match ? match[0] : trimmed).trim();
if (sentence.length > MAX_LINE_LENGTH) {
sentence = `${sentence.slice(0, MAX_LINE_LENGTH - 1).trimEnd()}…`;
}
return sentence.length > 0 ? sentence : null;
}
/** Title-case a lowercase integration token for user-facing copy. */
function integrationLabel(token: string): string {
if (!token) return token;
return token.charAt(0).toUpperCase() + token.slice(1);
}
/**
* Dose-window item — fires when an active-medication dose is open and overdue.
* Gated on the `medications` module; carries a single log-dose action.
*/
function buildDoseWindowItem(
meds: MedsTodayBlock,
modules: DigestModuleMap,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "medications")) return null;
if (!meds.nextDueOverdue) return null;
const name = meds.nextDueMedicationName;
const id = meds.nextDueMedicationId;
return {
kind: "dose_window",
title: t("daily.item.doseWindow.title"),
body: name
? t("daily.item.doseWindow.bodyNamed", { name })
: t("daily.item.doseWindow.body"),
status: "warning",
actions: [
{
labelKey: "daily.action.logDose",
intent: "dose.log",
// Deep-link straight to the overdue medication's card so the tap
// lands on the right one instead of a generic list the user then
// has to scan (the id is known server-side whenever the name is).
// The bare list stays the honest fallback for an older cached
// block that predates the id field.
href: id ? `/medications?highlight=${id}` : "/medications",
},
],
moduleKey: "medications",
};
}
/** One rail item per broken integration — reconnect to keep data current. */
function buildSyncIssueItems(
issues: DailyDigestSyncIssue[],
t: Translate,
): PriorityItem[] {
return issues.map((issue) => ({
kind: "sync_issue" as const,
title: t("daily.item.syncIssue.title"),
body: t("daily.item.syncIssue.body", {
integration: integrationLabel(issue.integration),
}),
status: "warning" as const,
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
}));
}
/** A single preventive-care item summarising the due Vorsorge reminders. */
function buildPreventiveCareItem(
due: DailyDigestPreventiveDue[],
t: Translate,
): PriorityItem | null {
if (due.length === 0) return null;
const body =
due.length === 1
? t("daily.item.preventiveCare.body", { label: due[0].label })
: t("daily.item.preventiveCare.bodyMany", { count: due.length });
return {
kind: "preventive_care",
title: t("daily.item.preventiveCare.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
};
}
/**
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
* state was reached TODAY (the IO seam already applied the reached-once gate).
* Gated on the `insights` module — the daily narrative layer that hosts it.
* Celebratory-but-quiet: `success` status, a single "view" action into the
* metric's insight, and copy that marks arrival at a state, never a maintained
* count. Shown the day reached and never again — never a "you broke it" note.
*/
function buildMilestoneItem(
milestone: Milestone | null | undefined,
modules: DigestModuleMap,
t: Translate,
): PriorityItem | null {
if (!milestone) return null;
if (!moduleEnabled(modules, "insights")) return null;
const { title, body } = milestoneCopy(milestone, t);
return {
kind: "milestone",
itemKey: milestoneItemKey(milestone),
title,
body,
status: "success",
actions: [
{
labelKey: "daily.action.viewMilestone",
intent: "milestone.view",
href: milestoneHref(milestone),
},
],
moduleKey: "insights",
};
}
/**
* S11 — the elevated-at-rest ("tension") card. Emitted at most once per day
* (the window is already the day's single most confident stretch), gated on
* the `insights` module and on a non-null window (the analytics layer stays
* silent unless every confidence gate holds). Cautious, non-diagnostic copy:
* "possible tension", never a clinical stress verdict. The one action deep-
* links into the pulse insight where the intraday shape is charted.
*/
function buildTensionWindowItem(
window: DailyDigestTensionWindow | null,
modules: DigestModuleMap,
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!window) return null;
return {
kind: "tension_window",
itemKey: tensionWindowItemKey(todayLocalDate, window.partOfDay),
title: t("daily.item.tensionWindow.title"),
body: t(`daily.item.tensionWindow.body.${window.partOfDay}`),
status: "info",
actions: [
{
labelKey: "daily.action.viewPulse",
intent: "pulse.view",
href: "/insights/pulse",
},
],
moduleKey: "insights",
};
}
/**
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
* when the read-back fired, so its `updatedAt` (the flip moment) is when the
* check-in came due. A legacy `active` plan the coach never dated defaults to
* activation + `COACH_CHECKIN_REVIEW_DAYS` — the same rule the PATCH route now
* writes forward, applied read-side for plans that predate it.
*/
function checkinDueAt(plan: DailyDigestCoachPlan): number {
if (plan.reviewDate) return plan.reviewDate.getTime();
if (plan.status === "reviewed") return plan.updatedAt.getTime();
return plan.createdAt.getTime() + COACH_CHECKIN_REVIEW_DAYS * MS_PER_DAY;
}
/**
* A plan is check-in-due when its due instant has passed AND it is still within
* the resurface window. Past the window the card retires quietly — the plan's
* status is untouched (never abandoned behind the user's back); a keep re-arms
* it, a let-go ends it, silence just stops the card.
*/
function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
const dueAt = checkinDueAt(plan);
if (dueAt > now) return false;
return now - dueAt <= COACH_CHECKIN_RESURFACE_DAYS * MS_PER_DAY;
}
/**
* The one coach check-in card (§2.3). Gated on the `coach` module. Capped at
* ONE per day across every plan: the earliest-due check-in wins, the rest wait
* for a following day. Reads existing plan state only — never a fresh AI call.
* Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust
* (navigate into the coach), let go (guilt-free retirement).
*/
function buildCoachCheckinItem(
plans: DailyDigestCoachPlan[],
modules: DigestModuleMap,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "coach")) return null;
const nowMs = now.getTime();
const due = plans
.filter((p) => isCheckinDue(p, nowMs))
.sort((a, b) => {
const byDue = checkinDueAt(a) - checkinDueAt(b);
if (byDue !== 0) return byDue;
// Deterministic tie-break so the same day always surfaces the same card.
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const plan = due[0];
if (!plan) return null;
const body = plan.planText
? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.body");
// 2026-07-17 UX-flows audit F1-2 — "Adjust" used to be a bare `/coach`
// link carrying no plan context: keep/let-go both thread the plan id
// into their intent, but adjust dropped the user into an empty new chat
// with no memory of which plan prompted the tap. The coach route's
// full-page surface now reads `?ask=` and seeds it as the composer
// prefill for a fresh conversation (`CoachConversation`'s `prefill` prop,
// same mechanism the drawer's suggested-prompt chips use) — the user
// lands with the plan already named instead of a blank composer.
const adjustPrompt = plan.planText
? t("daily.item.coachCheckin.adjustPromptPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.adjustPrompt");
return {
kind: "coach_checkin",
title: t("daily.item.coachCheckin.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: `${COACH_CHECKIN_KEEP_INTENT}:${plan.id}`,
},
{
labelKey: "daily.action.checkinAdjust",
intent: COACH_CHECKIN_ADJUST_INTENT,
href: `/coach?ask=${encodeURIComponent(adjustPrompt)}`,
},
{
labelKey: "daily.action.checkinLetGo",
intent: `${COACH_CHECKIN_LETGO_INTENT}:${plan.id}`,
},
],
moduleKey: "coach",
};
}
/**
* S10 — how recently an ECG recording must have landed to count as "new" for
* the rail (§3.5.3). A calendar-day window: a recording synced within the last
* day surfaces once, then retires on its own (no persisted "seen" marker, no
* migration). The 24h window is itself the one/day cap — at most one recording
* is the freshest, and it drops off the rail after a day.
*/
const ECG_NEW_WINDOW_MS = MS_PER_DAY;
/** Device verdict → the calm, device-attributed verdict key for the card body. */
const ECG_VERDICT_KEYS: Record<
NonNullable<DailyDigestEcg["deviceVerdict"]>,
string
> = {
IRREGULAR: "daily.item.ecgNewRecording.verdict.irregular",
NOT_DETECTED: "daily.item.ecgNewRecording.verdict.notDetected",
INCONCLUSIVE: "daily.item.ecgNewRecording.verdict.inconclusive",
};
/**
* The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
* (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
* within the last day — a calm "a new ECG recording is ready to view" pointer
* into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
* verdict, attributed to the device; HealthLog never interprets the trace (the
* DTO carries no waveform). The 24h window caps it at one/day and retires it on
* its own without a persisted seen-marker.
*/
function buildEcgNewRecordingItem(
ecg: DailyDigestEcg | null | undefined,
modules: DigestModuleMap,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!ecg) return null;
const age = now.getTime() - ecg.recordedAt.getTime();
// Skip a future-dated row (clock skew) and anything older than the window.
if (age < 0 || age > ECG_NEW_WINDOW_MS) return null;
const verdictKey = ecg.deviceVerdict
? ECG_VERDICT_KEYS[ecg.deviceVerdict]
: null;
const body = verdictKey
? t("daily.item.ecgNewRecording.bodyVerdict", { verdict: t(verdictKey) })
: t("daily.item.ecgNewRecording.body");
return {
kind: "ecg_new_recording",
itemKey: ecgItemKey(ecg.recordedAt),
title: t("daily.item.ecgNewRecording.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.viewEcg",
intent: "ecg.view",
href: "/insights#ecg",
},
],
moduleKey: "insights",
};
}
/**
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
* back to the top signal's headline, then a deterministic score floor, then
* the honest all-clear. NEVER a fresh AI call — every branch reads cache or a
* deterministic string, so a keyless self-hoster still gets a first-class line.
*/
function composeLine(
briefingLead: string | null,
topSignal: DailyBriefingSignal | null,
score: DailyDigestScore | null,
t: Translate,
): string {
if (briefingLead) return briefingLead;
if (topSignal?.headline) return topSignal.headline.trim();
if (score) return t("daily.line.score", { score: score.value });
return t("daily.line.allClear");
}
export function buildDailyDigest(
input: DailyDigestInput,
t: Translate,
): DailyDigest {
// §E freshness lifecycle. The day is `final` once ANY of:
// - sleep is disabled (nothing to wait for);
// - the event-driven morning refresh has run for today (authoritative,
// fast — flips the instant the sleep-arrival job stamps the marker);
// - last night's sleep is already visibly in the record (`daysAgo === 0`) —
// the eventual-consistency backstop for when the refresh job never ran
// (no boss worker / keyless self-hoster) but the snapshot has caught up.
// Otherwise it stays `provisional`, and `sleepPending` drives the honest
// "last night's sleep not yet in" note.
const sleepEnabled = moduleEnabled(input.modules, "sleep");
const lastNightSleepIn = input.sleepLastSeenDaysAgo === 0;
const isFinal =
!sleepEnabled || input.morningRefreshedToday || lastNightSleepIn;
const sleepPending = sleepEnabled && !isFinal;
const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional";
const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;
const briefingLead = firstSentence(input.briefing?.paragraph);
// Priority order: an overdue dose is the most time-sensitive daily action, a
// broken sync next, then the calm coach check-in, a preventive check-up
// least urgent. Bounded to 3 — a check-in the cap crowds out resurfaces on a
// following day (within its window), never lost.
const worthALook: PriorityItem[] = [];
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
if (dose) worthALook.push(dose);
// A freshly-reached milestone is rare and one-per-day — surface it ahead of
// the ambient sync / check-in items so the calm reward is not buried, but
// below an overdue dose (the one genuinely time-critical daily action).
const milestone = buildMilestoneItem(input.milestone, input.modules, t);
if (milestone) worthALook.push(milestone);
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
const checkin = buildCoachCheckinItem(
input.coachPlans,
input.modules,
input.now,
t,
);
if (checkin) worthALook.push(checkin);
const ecg = buildEcgNewRecordingItem(
input.latestEcg,
input.modules,
input.now,
t,
);
if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
// bounded rail ahead of it.
const tension = buildTensionWindowItem(
input.tensionWindow,
input.modules,
input.todayLocalDate,
t,
);
if (tension) worthALook.push(tension);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
item.actions = item.actions.slice(0, MAX_PRIORITY_ACTIONS);
}
// Drop anything the user already dismissed (observational kinds only — an
// actionable item never carries an `itemKey`, so it can never match here).
// Filtered BEFORE the bounded slice, so a dismissal reliably makes room for
// the next candidate rather than leaving a gap.
const visible =
input.dismissedItemKeys.size === 0
? worthALook
: worthALook.filter(
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
);
return {
generatedAt: input.now.toISOString(),
phase,
sleepPending,
score: input.score,
topSignal,
briefingLead,
line: composeLine(briefingLead, topSignal, input.score, t),
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
};
}