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:
Marc-André Bombeck committed 2026-07-16 19:13:01 +02:00
1 parent ecc34880ba
commit ba6c0990bf
18 files changed
+1580

No files matched your search

+182
View File
@@ -10706,6 +10706,26 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/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:
$ref: "#/components/schemas/DailyDigestEnvelope"
"401": *a1
"403": *a5
"422": *a3
"429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -29772,6 +29792,168 @@ components:
- data
- error
additionalProperties: false
DailyDigestEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/DailyDigest"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DailyDigest:
type: object
properties:
generatedAt:
type: string
description: ISO-8601 instant the digest was read.
phase:
type: string
enum:
- provisional
- final
description: Freshness lifecycle — 'final' once last night's sleep is in.
sleepPending:
type: boolean
description: Sleep tracked but last night not yet recorded.
score:
anyOf:
- type: object
properties:
value:
type: number
band:
type: string
delta:
anyOf:
- type: number
- type: "null"
required:
- value
- band
- delta
additionalProperties: false
- type: "null"
description: Health score + band + week-over-week delta; null when none.
topSignal:
anyOf:
- type: object
properties:
sourceMetric:
type: string
tone:
type: string
enum:
- good
- watch
- info
headline:
type: string
nudge:
type: string
delta:
anyOf:
- type: string
- type: "null"
required:
- sourceMetric
- tone
- headline
- nudge
- delta
additionalProperties: false
- type: "null"
description: Clinical-priority top signal, lifted from the cached briefing.
briefingLead:
anyOf:
- type: string
- type: "null"
description: First sentence of the cached briefing paragraph.
line:
type: string
description: "Push / lock-screen line: cached-AI lead with a deterministic floor."
worthALook:
type: array
items:
$ref: "#/components/schemas/DailyPriorityItem"
description: Bounded 0–3 rail items, never padded.
required:
- generatedAt
- phase
- sleepPending
- score
- topSignal
- briefingLead
- line
- worthALook
additionalProperties: false
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.
DailyPriorityItem:
type: object
properties:
kind:
type: string
enum:
- coach_checkin
- dose_window
- preventive_care
- sync_issue
- milestone
- ecg_new_recording
- tension_window
description: Closed rail-item kind.
title:
type: string
description: Localised headline (resolved server-side).
body:
description: Grounded one-liner, rendered as plain text.
type: string
status:
description: Semantic status wash — meaning, not decoration.
type: string
enum:
- success
- warning
- info
- destructive
actions:
maxItems: 3
type: array
items:
type: object
properties:
labelKey:
type: string
description: i18n key, resolved client-side.
intent:
type: string
description: Stable action token.
href:
description: Deep-link when navigational.
type: string
required:
- labelKey
- intent
additionalProperties: false
description: 1–3 one-tap actions.
moduleKey:
description: Provenance of the gate that admitted the item.
type: string
required:
- kind
- title
- actions
additionalProperties: false
description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.
MedicationScheduleOutput:
type: object
properties:
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Wasser",
"caffeine": "Koffein"
}
},
"daily": {
"line": {
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
"allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal."
},
"item": {
"doseWindow": {
"title": "Medikament fällig",
"body": "Heute ist eine Dosis fällig.",
"bodyNamed": "{name} ist heute fällig."
},
"preventiveCare": {
"title": "Vorsorge fällig",
"body": "{label} ist fällig.",
"bodyMany": "{count} Vorsorgetermine sind fällig."
},
"syncIssue": {
"title": "Synchronisierung braucht Aufmerksamkeit",
"body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten."
}
},
"action": {
"logDose": "Dosis erfassen",
"reconnect": "Neu verbinden",
"viewCheckups": "Vorsorge ansehen"
}
}
}
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Water",
"caffeine": "Caffeine"
}
},
"daily": {
"line": {
"score": "Your health score today is {score}.",
"allClear": "Nothing needs your attention today — everything's tracking normally."
},
"item": {
"doseWindow": {
"title": "Medication due",
"body": "A dose is due today.",
"bodyNamed": "{name} is due today."
},
"preventiveCare": {
"title": "Check-up due",
"body": "{label} is due.",
"bodyMany": "{count} check-ups are due."
},
"syncIssue": {
"title": "Sync needs attention",
"body": "{integration} isn't syncing — reconnect to keep your data current."
}
},
"action": {
"logDose": "Log dose",
"reconnect": "Reconnect",
"viewCheckups": "View check-ups"
}
}
}
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Agua",
"caffeine": "Cafeína"
}
},
"daily": {
"line": {
"score": "Tu puntuación de salud hoy es {score}.",
"allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad."
},
"item": {
"doseWindow": {
"title": "Medicación pendiente",
"body": "Hoy toca una dosis.",
"bodyNamed": "{name} toca hoy."
},
"preventiveCare": {
"title": "Revisión pendiente",
"body": "{label} está pendiente.",
"bodyMany": "Hay {count} revisiones pendientes."
},
"syncIssue": {
"title": "La sincronización necesita atención",
"body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día."
}
},
"action": {
"logDose": "Registrar dosis",
"reconnect": "Reconectar",
"viewCheckups": "Ver revisiones"
}
}
}
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Eau",
"caffeine": "Caféine"
}
},
"daily": {
"line": {
"score": "Ton score de santé aujourd'hui est de {score}.",
"allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement."
},
"item": {
"doseWindow": {
"title": "Médicament à prendre",
"body": "Une dose est prévue aujourd'hui.",
"bodyNamed": "{name} est prévu aujourd'hui."
},
"preventiveCare": {
"title": "Examen à faire",
"body": "{label} est à faire.",
"bodyMany": "{count} examens sont à faire."
},
"syncIssue": {
"title": "La synchronisation demande votre attention",
"body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour."
}
},
"action": {
"logDose": "Enregistrer la dose",
"reconnect": "Reconnecter",
"viewCheckups": "Voir les examens"
}
}
}
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Acqua",
"caffeine": "Caffeina"
}
},
"daily": {
"line": {
"score": "Il tuo punteggio di salute oggi è {score}.",
"allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente."
},
"item": {
"doseWindow": {
"title": "Farmaco da assumere",
"body": "Oggi è prevista una dose.",
"bodyNamed": "{name} è previsto oggi."
},
"preventiveCare": {
"title": "Controllo da fare",
"body": "{label} è da fare.",
"bodyMany": "Ci sono {count} controlli da fare."
},
"syncIssue": {
"title": "La sincronizzazione richiede attenzione",
"body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati."
}
},
"action": {
"logDose": "Registra dose",
"reconnect": "Riconnetti",
"viewCheckups": "Vedi controlli"
}
}
}
+27
View File
@@ -8530,5 +8530,32 @@
"water": "Woda",
"caffeine": "Kofeina"
}
},
"daily": {
"line": {
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
"allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie."
},
"item": {
"doseWindow": {
"title": "Lek do przyjęcia",
"body": "Dziś przypada dawka.",
"bodyNamed": "{name} przypada dziś."
},
"preventiveCare": {
"title": "Badanie do wykonania",
"body": "{label} jest do wykonania.",
"bodyMany": "Do wykonania jest {count} badań."
},
"syncIssue": {
"title": "Synchronizacja wymaga uwagi",
"body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne."
}
},
"action": {
"logDose": "Zapisz dawkę",
"reconnect": "Połącz ponownie",
"viewCheckups": "Zobacz badania"
}
}
}
@@ -111,6 +111,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
// is the AI-narrative daily layer, so it gates on the `insights` module via
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
// token when the account turned insights off.
"src/app/api/daily",
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
// mental-health posture): both the batch ingest and the window-summary
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { apiError } from "@/lib/api-response";
import type { DailyDigest } from "@/lib/daily/digest";
/**
* `GET /api/daily/digest` — the P3 read seam.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off (even for a valid
* session), and the happy path returns the `DailyDigest` DTO shape. The digest
* itself is composed by `loadDailyDigest`, mocked here — the route must reach
* no provider (there is nothing AI-shaped in this module graph).
*/
vi.mock("@/lib/db", () => ({
prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) } },
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
vi.mock("@/lib/daily/load-digest", () => ({ loadDailyDigest: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { loadDailyDigest } from "@/lib/daily/load-digest";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
locale: "en",
},
};
const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
worthALook: [],
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(new URL("http://localhost/api/daily/digest"));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(loadDailyDigest).mockResolvedValue(DIGEST);
});
describe("GET /api/daily/digest", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("gates on the insights module key", async () => {
await callGet(makeReq());
expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith(
"user-1",
"insights",
);
});
it("returns 200 with the DailyDigest DTO on the happy path", async () => {
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toEqual(DIGEST);
expect(vi.mocked(loadDailyDigest)).toHaveBeenCalledOnce();
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* GET /api/daily/digest
*
* The read seam for the unified daily-value system (P3). Returns the
* `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget
* all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the
* nightly briefing lifted read-only from `User.insightsCachedText`, the
* dashboard-snapshot health score / meds-today / sleep freshness) plus two
* light deterministic reads (broken integrations, overdue Vorsorge). No
* provider call is reachable from this path, and nothing warms on mount.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module (the
* daily digest is the AI-narrative daily layer); a disabled account gets the
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
* data-tile inputs inherit their own module gates via the snapshot builder.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { loadDailyDigest } from "@/lib/daily/load-digest";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const digest = await loadDailyDigest(user);
const response = apiSuccess(digest);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { PriorityCard } from "../priority-card";
import {
PRIORITY_ITEM_KINDS,
type PriorityItem,
type PriorityItemKind,
} from "@/lib/daily/priority-item";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
function item(
over: Partial<PriorityItem> & { kind: PriorityItemKind },
): PriorityItem {
return {
title: "Something to look at",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
...over,
};
}
describe("<PriorityCard>", () => {
it("renders every kind of the discriminated union without throwing", () => {
for (const kind of PRIORITY_ITEM_KINDS) {
const html = render(<PriorityCard item={item({ kind })} />);
expect(html).toContain(`data-kind="${kind}"`);
expect(html).toContain("Something to look at");
}
});
it("renders the grounded body one-liner", () => {
const html = render(
<PriorityCard
item={item({ kind: "dose_window", body: "Ramipril is due today." })}
/>,
);
expect(html).toContain("Ramipril is due today.");
});
it("applies a semantic status wash (meaning, not decoration)", () => {
const warn = render(
<PriorityCard item={item({ kind: "sync_issue", status: "warning" })} />,
);
expect(warn).toContain("bg-warning/10");
const info = render(
<PriorityCard item={item({ kind: "preventive_care", status: "info" })} />,
);
expect(info).toContain("bg-info/10");
});
it("renders a navigation action as a link carrying the href", () => {
const html = render(
<PriorityCard
item={item({
kind: "sync_issue",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
})}
/>,
);
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain("Reconnect");
});
it("renders a non-navigation action as a button (no href)", () => {
const html = render(
<PriorityCard
item={item({
kind: "coach_checkin",
actions: [{ labelKey: "daily.action.logDose", intent: "dose.log" }],
})}
/>,
);
expect(html).toContain("<button");
expect(html).toContain("Log dose");
});
it("resolves action label keys through the active locale", () => {
const html = render(
<PriorityCard
item={item({
kind: "preventive_care",
actions: [
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
})}
/>,
);
expect(html).toContain("View check-ups");
});
});
+152
View File
@@ -0,0 +1,152 @@
"use client";
/**
* P1 — `PriorityCard`, the ONE card behind every "worth a look" rail item.
*
* It renders a `PriorityItem` and never fetches. Every later consumer reuses
* it verbatim: the Today rail (S2), a coach check-in (S3), a milestone
* celebration (S12), a new-ECG pointer (S10), a tension window (S11). A new
* rail type is a new `kind` on the item + a server builder — never a new
* component (§1.2 P1).
*
* Design: composed ENTIRELY from shipped primitives — `Card` (compact
* density) + `TileHeader` (size `sm`, foreground icon) + `ProseBlocks` for the
* grounded one-liner, semantic-token status wash only, no raw palette colour
* (the `no-raw-palette-color` rule errors), no markdown. The Lucide glyph is
* derived from `kind` so the wire DTO stays serialisable (the icon lives with
* the renderer, the closed enum with the model). The `animate-insight-in`
* reveal carries its own reduced-motion fallback in `globals.css`.
*/
import Link from "next/link";
import {
Activity,
Award,
CalendarClock,
HeartPulse,
MessageCircle,
Pill,
RefreshCw,
type LucideIcon,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { Button, buttonVariants } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
PriorityItem,
PriorityItemAction,
PriorityItemKind,
PriorityItemStatus,
} from "@/lib/daily/priority-item";
/** Deterministic glyph per kind — the icon the serialisable item cannot hold. */
const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
dose_window: Pill,
preventive_care: CalendarClock,
sync_issue: RefreshCw,
coach_checkin: MessageCircle,
milestone: Award,
ecg_new_recording: HeartPulse,
tension_window: Activity,
};
/**
* Status wash — a subtle semantic-token tint carrying MEANING, not decoration
* (the `recommendation-card` pattern). Static classes so Tailwind sees them.
*/
const STATUS_WASH: Record<PriorityItemStatus, string> = {
success: "bg-success/10 border-success/25",
warning: "bg-warning/10 border-warning/25",
info: "bg-info/10 border-info/25",
destructive: "bg-destructive/10 border-destructive/25",
};
export interface PriorityCardProps {
item: PriorityItem;
/**
* Tap handler for non-navigation actions. Receives the action's stable
* `intent` token. Navigation actions (those carrying an `href`) render as
* links and do not call this.
*/
onAction?: (intent: string) => void;
className?: string;
}
/** Tap-target floor per P1: `min-h-11` on touch, `min-h-9` on pointer. */
const ACTION_SIZE = "min-h-11 sm:min-h-9";
export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
const { t } = useTranslations();
const Icon = KIND_ICON[item.kind];
const actions = item.actions.slice(0, 3);
return (
<Card
data-slot="priority-card"
data-kind={item.kind}
className={cn(
"animate-insight-in gap-2 py-3 md:gap-2 md:py-4",
item.status ? STATUS_WASH[item.status] : null,
className,
)}
>
<CardContent className="flex flex-col gap-2">
<TileHeader icon={Icon} title={item.title} size="sm" />
{item.body ? (
<div className="text-foreground text-sm">
<ProseBlocks text={item.body} strip={false} linkify={false} />
</div>
) : null}
{actions.length > 0 ? (
<div className="flex flex-wrap items-center gap-2 pt-1">
{actions.map((action) => (
<ActionButton
key={action.intent}
action={action}
label={t(action.labelKey)}
onAction={onAction}
/>
))}
</div>
) : null}
</CardContent>
</Card>
);
}
function ActionButton({
action,
label,
onAction,
}: {
action: PriorityItemAction;
label: string;
onAction?: (intent: string) => void;
}) {
if (action.href) {
return (
<Link
href={action.href}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
ACTION_SIZE,
)}
>
{label}
</Link>
);
}
return (
<Button
variant="outline"
size="sm"
className={ACTION_SIZE}
onClick={() => onAction?.(action.intent)}
>
{label}
</Button>
);
}
+235
View File
@@ -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([]);
});
});
+253
View File
@@ -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),
};
}
+116
View File
@@ -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;
}
+84
View File
@@ -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;
+119
View File
@@ -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,
},
},
},
};
+4
View File
@@ -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"]> = {