mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user