From 90a9407195b725ef9fc76361441f4e8582059f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:53:41 +0200 Subject: [PATCH] fix(vorsorge): key due status on the local calendar day --- .../__tests__/vorsorge-section.test.tsx | 62 +++++++++++++++++++ .../vorsorge-section.tsx | 15 ++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index d9ad228cd..eb608b13d 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -136,6 +136,68 @@ describe(" loading + empty", () => { expect(html).not.toContain("Log value"); }); + it("reads a same-day due time as 'today' (calendar-day delta, not rolling 24h)", () => { + // v1.18.9 recon — a reminder whose nextDue falls on the SAME local + // calendar day must read "Due today" with the green status hue, even when + // the wall-clock gap to now exceeds the 24h a rolling delta would key on. + // Anchor the due instant to local noon today so the floored calendar-day + // delta is 0 regardless of the hour the suite runs. + const todayNoon = new Date(); + todayNoon.setHours(12, 0, 0, 0); + const reminder: MeasurementReminder = { + id: "due-today", + label: "Blood pressure check", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + nextDueAt: todayNoon.toISOString(), + notifyHour: 9, + location: null, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2030-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + } as MeasurementReminder; + remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); + const html = render(); + expect(html).toContain("Due today"); + // The status hue is the discreet success colour, never a card-bg tint. + expect(html).toContain("text-success"); + }); + + it("reads a prior-day due time as 'overdue' once the calendar day has passed", () => { + // A reminder whose due CALENDAR day is before today reads "Overdue", with + // the warning hue — a rolling-24h delta would mis-bucket a yesterday-evening + // due viewed this morning as "today". + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(20, 0, 0, 0); + const reminder: MeasurementReminder = { + id: "overdue", + label: "Annual physical", + measurementType: null, + intervalDays: 365, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + nextDueAt: yesterday.toISOString(), + notifyHour: 9, + location: null, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2030-01-01T00:00:00.000Z", + updatedAt: "2030-01-01T00:00:00.000Z", + } as MeasurementReminder; + remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); + const html = render(); + expect(html).toContain("Overdue by 1 days"); + expect(html).toContain("text-warning"); + }); + it("translates a COACH-origin label i18n key and shows the neutral badge", () => { const reminder: MeasurementReminder = { id: "c1", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index e3ea28f57..e1c1c59f8 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -40,6 +40,7 @@ import { import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -124,9 +125,17 @@ function relativeDueKey( now: number, ): { key: string; days: number } { if (!nextDueAt) return { key: "nextDue.none", days: 0 }; - const due = new Date(nextDueAt).getTime(); - // Compare calendar-day deltas so "today" / "in 1 day" read cleanly. - const deltaDays = Math.round((due - now) / DAY_MS); + const due = new Date(nextDueAt); + if (Number.isNaN(due.getTime())) return { key: "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: "overdueByDays", days: Math.abs(deltaDays) }; if (deltaDays === 0) return { key: "nextDue.today", days: 0 }; if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };