mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
4f198ec25f
commit
270f33edb2
17 files changed
+903
-9
No files matched your search
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user