feat(daily): let the Today rail dismiss milestone / ECG / tension cards

The worth-a-look rail mixed actionable items (an overdue dose, a broken
sync) with observational ones (a milestone, a new ECG pointer, a tension
window) that have nothing to act on beyond "seen it" — there was no way
to clear them short of them expiring on their own.

Add server-side dismissal scoped to the three observational kinds only:
a new DismissedPriorityItem table (userId + itemKey, composite-unique),
a POST /api/daily/digest/dismiss route, and a deterministic itemKey the
digest builder stamps per kind (metric + reach day for a milestone, the
recording's own timestamp for an ECG, the local day + part of day for a
tension window) so a dismissal never bleeds into a later day's instance.
The actionable kinds never carry an itemKey, so the dismiss affordance
and the endpoint are both structurally unreachable for them — an
itemKey not namespaced under a dismissible kind 422s before any lookup.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 14:29:07 +02:00
1 parent 4f198ec25f
commit 270f33edb2
17 files changed
+903 -9

No files matched your search

+64
View File
@@ -10848,6 +10848,42 @@ paths:
"403": *a5
"422": *a3
"429": *a4
/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:
type: object
properties:
itemKey:
type: string
minLength: 1
maxLength: 200
required:
- itemKey
additionalProperties: false
responses:
"200":
description: The item is dismissed (idempotent).
content:
application/json:
schema:
$ref: "#/components/schemas/DismissPriorityItemEnvelope"
"401": *a1
"403": *a5
"422": *a3
"429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -30150,6 +30186,10 @@ components:
- ecg_new_recording
- tension_window
description: Closed rail-item kind.
itemKey:
description: 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.
type: string
title:
type: string
description: Localised headline (resolved server-side).
@@ -30193,6 +30233,30 @@ components:
- actions
additionalProperties: false
description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.
DismissPriorityItemEnvelope:
type: object
properties:
data:
type: object
properties:
dismissed:
type: boolean
const: true
required:
- dismissed
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MedicationScheduleOutput:
type: object
properties:
@@ -0,0 +1,30 @@
-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL
-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The
-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin)
-- never write a row here — they clear on their own once the user acts.
--
-- `item_key` is a deterministic identity for the candidate INSTANCE the
-- digest builder already computes (kind + metric + reach day for a
-- milestone, the recording's own timestamp for an ECG, the local day for a
-- tension window) — not the bare kind — so dismissing today's milestone never
-- suppresses tomorrow's.
-- CreateTable
CREATE TABLE "dismissed_priority_items" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_key" TEXT NOT NULL,
"dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the dismiss upsert's natural key + the digest read's
-- `userId + itemKey IN (...)` lookup.
CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key"
ON "dismissed_priority_items" ("user_id", "item_key");
-- AddForeignKey — deleting a user cascades its dismissal rows away.
ALTER TABLE "dismissed_priority_items"
ADD CONSTRAINT "dismissed_priority_items_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+30
View File
@@ -719,6 +719,8 @@ model User {
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
@@ -2100,6 +2102,34 @@ model PersonalRecord {
@@map("personal_records")
}
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
/// preventive_care, coach_checkin) never write a row here — they clear on
/// their own once the user acts, so a dismiss affordance on them would just
/// duplicate the existing resolution path.
///
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
/// digest builder already computes (kind + metric + reach day for a
/// milestone, the recording's own timestamp for an ECG, the local day for a
/// tension window) — not the bare `kind` — so dismissing today's milestone
/// never suppresses tomorrow's; the underlying item's own freshness window
/// (reached-once / 24h / one-per-day) is what actually retires the row's
/// relevance, this table only fast-forwards that for the rest of today.
model DismissedPriorityItem {
id String @id @default(cuid())
userId String @map("user_id")
itemKey String @map("item_key")
dismissedAt DateTime @default(now()) @map("dismissed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The dismiss upsert's natural key + the digest read's
/// `userId + itemKey IN (...)` lookup.
@@unique([userId, itemKey])
@@map("dismissed_priority_items")
}
// ─── Medications ───────────────────────────────────────────
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
@@ -0,0 +1,164 @@
/**
* `POST /api/daily/digest/dismiss` — the Today rail dismiss surface.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't
* namespaced under a dismissible kind 422s BEFORE any DB write, and the happy
* path upserts the `DismissedPriorityItem` row scoped to the caller.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
dismissedPriorityItem: {
upsert: vi.fn().mockResolvedValue({}),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 39,
resetAt: Date.now() + 60_000,
})),
rateLimitHeaders: 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("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
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 callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(body: unknown): NextRequest {
return new NextRequest(new URL("http://localhost/api/daily/digest/dismiss"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("POST /api/daily/digest/dismiss", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(401);
expect(prisma.dismissedPriorityItem.upsert).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 callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(403);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => {
const res = await callPost(makeReq({ itemKey: "dose_window:anything" }));
expect(res.status).toBe(422);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("422s a missing itemKey", async () => {
const res = await callPost(makeReq({}));
expect(res.status).toBe(422);
});
it("422s an unknown extra field (strict schema)", async () => {
const res = await callPost(
makeReq({
itemKey: "milestone:record_first:WEIGHT:2026-07-16",
userId: "someone-else",
}),
);
expect(res.status).toBe(422);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
for (const itemKey of [
"milestone:record_first:WEIGHT:2026-07-16",
"ecg_new_recording:2026-07-16T08:00:00.000Z",
"tension_window:2026-07-16:afternoon",
]) {
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
const res = await callPost(makeReq({ itemKey }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ dismissed: true });
expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalledWith({
where: { userId_itemKey: { userId: "user-1", itemKey } },
update: {},
create: { userId: "user-1", itemKey },
});
}
});
it("returns 429 when the caller's dismiss rate limit is exhausted", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(429);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* POST /api/daily/digest/dismiss
*
* "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 never reachable here: `dismissPriorityItemSchema`
* rejects any `itemKey` that doesn't carry one of the three dismissible
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
*
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
* an upsert, so a repeat dismiss of the same instance is a no-op rather than
* a conflict. `userId` is narrowed from `requireAuth()` only; the body never
* carries one. Cookie OR Bearer, mirroring the digest GET route's own auth
* policy, and gated on the same `insights` module (the daily rail's host
* surface).
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
export const dynamic = "force-dynamic";
// A cheap, owner-scoped upsert; the limit only caps a runaway client loop.
const DISMISS_RATE_LIMIT = 40;
const DISMISS_WINDOW_MS = 60_000;
export const POST = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`daily-digest-dismiss:${user.id}`,
DISMISS_RATE_LIMIT,
DISMISS_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = dismissPriorityItemSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { itemKey } = parsed.data;
await prisma.dismissedPriorityItem.upsert({
where: { userId_itemKey: { userId: user.id, itemKey } },
update: {},
create: { userId: user.id, itemKey },
});
annotate({
action: { name: "daily.digest.item.dismissed" },
meta: { daily_digest_item_kind: itemKey.split(":")[0] },
});
return apiSuccess({ dismissed: true });
});
@@ -134,4 +134,61 @@ describe("<PriorityCard>", () => {
);
expect(html).toContain("View check-ups");
});
describe("dismiss affordance — observational kinds only", () => {
const DISMISSIBLE: PriorityItemKind[] = [
"milestone",
"ecg_new_recording",
"tension_window",
];
const ACTIONABLE: PriorityItemKind[] = [
"dose_window",
"sync_issue",
"preventive_care",
"coach_checkin",
];
it("renders the dismiss button on every observational kind that carries an itemKey", () => {
for (const kind of DISMISSIBLE) {
const html = render(
<PriorityCard
item={item({ kind, itemKey: `${kind}:instance-1` })}
onDismiss={() => {}}
/>,
);
expect(html).toContain('data-slot="priority-card-dismiss"');
}
});
it("never renders the dismiss button on an actionable kind, even if a caller wires onDismiss", () => {
for (const kind of ACTIONABLE) {
const html = render(
<PriorityCard
item={item({ kind, itemKey: `${kind}:instance-1` })}
onDismiss={() => {}}
/>,
);
expect(html).not.toContain('data-slot="priority-card-dismiss"');
}
});
it("does not render the dismiss button when the item carries no itemKey (server didn't stamp one)", () => {
const html = render(
<PriorityCard
item={item({ kind: "milestone" })}
onDismiss={() => {}}
/>,
);
expect(html).not.toContain('data-slot="priority-card-dismiss"');
});
it("does not render the dismiss button when the caller passes no onDismiss handler", () => {
const html = render(
<PriorityCard
item={item({ kind: "milestone", itemKey: "milestone:instance-1" })}
/>,
);
expect(html).not.toContain('data-slot="priority-card-dismiss"');
});
});
});
+49 -7
View File
@@ -26,6 +26,7 @@ import {
MessageCircle,
Pill,
RefreshCw,
X,
type LucideIcon,
} from "lucide-react";
@@ -35,11 +36,12 @@ 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,
import {
isDismissibleKind,
type PriorityItem,
type PriorityItemAction,
type PriorityItemKind,
type PriorityItemStatus,
} from "@/lib/daily/priority-item";
/** Deterministic glyph per kind — the icon the serialisable item cannot hold. */
@@ -93,13 +95,27 @@ export interface PriorityCardProps {
* links and do not call this.
*/
onAction?: (intent: string) => void;
/**
* Dismiss handler for the OBSERVATIONAL kinds only (`milestone`,
* `ecg_new_recording`, `tension_window`). Receives the item's own
* `itemKey`. The card renders the affordance ONLY when the kind is
* dismissible AND the item actually carries a key — an actionable item (no
* `itemKey` by construction) never shows one, no matter what the caller
* passes.
*/
onDismiss?: (itemKey: 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) {
export function PriorityCard({
item,
onAction,
onDismiss,
className,
}: PriorityCardProps) {
const { t } = useTranslations();
const Icon = KIND_ICON[item.kind];
const actions = item.actions.slice(0, 3);
@@ -111,6 +127,13 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
// halo's background wins over the status wash's flat `bg-success/10`
// (unlayered CSS beats utilities); the wash keeps the success border.
const isMilestone = item.kind === "milestone";
// Dismiss is offered ONLY on the observational kinds, and only once the
// server has actually stamped an identity onto this instance — the
// actionable kinds never carry an `itemKey`, so this is structurally false
// for them regardless of what the caller wires up. Held as its own const
// (rather than re-checked inline) so the narrowed `string` type carries
// into the click handler below without a non-null assertion.
const dismissKey = isDismissibleKind(item.kind) ? item.itemKey : undefined;
return (
<Card
@@ -126,7 +149,26 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) {
style={hue ? ({ "--tile-hue": hue } as React.CSSProperties) : undefined}
>
<CardContent className="flex flex-col gap-2">
<TileHeader icon={Icon} title={item.title} size="sm" />
<TileHeader
icon={Icon}
title={item.title}
size="sm"
right={
dismissKey && onDismiss ? (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="priority-card-dismiss"
className="text-muted-foreground hover:text-foreground -my-2 -mr-2 min-h-11 min-w-11 shrink-0 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
onClick={() => onDismiss(dismissKey)}
aria-label={t("common.dismiss")}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : undefined
}
/>
{item.body ? (
<div className="text-foreground text-sm">
<ProseBlocks text={item.body} strip={false} linkify={false} />
+3
View File
@@ -42,6 +42,7 @@ import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DailyDigest } from "@/lib/daily/digest";
@@ -64,6 +65,7 @@ function formatDelta(
export function TodayHero({ digest }: { digest: DailyDigest }) {
const { t } = useTranslations();
const { keep, letGo } = useCoachCheckinAction();
const dismissItem = usePriorityItemDismiss();
// The coach check-in card's keep / let-go intents carry the plan id after the
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
@@ -220,6 +222,7 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
/>
))}
</div>
+29
View File
@@ -0,0 +1,29 @@
"use client";
/**
* Today rail — "dismiss / mark seen" for the OBSERVATIONAL `PriorityItem`
* kinds (`milestone`, `ecg_new_recording`, `tension_window`). Posts the
* item's own `itemKey` to `POST /api/daily/digest/dismiss` (server persists
* it so the dismissal survives reload / a second device), then invalidates
* the daily digest so the dismissed card leaves the rail immediately — the
* same invalidate-on-mutate pattern `useCoachCheckinAction` uses. Keys route
* through the centralised factory — no bare arrays.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export function usePriorityItemDismiss() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (itemKey: string) => {
await apiPost("/api/daily/digest/dismiss", { itemKey });
return itemKey;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
},
});
}
+95
View File
@@ -17,6 +17,11 @@ import {
} from "@/lib/daily/coach-checkin-intents";
import type { Milestone } from "@/lib/daily/milestones";
import type { PriorityItem } from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
const t = getServerTranslator("en").t;
const NOW = new Date("2026-07-16T09:00:00.000Z");
@@ -62,6 +67,8 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
preventiveDue: [],
coachPlans: [],
tensionWindow: null,
todayLocalDate: "2026-07-16",
dismissedItemKeys: new Set<string>(),
...over,
};
}
@@ -483,6 +490,25 @@ describe("S12 — the milestone reward card", () => {
expect(item?.title).not.toMatch(forbidden);
expect(item?.body ?? "").not.toMatch(forbidden);
});
it("stamps a deterministic dismiss key namespaced by kind", () => {
const item = milestone(
buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t),
);
expect(item?.itemKey).toBe(milestoneItemKey(RECORD_MILESTONE));
expect(item?.itemKey).toMatch(/^milestone:/);
});
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
const d = buildDailyDigest(
input({
milestone: RECORD_MILESTONE,
dismissedItemKeys: new Set([milestoneItemKey(RECORD_MILESTONE)]),
}),
t,
);
expect(milestone(d)).toBeUndefined();
});
});
describe("buildDailyDigest — S11 tension_window item", () => {
@@ -535,6 +561,33 @@ describe("buildDailyDigest — S11 tension_window item", () => {
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(tension(d)).toBeUndefined();
});
it("stamps a dismiss key namespaced by kind, folding in the local day + part of day", () => {
const d = buildDailyDigest(
input({
tensionWindow: { partOfDay: "afternoon" },
todayLocalDate: "2026-07-16",
}),
t,
);
const item = tension(d);
expect(item?.itemKey).toBe(tensionWindowItemKey("2026-07-16", "afternoon"));
expect(item?.itemKey).toMatch(/^tension_window:/);
});
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
const d = buildDailyDigest(
input({
tensionWindow: { partOfDay: "afternoon" },
todayLocalDate: "2026-07-16",
dismissedItemKeys: new Set([
tensionWindowItemKey("2026-07-16", "afternoon"),
]),
}),
t,
);
expect(tension(d)).toBeUndefined();
});
});
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
@@ -654,4 +707,46 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const serialised = JSON.stringify(ecgItem(d));
expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i);
});
it("stamps a dismiss key namespaced by kind, folding in the recording's own timestamp", () => {
const recordedAt = new Date(NOW.getTime() - 60 * 60 * 1000);
const d = buildDailyDigest(
input({ latestEcg: { recordedAt, deviceVerdict: "IRREGULAR" } }),
t,
);
const item = ecgItem(d);
expect(item?.itemKey).toBe(ecgItemKey(recordedAt));
expect(item?.itemKey).toMatch(/^ecg_new_recording:/);
});
it("is dropped from the rail once its dismiss key is in the dismissed set", () => {
const recordedAt = new Date(NOW.getTime() - 60 * 60 * 1000);
const d = buildDailyDigest(
input({
latestEcg: { recordedAt, deviceVerdict: "IRREGULAR" },
dismissedItemKeys: new Set([ecgItemKey(recordedAt)]),
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
});
describe("buildDailyDigest — dismiss filtering never touches actionable kinds", () => {
it("ignores a dismissed key that happens to collide with an actionable item's shape (they never carry one)", () => {
// Actionable items never stamp an `itemKey`, so filtering can never match
// them regardless of what the dismissed set contains — a dose-window item
// stays on the rail even with an arbitrary set of dismissed keys.
const d = buildDailyDigest(
input({
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
dismissedItemKeys: new Set(["dose_window:anything", "sync_issue:x"]),
}),
t,
);
expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(true);
expect(
d.worthALook.find((i) => i.kind === "dose_window")?.itemKey,
).toBeUndefined();
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
ecgItemKey,
milestoneItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
describe("priority-item-key — deterministic dismiss identities", () => {
it("milestoneItemKey folds kind + metric + reach day, namespaced `milestone:`", () => {
const key = milestoneItemKey({
kind: "record_first",
metricType: "WEIGHT",
sinceDate: "2026-07-16",
});
expect(key).toBe("milestone:record_first:WEIGHT:2026-07-16");
});
it("milestoneItemKey differs across metric types and reach days (never collides across instances)", () => {
const a = milestoneItemKey({
kind: "record_first",
metricType: "WEIGHT",
sinceDate: "2026-07-16",
});
const b = milestoneItemKey({
kind: "record_first",
metricType: "RESTING_HEART_RATE",
sinceDate: "2026-07-16",
});
const c = milestoneItemKey({
kind: "record_first",
metricType: "WEIGHT",
sinceDate: "2026-07-17",
});
expect(new Set([a, b, c]).size).toBe(3);
});
it("ecgItemKey folds the recording's own ISO timestamp, namespaced `ecg_new_recording:`", () => {
const recordedAt = new Date("2026-07-16T08:03:00.000Z");
expect(ecgItemKey(recordedAt)).toBe(
"ecg_new_recording:2026-07-16T08:03:00.000Z",
);
});
it("tensionWindowItemKey folds the local day + part of day, namespaced `tension_window:`", () => {
expect(tensionWindowItemKey("2026-07-16", "afternoon")).toBe(
"tension_window:2026-07-16:afternoon",
);
// A different part of day on the same local day is a distinct instance.
expect(tensionWindowItemKey("2026-07-16", "afternoon")).not.toBe(
tensionWindowItemKey("2026-07-16", "evening"),
);
});
it("every emitted key satisfies isDismissibleItemKey (prefix round-trips)", () => {
expect(
isDismissibleItemKey(
milestoneItemKey({
kind: "record_first",
metricType: "WEIGHT",
sinceDate: "2026-07-16",
}),
),
).toBe(true);
expect(
isDismissibleItemKey(ecgItemKey(new Date("2026-07-16T08:00:00.000Z"))),
).toBe(true);
expect(
isDismissibleItemKey(tensionWindowItemKey("2026-07-16", "morning")),
).toBe(true);
});
it("isDismissibleItemKey rejects an actionable-shaped key", () => {
expect(isDismissibleItemKey("dose_window:anything")).toBe(false);
expect(isDismissibleItemKey("sync_issue:withings")).toBe(false);
expect(isDismissibleItemKey("")).toBe(false);
});
});
+40 -2
View File
@@ -27,6 +27,11 @@ import {
MAX_PRIORITY_ACTIONS,
type PriorityItem,
} from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import {
milestoneCopy,
milestoneHref,
@@ -184,6 +189,19 @@ export interface DailyDigestInput {
* builder treats a missing value as "no recent recording".
*/
latestEcg?: DailyDigestEcg | null;
/**
* The caller's local calendar day (profile tz), used ONLY to stamp the
* tension-window item's dismiss key (`tensionWindowItemKey`) — the same day
* space `loadIntradayPulse` already computed the window against.
*/
todayLocalDate: string;
/**
* Item keys the user has already dismissed (`DismissedPriorityItem`),
* scoped to today's OWN candidate keys by the IO seam — never a full
* history read. A dismissed item is dropped from `worthALook` entirely,
* never rendered with its actions suppressed.
*/
dismissedItemKeys: ReadonlySet<string>;
}
export interface DailyDigest {
@@ -323,6 +341,7 @@ function buildMilestoneItem(
const { title, body } = milestoneCopy(milestone, t);
return {
kind: "milestone",
itemKey: milestoneItemKey(milestone),
title,
body,
status: "success",
@@ -348,12 +367,14 @@ function buildMilestoneItem(
function buildTensionWindowItem(
window: DailyDigestTensionWindow | null,
modules: DigestModuleMap,
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!window) return null;
return {
kind: "tension_window",
itemKey: tensionWindowItemKey(todayLocalDate, window.partOfDay),
title: t("daily.item.tensionWindow.title"),
body: t(`daily.item.tensionWindow.body.${window.partOfDay}`),
status: "info",
@@ -497,6 +518,7 @@ function buildEcgNewRecordingItem(
return {
kind: "ecg_new_recording",
itemKey: ecgItemKey(ecg.recordedAt),
title: t("daily.item.ecgNewRecording.title"),
body,
status: "info",
@@ -584,7 +606,12 @@ export function buildDailyDigest(
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
// bounded rail ahead of it.
const tension = buildTensionWindowItem(input.tensionWindow, input.modules, t);
const tension = buildTensionWindowItem(
input.tensionWindow,
input.modules,
input.todayLocalDate,
t,
);
if (tension) worthALook.push(tension);
// Defence-in-depth: no card ever exceeds the P1 action cap.
@@ -592,6 +619,17 @@ export function buildDailyDigest(
item.actions = item.actions.slice(0, MAX_PRIORITY_ACTIONS);
}
// Drop anything the user already dismissed (observational kinds only — an
// actionable item never carries an `itemKey`, so it can never match here).
// Filtered BEFORE the bounded slice, so a dismissal reliably makes room for
// the next candidate rather than leaving a gap.
const visible =
input.dismissedItemKeys.size === 0
? worthALook
: worthALook.filter(
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
);
return {
generatedAt: input.now.toISOString(),
phase,
@@ -600,6 +638,6 @@ export function buildDailyDigest(
topSignal,
briefingLead,
line: composeLine(briefingLead, topSignal, input.score, t),
worthALook: worthALook.slice(0, MAX_WORTH_A_LOOK),
worthALook: visible.slice(0, MAX_WORTH_A_LOOK),
};
}
+28
View File
@@ -32,6 +32,11 @@ import {
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import {
ecgItemKey,
milestoneItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
@@ -316,6 +321,27 @@ export async function loadDailyDigest(
.catch(() => null)
: null;
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
// only their candidate keys are worth looking up — never a full per-user
// history read. At most 3 keys, so this is a single indexed `IN (...)`.
const candidateItemKeys = [
milestone ? milestoneItemKey(milestone) : null,
latestEcg ? ecgItemKey(latestEcg.recordedAt) : null,
tensionWindow
? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay)
: null,
].filter((k): k is string => k !== null);
const dismissedRows =
candidateItemKeys.length > 0
? await prisma.dismissedPriorityItem.findMany({
where: { userId: user.id, itemKey: { in: candidateItemKeys } },
select: { itemKey: true },
})
: [];
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
const { t } = getServerTranslator(resolveLocale(locale));
const digest = buildDailyDigest(
@@ -333,6 +359,8 @@ export async function loadDailyDigest(
milestone,
tensionWindow,
latestEcg,
todayLocalDate,
dismissedItemKeys,
},
t,
);
+41
View File
@@ -0,0 +1,41 @@
/**
* Deterministic identity keys for the dismissible (observational) Today rail
* items — `milestone`, `ecg_new_recording`, `tension_window`. Pure string
* formatting, no DB, no clock: both the digest composer (`./digest.ts`, which
* stamps the key onto the emitted `PriorityItem`) and the IO seam
* (`./load-digest.ts`, which needs the SAME keys to look up which of today's
* candidates are already dismissed) import from here so the format is one
* source of truth.
*
* Each key is namespaced `<kind>:...` (see `isDismissibleItemKey` in
* `./priority-item.ts`) and folds in enough of the underlying candidate's own
* identity that a fresh instance the NEXT day never collides with today's
* dismissed one — a milestone by its reach day, an ECG recording by its own
* timestamp, a tension window by the local day it was detected for.
*/
import type { Milestone } from "@/lib/daily/milestones";
/** `milestone:<kind>:<metricType>:<sinceDate>` — one key per durable state reached. */
export function milestoneItemKey(
milestone: Pick<Milestone, "kind" | "metricType" | "sinceDate">,
): string {
return `milestone:${milestone.kind}:${milestone.metricType}:${milestone.sinceDate}`;
}
/** `ecg_new_recording:<recordedAt ISO>` — the recording's own timestamp is unique. */
export function ecgItemKey(recordedAt: Date): string {
return `ecg_new_recording:${recordedAt.toISOString()}`;
}
/**
* `tension_window:<localDayKey>:<partOfDay>` — at most one per user per day.
* `partOfDay` mirrors `DailyDigestTensionWindow["partOfDay"]` (`./digest.ts`)
* by value rather than by import, so this module stays a leaf the digest
* composer can depend on without a cycle.
*/
export function tensionWindowItemKey(
localDayKey: string,
partOfDay: "morning" | "afternoon" | "evening" | "night",
): string {
return `tension_window:${localDayKey}:${partOfDay}`;
}
+46
View File
@@ -40,6 +40,45 @@ export const PRIORITY_ITEM_KINDS = [
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
/**
* The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an
* elevated-at-rest window. These carry no pending action; the user can only
* acknowledge them, so they're the only kinds the Today rail lets you
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
* own once the user acts (logs the dose, reconnects the integration, …) and
* are never dismissible; offering a dismiss on them would just let a user
* silence a still-open action item.
*/
export const DISMISSIBLE_PRIORITY_ITEM_KINDS = [
"milestone",
"ecg_new_recording",
"tension_window",
] as const satisfies readonly PriorityItemKind[];
export type DismissiblePriorityItemKind =
(typeof DISMISSIBLE_PRIORITY_ITEM_KINDS)[number];
/** Whether `kind` is one of the dismissible observational kinds. */
export function isDismissibleKind(
kind: PriorityItemKind,
): kind is DismissiblePriorityItemKind {
return (DISMISSIBLE_PRIORITY_ITEM_KINDS as readonly string[]).includes(kind);
}
/**
* The dismiss ledger's natural key is namespaced by kind (`<kind>:...`), so
* the prefix alone tells the server the key names a dismissible instance —
* no separate `kind` field needs to cross the wire. Shared by the digest
* builder (which stamps the key) and the dismiss route's Zod schema (which
* rejects anything else structurally, before a lookup ever runs).
*/
export function isDismissibleItemKey(itemKey: string): boolean {
return DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) =>
itemKey.startsWith(`${kind}:`),
);
}
/**
* 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.
@@ -68,6 +107,13 @@ export interface PriorityItemAction {
*/
export interface PriorityItem {
kind: PriorityItemKind;
/**
* Stable identity for THIS item instance, namespaced `<kind>:...` — present
* only on the dismissible observational kinds (see
* `DISMISSIBLE_PRIORITY_ITEM_KINDS`). The client only offers a dismiss
* affordance when this is set; the actionable kinds never carry one.
*/
itemKey?: string;
/** i18n-resolved headline (server-side). */
title: string;
/** Optional grounded one-liner — a plain string rendered via `ProseBlocks`. */
+36
View File
@@ -12,11 +12,18 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
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()
@@ -116,4 +123,33 @@ export const dailyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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,
},
},
},
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Request schema for the Today rail's dismiss surface
* (`POST /api/daily/digest/dismiss`).
*
* Lives outside the route file so the OpenAPI registry can import it without
* touching the route module. `itemKey` is namespaced `<kind>:...`
* (`isDismissibleItemKey` in `@/lib/daily/priority-item`) — the prefix alone
* proves the key names one of the OBSERVATIONAL kinds
* (`milestone` / `ecg_new_recording` / `tension_window`); anything else 422s
* before a lookup ever runs, so an actionable item can never be dismissed
* through this endpoint no matter what a client sends.
*/
import { z } from "zod/v4";
import { isDismissibleItemKey } from "@/lib/daily/priority-item";
/** Generous ceiling on the longest real key (an ISO timestamp is ~24 chars). */
const MAX_ITEM_KEY_LENGTH = 200;
export const dismissPriorityItemSchema = z
.object({
itemKey: z
.string()
.min(1)
.max(MAX_ITEM_KEY_LENGTH)
.refine(isDismissibleItemKey, {
message: "itemKey does not name a dismissible rail item",
}),
})
.strict();