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

@@ -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>