mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value system, with no user surface yet: - PriorityItem: the one wire-serialisable model behind every "worth a look" rail item, coach check-in, milestone, and device-verdict pointer. Its Lucide glyph is derived from the closed `kind` enum in the renderer so the model itself stays JSON-serialisable across the API and future native-client boundaries. - PriorityCard: a calm, token-only, reduced-motion-aware card composed entirely from the shipped Card / TileHeader / ProseBlocks primitives — no new visual language, semantic-token status wash only, no markdown. - buildDailyDigest: one pure, deterministic server composer that assembles the day's read from data that already exists — the nightly briefing lifted read-only from the insights cache plus the dashboard-snapshot health score, meds-today, and sleep freshness. It computes no analytics and never reaches an AI provider. It emits the DailyDigest DTO the Today surface, the daily push, and a future iOS widget will all consume. Deterministic item builders cover overdue doses, broken integrations, and due Vorsorge reminders; the push line prefers the cached briefing lead with a deterministic score floor so a keyless self-hoster still gets a first-class line. The read seam GET /api/daily/digest gathers the cached inputs, gates on the insights module (cookie or Bearer; userId from the session), and returns the digest. OpenAPI regenerated; the route is covered by the module gate inventory. Digest strings ship in all six locales.
This commit is contained in:
1 parent
ecc34880ba
commit
ba6c0990bf
18 files changed
+1580
No files matched your search
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import {
|
||||
buildDailyDigest,
|
||||
MAX_WORTH_A_LOOK,
|
||||
type DailyDigestInput,
|
||||
} from "@/lib/daily/digest";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
const NOW = new Date("2026-07-16T09:00:00.000Z");
|
||||
|
||||
function meds(over: Partial<MedsTodayBlock> = {}): MedsTodayBlock {
|
||||
return {
|
||||
activeCount: 0,
|
||||
scheduledToday: 0,
|
||||
takenToday: 0,
|
||||
skippedToday: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const briefing: DailyBriefing = {
|
||||
paragraph:
|
||||
"Your blood pressure is holding steady this week. Sleep dipped slightly last night.",
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "bp",
|
||||
tone: "good",
|
||||
headline: "Blood pressure is holding steady",
|
||||
nudge: "Keep the evening walks going.",
|
||||
delta: null,
|
||||
},
|
||||
],
|
||||
keyFindings: [],
|
||||
};
|
||||
|
||||
function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
return {
|
||||
now: NOW,
|
||||
modules: {},
|
||||
score: { value: 82, band: "good", delta: 3 },
|
||||
briefing,
|
||||
medsToday: meds(),
|
||||
sleepLastSeenDaysAgo: 0,
|
||||
syncIssues: [],
|
||||
preventiveDue: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDailyDigest — composition", () => {
|
||||
it("lifts score, top signal, and briefing lead from cached inputs (no recompute)", () => {
|
||||
const d = buildDailyDigest(input(), t);
|
||||
expect(d.generatedAt).toBe(NOW.toISOString());
|
||||
expect(d.score).toEqual({ value: 82, band: "good", delta: 3 });
|
||||
expect(d.topSignal?.headline).toBe("Blood pressure is holding steady");
|
||||
expect(d.briefingLead).toBe(
|
||||
"Your blood pressure is holding steady this week.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the briefing lead for the push line", () => {
|
||||
const d = buildDailyDigest(input(), t);
|
||||
expect(d.line).toBe("Your blood pressure is holding steady this week.");
|
||||
});
|
||||
|
||||
it("falls back to the top-signal headline when there is no paragraph", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ briefing: { ...briefing, paragraph: "" } }),
|
||||
t,
|
||||
);
|
||||
expect(d.line).toBe("Blood pressure is holding steady");
|
||||
});
|
||||
|
||||
it("falls back to a deterministic score floor when no briefing exists", () => {
|
||||
const d = buildDailyDigest(input({ briefing: null }), t);
|
||||
expect(d.topSignal).toBeNull();
|
||||
expect(d.briefingLead).toBeNull();
|
||||
expect(d.line).toBe("Your health score today is 82.");
|
||||
});
|
||||
|
||||
it("degrades to the honest all-clear line with neither briefing nor score", () => {
|
||||
const d = buildDailyDigest(input({ briefing: null, score: null }), t);
|
||||
expect(d.line).toContain("Nothing needs your attention today");
|
||||
expect(d.score).toBeNull();
|
||||
expect(d.worthALook).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a synchronous pure function — repeated calls are identical", () => {
|
||||
const first = buildDailyDigest(input(), t);
|
||||
const second = buildDailyDigest(input(), t);
|
||||
expect(first).toEqual(second);
|
||||
// No provider/AI dependency: the composer never returns a promise.
|
||||
expect(first).not.toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — freshness (provisional/final)", () => {
|
||||
it("is final when last night's sleep is in", () => {
|
||||
const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: 0 }), t);
|
||||
expect(d.phase).toBe("final");
|
||||
expect(d.sleepPending).toBe(false);
|
||||
});
|
||||
|
||||
it("is provisional when sleep is tracked but last night is not yet in", () => {
|
||||
const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: 2 }), t);
|
||||
expect(d.phase).toBe("provisional");
|
||||
expect(d.sleepPending).toBe(true);
|
||||
});
|
||||
|
||||
it("is provisional when sleep has never been recorded", () => {
|
||||
const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: null }), t);
|
||||
expect(d.sleepPending).toBe(true);
|
||||
});
|
||||
|
||||
it("is final (not pending) when the sleep module is off", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ modules: { sleep: false }, sleepLastSeenDaysAgo: null }),
|
||||
t,
|
||||
);
|
||||
expect(d.phase).toBe("final");
|
||||
expect(d.sleepPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
it("emits a dose-window item when a dose is overdue and medications is on", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({
|
||||
nextDueOverdue: true,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const dose = d.worthALook.find((i) => i.kind === "dose_window");
|
||||
expect(dose).toBeDefined();
|
||||
expect(dose?.status).toBe("warning");
|
||||
expect(dose?.moduleKey).toBe("medications");
|
||||
expect(dose?.title).toBe("Medication due");
|
||||
expect(dose?.body).toBe("Ramipril is due today.");
|
||||
expect(dose?.actions).toHaveLength(1);
|
||||
expect(dose?.actions[0].intent).toBe("dose.log");
|
||||
});
|
||||
|
||||
it("does NOT emit a dose-window item when the medications module is off", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
modules: { medications: false },
|
||||
medsToday: meds({
|
||||
nextDueOverdue: true,
|
||||
nextDueMedicationName: "Ramipril",
|
||||
}),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT emit a dose-window item when nothing is overdue", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ medsToday: meds({ nextDueOverdue: false }) }),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps one sync-issue item per broken integration", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
syncIssues: [
|
||||
{ integration: "withings", state: "error_reauth" },
|
||||
{ integration: "moodlog", state: "parked" },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const sync = d.worthALook.filter((i) => i.kind === "sync_issue");
|
||||
expect(sync).toHaveLength(2);
|
||||
expect(sync[0].status).toBe("warning");
|
||||
expect(sync[0].body).toContain("Withings");
|
||||
expect(sync[0].actions[0].href).toBe("/settings/integrations");
|
||||
});
|
||||
|
||||
it("summarises a single preventive-care item with the label", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ preventiveDue: [{ label: "Blood panel" }] }),
|
||||
t,
|
||||
);
|
||||
const care = d.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.status).toBe("info");
|
||||
expect(care?.body).toBe("Blood panel is due.");
|
||||
});
|
||||
|
||||
it("summarises many preventive-care items into one counted item", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const care = d.worthALook.filter((i) => i.kind === "preventive_care");
|
||||
expect(care).toHaveLength(1);
|
||||
expect(care[0].body).toBe("3 check-ups are due.");
|
||||
});
|
||||
|
||||
it("bounds the rail at three items, never padded", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
syncIssues: [
|
||||
{ integration: "withings", state: "error_reauth" },
|
||||
{ integration: "moodlog", state: "parked" },
|
||||
{ integration: "fitbit", state: "error_reauth" },
|
||||
],
|
||||
preventiveDue: [{ label: "Blood panel" }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.worthALook.length).toBeLessThanOrEqual(MAX_WORTH_A_LOOK);
|
||||
expect(d.worthALook.length).toBe(3);
|
||||
});
|
||||
|
||||
it("returns an empty rail when nothing needs attention", () => {
|
||||
const d = buildDailyDigest(input(), t);
|
||||
expect(d.worthALook).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/** 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
}
|
||||
|
||||
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;
|
||||
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",
|
||||
href: "/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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const sleepEnabled = moduleEnabled(input.modules, "sleep");
|
||||
const sleepPending =
|
||||
sleepEnabled &&
|
||||
(input.sleepLastSeenDaysAgo === null || input.sleepLastSeenDaysAgo >= 1);
|
||||
const phase: DailyDigest["phase"] = sleepPending ? "provisional" : "final";
|
||||
|
||||
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, a preventive check-up least urgent. Bounded to 3.
|
||||
const worthALook: PriorityItem[] = [];
|
||||
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
|
||||
if (dose) worthALook.push(dose);
|
||||
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
|
||||
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
|
||||
if (preventive) worthALook.push(preventive);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: input.now.toISOString(),
|
||||
phase,
|
||||
sleepPending,
|
||||
score: input.score,
|
||||
topSignal,
|
||||
briefingLead,
|
||||
line: composeLine(briefingLead, topSignal, input.score, t),
|
||||
worthALook: worthALook.slice(0, MAX_WORTH_A_LOOK),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 {
|
||||
buildDailyDigest,
|
||||
type DailyDigest,
|
||||
type DailyDigestPreventiveDue,
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSyncIssue,
|
||||
} from "@/lib/daily/digest";
|
||||
|
||||
/** 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;
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
export async function loadDailyDigest(
|
||||
user: User,
|
||||
now: Date = new Date(),
|
||||
): Promise<DailyDigest> {
|
||||
const [{ body: snapshot, locale }, modules, syncRows, dueReminders] =
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
|
||||
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;
|
||||
|
||||
const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({
|
||||
integration: row.integration,
|
||||
state: row.state,
|
||||
}));
|
||||
|
||||
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
const { t } = getServerTranslator(resolveLocale(locale));
|
||||
|
||||
const digest = buildDailyDigest(
|
||||
{
|
||||
now,
|
||||
modules,
|
||||
score,
|
||||
briefing: snapshot.briefing,
|
||||
medsToday: snapshot.medsToday,
|
||||
sleepLastSeenDaysAgo,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return digest;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* P1 — `PriorityItem`, the ONE typed model behind every "worth a look" item.
|
||||
*
|
||||
* A `PriorityItem` is the single rendering unit for every rail entry the
|
||||
* unified daily-value system produces: a coach check-in, a dose-window CTA, a
|
||||
* Vorsorge nudge, a failed-sync retry, a durable milestone, a new-ECG pointer,
|
||||
* and an elevated-at-rest tension window. Later slices (Today rail S2, coach
|
||||
* check-in S3, ECG weave S10, milestones S12, tension S11) each add ONE `kind`
|
||||
* plus a server-side item builder — never a new component. `PriorityCard`
|
||||
* renders it; `buildDailyDigest` emits it.
|
||||
*
|
||||
* The type is deliberately WIRE-SERIALISABLE: the digest DTO crosses the
|
||||
* `GET /api/daily/digest` boundary (and, later, a native-client / iOS-widget
|
||||
* boundary), so an item carries no React component. The card derives its
|
||||
* Lucide glyph deterministically from `kind` (see `KIND_ICON` in
|
||||
* `src/components/daily/priority-card.tsx`) — the icon lives with the
|
||||
* renderer, the closed `kind` enum with the model. This is the one intentional
|
||||
* departure from the Fable §1.2 code block (which sketched `icon: LucideIcon`
|
||||
* inline); a serialisable single-source-of-truth DTO cannot hold a component.
|
||||
*/
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/**
|
||||
* Closed set of rail-item kinds. It GROWS BY PR — a new rail item type is a new
|
||||
* entry here + a new server builder, nothing else. S1 ships builders for
|
||||
* `dose_window`, `preventive_care`, and `sync_issue`; the remaining kinds are
|
||||
* reserved for the slices that own them (coach check-in S3, milestone S12,
|
||||
* new-ECG S10, tension window S11) so their consumers can already type against
|
||||
* the union.
|
||||
*/
|
||||
export const PRIORITY_ITEM_KINDS = [
|
||||
"coach_checkin",
|
||||
"dose_window",
|
||||
"preventive_care",
|
||||
"sync_issue",
|
||||
"milestone",
|
||||
"ecg_new_recording",
|
||||
"tension_window",
|
||||
] as const;
|
||||
|
||||
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
|
||||
|
||||
/**
|
||||
* Semantic status — meaning, not decoration. Maps to a `text-<status>` plus a
|
||||
* `bg-<status>/10` wash in the card (§3 status-colour tier), never raw palette.
|
||||
*/
|
||||
export type PriorityItemStatus = "success" | "warning" | "info" | "destructive";
|
||||
|
||||
/**
|
||||
* One of the 1–3 one-tap actions a card offers. `labelKey` is an i18n key
|
||||
* resolved CLIENT-side (the card owns the user's locale); `intent` is a stable
|
||||
* token the consumer switches on to wire the tap; `href` deep-links when the
|
||||
* action is pure navigation.
|
||||
*/
|
||||
export interface PriorityItemAction {
|
||||
/** i18n key under `daily.action.*`, resolved by `PriorityCard`. */
|
||||
labelKey: string;
|
||||
/** Stable action token (`dose.log`, `sync.reconnect`, …). */
|
||||
intent: string;
|
||||
/** Deep-link target when the action is navigation. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one model every rail consumer reuses. `title` and `body` are already
|
||||
* localised server-side (the builder resolves them through the server
|
||||
* translator); action labels stay as keys for the client.
|
||||
*/
|
||||
export interface PriorityItem {
|
||||
kind: PriorityItemKind;
|
||||
/** i18n-resolved headline (server-side). */
|
||||
title: string;
|
||||
/** Optional grounded one-liner — a plain string rendered via `ProseBlocks`. */
|
||||
body?: string;
|
||||
/** Semantic status wash; omitted for a neutral card. */
|
||||
status?: PriorityItemStatus;
|
||||
/** 1–3 one-tap actions. Never zero on a live rail item, never more than 3. */
|
||||
actions: PriorityItemAction[];
|
||||
/** Provenance of the gate that admitted the item, when module-scoped. */
|
||||
moduleKey?: ModuleKey;
|
||||
}
|
||||
|
||||
/** Hard cap on one-tap actions per card (P1 anatomy). */
|
||||
export const MAX_PRIORITY_ACTIONS = 3;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* OpenAPI route module — the unified daily digest (P3).
|
||||
*
|
||||
* GET /api/daily/digest returns the `DailyDigest` DTO the Today surface, the
|
||||
* daily push, and a future iOS widget all consume. It is a pure read of
|
||||
* already-cached data (nightly briefing lift + dashboard-snapshot ingredients
|
||||
* + two light deterministic reads) — no provider call, no warm-on-mount. Gated
|
||||
* on the `insights` module. Part of the OpenAPI route table; aggregated in
|
||||
* `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
|
||||
|
||||
const priorityItemSchema = z
|
||||
.object({
|
||||
kind: z.enum([...PRIORITY_ITEM_KINDS]).describe("Closed rail-item kind."),
|
||||
title: z.string().describe("Localised headline (resolved server-side)."),
|
||||
body: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Grounded one-liner, rendered as plain text."),
|
||||
status: z
|
||||
.enum(["success", "warning", "info", "destructive"])
|
||||
.optional()
|
||||
.describe("Semantic status wash — meaning, not decoration."),
|
||||
actions: z
|
||||
.array(
|
||||
z.object({
|
||||
labelKey: z.string().describe("i18n key, resolved client-side."),
|
||||
intent: z.string().describe("Stable action token."),
|
||||
href: z.string().optional().describe("Deep-link when navigational."),
|
||||
}),
|
||||
)
|
||||
.max(3)
|
||||
.describe("1–3 one-tap actions."),
|
||||
moduleKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Provenance of the gate that admitted the item."),
|
||||
})
|
||||
.meta({
|
||||
id: "DailyPriorityItem",
|
||||
description:
|
||||
"One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.",
|
||||
});
|
||||
|
||||
const dailyDigestResponse = z
|
||||
.object({
|
||||
generatedAt: z.string().describe("ISO-8601 instant the digest was read."),
|
||||
phase: z
|
||||
.enum(["provisional", "final"])
|
||||
.describe("Freshness lifecycle — 'final' once last night's sleep is in."),
|
||||
sleepPending: z
|
||||
.boolean()
|
||||
.describe("Sleep tracked but last night not yet recorded."),
|
||||
score: z
|
||||
.object({
|
||||
value: z.number(),
|
||||
band: z.string(),
|
||||
delta: z.number().nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.describe("Health score + band + week-over-week delta; null when none."),
|
||||
topSignal: z
|
||||
.object({
|
||||
sourceMetric: z.string(),
|
||||
tone: z.enum(["good", "watch", "info"]),
|
||||
headline: z.string(),
|
||||
nudge: z.string(),
|
||||
delta: z.string().nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Clinical-priority top signal, lifted from the cached briefing.",
|
||||
),
|
||||
briefingLead: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("First sentence of the cached briefing paragraph."),
|
||||
line: z
|
||||
.string()
|
||||
.describe(
|
||||
"Push / lock-screen line: cached-AI lead with a deterministic floor.",
|
||||
),
|
||||
worthALook: z
|
||||
.array(priorityItemSchema)
|
||||
.describe("Bounded 0–3 rail items, never padded."),
|
||||
})
|
||||
.meta({
|
||||
id: "DailyDigest",
|
||||
description:
|
||||
"The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget.",
|
||||
});
|
||||
|
||||
export const dailyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/daily/digest": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "The unified daily digest",
|
||||
description:
|
||||
"Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. Requires the insights module. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The daily digest.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(dailyDigestResponse, "DailyDigestEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./coach";
|
||||
import { consentPaths } from "./consent";
|
||||
import { customMetricPaths } from "./custom-metrics";
|
||||
import { dailyPaths } from "./daily";
|
||||
import { cyclePaths } from "./cycle";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { inboundDocumentPaths } from "./documents";
|
||||
@@ -101,6 +102,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
// v1.28 — HealthKit integration config (appended; spread order is
|
||||
// load-bearing).
|
||||
...integrationPaths,
|
||||
// v1.28 — the unified daily digest (P3 spine; appended, spread order is
|
||||
// load-bearing).
|
||||
...dailyPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
Reference in new issue
Block a user