mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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