mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
/**
|
||
* 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),
|
||
};
|
||
}
|