diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index cd101028d..50d820432 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -28,40 +28,15 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; +import { relativeDueKey } from "@/lib/measurement-reminders/due-day"; import { useMeasurementReminders, useMeasurementReminderMutations, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; -const DAY_MS = 24 * 60 * 60 * 1000; const SUMMARY_LIMIT = 3; -/** - * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site - * that interpolates the tail onto the namespace root anchors the - * reverse-coverage guard at that bare namespace, which marks every key under - * it reachable and turns the guard into a no-op for the whole namespace. The - * guard now refuses that shape outright, so the key is built whole here. - */ -function relativeDueKey( - nextDueAt: string | null, - now: number, -): { key: string; days: number } { - if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; - const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS); - if (deltaDays < 0) - return { - key: "measurementReminders.overdueByDays", - days: Math.abs(deltaDays), - }; - if (deltaDays === 0) - return { key: "measurementReminders.nextDue.today", days: 0 }; - if (deltaDays === 1) - return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; - return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; -} - function resolveLabel( reminder: MeasurementReminder, t: (key: string) => string, diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 5a1d697a7..e1a5f945c 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -46,7 +46,7 @@ import { useDisplayTimezone, } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { relativeDueKey } from "@/lib/measurement-reminders/due-day"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -143,42 +143,6 @@ const CADENCE_CUSTOM = "custom"; const CADENCE_RRULE = "rrule"; const DAY_MS = 24 * 60 * 60 * 1000; -/** - * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site - * that interpolates the tail onto the namespace root anchors the - * reverse-coverage guard at that bare namespace, which marks every key under - * it reachable and turns the guard into a no-op for the whole namespace. The - * guard now refuses that shape outright, so the key is built whole here. - */ -function relativeDueKey( - nextDueAt: string | null, - now: number, -): { key: string; days: number } { - if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; - const due = new Date(nextDueAt); - if (Number.isNaN(due.getTime())) - return { key: "measurementReminders.nextDue.none", days: 0 }; - // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's local zone, - // not a rolling-24h delta. A reminder due at 09:00 viewed the same evening - // at 20:00 must still read "heute", and one whose due calendar-day is before - // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would - // mis-bucket both. Floor each instant to its local day start (host-local zone - // = the browser user's zone for this client component) before differencing. - const dueDay = startOfLocalDayInTz(due, undefined).getTime(); - const nowDay = startOfLocalDayInTz(new Date(now), undefined).getTime(); - const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); - if (deltaDays < 0) - return { - key: "measurementReminders.overdueByDays", - days: Math.abs(deltaDays), - }; - if (deltaDays === 0) - return { key: "measurementReminders.nextDue.today", days: 0 }; - if (deltaDays === 1) - return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; - return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; -} - /** * v1.18.1 — a COACH-minted reminder stores an i18n KEY in `label` (the * cadence preset's `labelKey`), not free prose. Resolve it through `t()`; diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts new file mode 100644 index 000000000..be07a84db --- /dev/null +++ b/src/lib/measurement-reminders/due-day.ts @@ -0,0 +1,62 @@ +/** + * The one place a Vorsorge reminder's next-due instant becomes the words a + * person reads: "heute" / "morgen" / "in N Tagen" / "überfällig seit N Tagen". + * + * It used to live inside the two surfaces that render it — the `/checkups` + * page and the dashboard summary card — as two hand-copied bodies. The page + * copy was fixed to compare CALENDAR days; the dashboard copy was not, and + * kept differencing raw instants. So the same reminder read "morgen" on one + * screen and "in 2 Tagen" on the other, and a reminder due this morning + * dropped out of "heute" over the course of the afternoon. One body here, both + * surfaces importing it, and the two cannot disagree again. + * + * Sits beside `scheduling.ts` (which computes the server-authoritative + * `nextDueAt`) because this is the read side of the same value. Nothing in + * this file touches the database or the network, so a client component can + * import it directly. + */ +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** A fully-qualified i18n key plus the day count it interpolates. */ +export interface RelativeDue { + key: string; + days: number; +} + +/** + * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site + * that interpolates the tail onto the namespace root anchors the + * reverse-coverage guard at that bare namespace, which marks every key under + * it reachable and turns the guard into a no-op for the whole namespace. The + * guard now refuses that shape outright, so the key is built whole here. + */ +export function relativeDueKey( + nextDueAt: string | null, + now: number, +): RelativeDue { + if (!nextDueAt) return { key: "measurementReminders.nextDue.none", days: 0 }; + const due = new Date(nextDueAt); + if (Number.isNaN(due.getTime())) + return { key: "measurementReminders.nextDue.none", days: 0 }; + // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's local zone, + // not a rolling-24h delta. A reminder due at 09:00 viewed the same evening + // at 20:00 must still read "heute", and one whose due calendar-day is before + // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would + // mis-bucket both. Floor each instant to its local day start (host-local zone + // = the browser user's zone for this client component) before differencing. + const dueDay = startOfLocalDayInTz(due, undefined).getTime(); + const nowDay = startOfLocalDayInTz(new Date(now), undefined).getTime(); + const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); + if (deltaDays < 0) + return { + key: "measurementReminders.overdueByDays", + days: Math.abs(deltaDays), + }; + if (deltaDays === 0) + return { key: "measurementReminders.nextDue.today", days: 0 }; + if (deltaDays === 1) + return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; + return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; +}