mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Today gains two additive-nullable digest fields. `justIn` names the day's newest arrival while it is still news — under three hours old, after which the record is simply current rather than new. `reactionLine` carries a single generated sentence about that arrival and replaces the hero's standing lead for the rest of the local day, never appending a second paragraph, so the hero stays one lead line tall and nothing below it shifts. Nothing here polls. The open dashboard picks the change up on the cadence it already runs — a state change, not a notification. The generated sentence is bounded before it is written: the arrival spine's unique row per kind per local day IS the throttle, the worker re-checks the same claim before spending, and the reservation is reconciled on every exit including each failure path. Without a provider, without consent, or against an exhausted budget the row stays line-less and the surface falls back to the deterministic lead — the chip and the provisional-to-final flip are unaffected. The chip's time string is formatted client-side after mount; `justIn.at` crosses the wire as an ISO instant so the server never renders a local wall-clock time the browser would disagree with.
178 lines
6.7 KiB
TypeScript
178 lines
6.7 KiB
TypeScript
/**
|
||
* 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 { ARRIVAL_KINDS } from "@/lib/arrivals/types";
|
||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
|
||
|
||
const priorityItemSchema = z
|
||
.object({
|
||
kind: z.enum([...PRIORITY_ITEM_KINDS]).describe("Closed rail-item kind."),
|
||
itemKey: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
"Stable dismiss identity, namespaced `<kind>:...`. Present only on the observational kinds (milestone / ecg_new_recording / tension_window) — absent on the actionable kinds, which are never dismissible.",
|
||
),
|
||
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."),
|
||
justIn: z
|
||
.object({
|
||
kind: z
|
||
.enum([...ARRIVAL_KINDS])
|
||
.describe("Closed arrival kind that landed."),
|
||
at: z
|
||
.string()
|
||
.describe(
|
||
"ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own locale and timezone.",
|
||
),
|
||
})
|
||
.nullable()
|
||
.describe(
|
||
"The day's newest data arrival while it is still news (under three hours old), else null. Additive since v1.31.0.",
|
||
),
|
||
reactionLine: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0.",
|
||
),
|
||
})
|
||
.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,
|
||
},
|
||
},
|
||
},
|
||
"/api/daily/digest/dismiss": {
|
||
post: {
|
||
tags: ["Insights"],
|
||
summary: "Dismiss a Today rail item",
|
||
description:
|
||
'"Dismiss / mark seen" for the Today rail\'s OBSERVATIONAL PriorityItem kinds only — milestone, ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or Bearer auth.',
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"application/json": { schema: dismissPriorityItemSchema },
|
||
},
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "The item is dismissed (idempotent).",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({ dismissed: z.literal(true) }),
|
||
"DismissPriorityItemEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...moduleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
};
|