Files
HealthLog/src/lib/daily/priority-item-key.ts
T
Marc-André Bombeck 270f33edb2 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.
2026-07-17 14:29:07 +02:00

42 lines
1.8 KiB
TypeScript

/**
* 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}`;
}