From a2497cf21ded8b8eed67d84600aabd5c265cd75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:44:20 +0200 Subject: [PATCH 1/3] Make the checkup due line say the same thing on both screens The dashboard summary card and the checkups page each worked out "today", "tomorrow", "in N days" and "overdue by N days" from the same due date, and each did it with its own copy of the arithmetic. The page compares calendar days. The card subtracted one instant from the other and divided, so a checkup due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at 09:00 quietly stopped saying "today" as the afternoon wore on. Both now read from one place, so they cannot drift apart again. --- .../vorsorge-dashboard-card.tsx | 27 +------- .../vorsorge-section.tsx | 38 +----------- src/lib/measurement-reminders/due-day.ts | 62 +++++++++++++++++++ 3 files changed, 64 insertions(+), 63 deletions(-) create mode 100644 src/lib/measurement-reminders/due-day.ts 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 }; +} From 70cab3c8855f412a6aa036e6159118c8bbfd492e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:46:55 +0200 Subject: [PATCH 2/3] Pin the checkup due line to calendar days The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers. --- .../__tests__/due-day.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/lib/measurement-reminders/__tests__/due-day.test.ts diff --git a/src/lib/measurement-reminders/__tests__/due-day.test.ts b/src/lib/measurement-reminders/__tests__/due-day.test.ts new file mode 100644 index 000000000..d653f24b7 --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/due-day.test.ts @@ -0,0 +1,125 @@ +/** + * The due line on a checkup is a CALENDAR-day statement. "Tomorrow" means the + * next date on the wall calendar, not roughly twenty-four hours from now, and + * every case below is one where those two answers differ. + * + * Each instant is built from local wall-clock components rather than a UTC + * string, so the suite says the same thing whatever zone it runs in — the CI + * box is on UTC and a laptop is not. + */ +import { describe, it, expect } from "vitest"; + +import { relativeDueKey } from "../due-day"; + +/** A local wall-clock instant, as the ISO string the API hands the client. */ +function localIso( + year: number, + month: number, + day: number, + hour: number, + minute = 0, +): string { + return new Date(year, month - 1, day, hour, minute, 0, 0).toISOString(); +} + +/** The same wall clock as the epoch millis a component reads off `Date.now()`. */ +function localNow( + year: number, + month: number, + day: number, + hour: number, + minute = 0, +): number { + return new Date(year, month - 1, day, hour, minute, 0, 0).getTime(); +} + +// A July week, deliberately: no IANA zone shifts its clocks mid-July, so +// building 01:00 or 23:59 from local components can never land on a skipped or +// repeated hour. Fri 17th, Sat 18th, Sun 19th. +const FRIDAY = 17; +const SATURDAY = 18; +const SUNDAY = 19; + +describe("relativeDueKey", () => { + it("still reads 'today' in the evening for a checkup that was due this morning", () => { + expect( + relativeDueKey( + localIso(2026, 7, FRIDAY, 9), + localNow(2026, 7, FRIDAY, 20), + ), + ).toEqual({ key: "measurementReminders.nextDue.today", days: 0 }); + }); + + it("holds 'today' from first thing to last thing, either side of the due hour", () => { + // Both ends of the same date sit more than twelve hours from the due time, + // which is where a rolling-hours delta tips into the neighbouring day: it + // would call the 07:00 checkup overdue by bedtime, and the 21:00 one + // tomorrow at breakfast. + expect( + relativeDueKey( + localIso(2026, 7, FRIDAY, 7), + localNow(2026, 7, FRIDAY, 22), + ), + ).toEqual({ key: "measurementReminders.nextDue.today", days: 0 }); + expect( + relativeDueKey( + localIso(2026, 7, FRIDAY, 21), + localNow(2026, 7, FRIDAY, 6), + ), + ).toEqual({ key: "measurementReminders.nextDue.today", days: 0 }); + }); + + it("calls a Sunday 01:00 checkup two days out when read late on Friday", () => { + // Twenty-six hours apart, so a rolling-hours delta rounds to one day and + // says "tomorrow". Two dates separate them on the calendar. + expect( + relativeDueKey( + localIso(2026, 7, SUNDAY, 1), + localNow(2026, 7, FRIDAY, 23), + ), + ).toEqual({ key: "measurementReminders.nextDue.inDays", days: 2 }); + }); + + it("calls a Saturday 23:59 checkup tomorrow when read just after Friday midnight", () => { + // Just under forty-eight hours apart, so a rolling-hours delta rounds to + // two days. It is the very next date on the calendar. + expect( + relativeDueKey( + localIso(2026, 7, SATURDAY, 23, 59), + localNow(2026, 7, FRIDAY, 0, 1), + ), + ).toEqual({ key: "measurementReminders.nextDue.tomorrow", days: 1 }); + }); + + it("counts a checkup from yesterday as one day overdue, however recent", () => { + // Forty-five minutes in the past. It was still yesterday's checkup. + expect( + relativeDueKey( + localIso(2026, 7, FRIDAY, 23, 30), + localNow(2026, 7, SATURDAY, 0, 15), + ), + ).toEqual({ key: "measurementReminders.overdueByDays", days: 1 }); + }); + + it("counts whole calendar days for a checkup further out", () => { + expect( + relativeDueKey( + localIso(2026, 7, FRIDAY + 7, 6), + localNow(2026, 7, FRIDAY, 22), + ), + ).toEqual({ key: "measurementReminders.nextDue.inDays", days: 7 }); + }); + + it("says nothing is scheduled rather than crashing on a date it cannot read", () => { + expect(relativeDueKey("not-a-date", localNow(2026, 7, FRIDAY, 12))).toEqual( + { key: "measurementReminders.nextDue.none", days: 0 }, + ); + }); + + it("says nothing is scheduled when there is no due date at all", () => { + expect(relativeDueKey(null, localNow(2026, 7, FRIDAY, 12))).toEqual({ + key: "measurementReminders.nextDue.none", + days: 0, + }); + }); +}); From c1b372de9c63e753b2e464355bf10683378f35cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:48:57 +0200 Subject: [PATCH 3/3] Stop a second copy of the checkup due line from appearing Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives. --- ...sorge-due-bucket-single-home-guard.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts diff --git a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts new file mode 100644 index 000000000..e65751449 --- /dev/null +++ b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts @@ -0,0 +1,123 @@ +/** + * Structural guard on the checkup due-day bucket. + * + * Turning a reminder's `nextDueAt` into "today" / "tomorrow" / "in N days" / + * "overdue by N days" is calendar arithmetic, and it is easy to get wrong in a + * way that reads fine most of the time: subtract the two instants, divide by + * twenty-four hours, round. That answer disagrees with the wall calendar + * whenever the two times of day are more than twelve hours apart, which is + * most evenings. + * + * It was written that way once, fixed on the checkups page, and left unfixed + * in the hand-copied second body on the dashboard card. For a year the two + * screens gave different answers about the same appointment. This file exists + * so a third body cannot appear. + * + * These are tripwires, not proofs. Whether the bucketing is CORRECT is settled + * by `src/lib/measurement-reminders/__tests__/due-day.test.ts`. What this file + * settles is that there is only one of it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +/** The one home. Everything else imports from here. */ +const HOME = "lib/measurement-reminders/due-day.ts"; + +/** How a consumer must reach it. */ +const HOME_IMPORT = 'from "@/lib/measurement-reminders/due-day"'; + +/** + * The two screens that render the due line. Named here so this guard can never + * pass by having nothing left to check: if the surfaces stop importing the + * shared body, that is the regression, not a reason to relax the test. + */ +const RENDERING_SURFACES = [ + "components/measurement-reminders/vorsorge-section.tsx", + "components/measurement-reminders/vorsorge-dashboard-card.tsx", +]; + +/** + * The keys only the bucket itself mints. A surface may COMPARE against the + * today / overdue keys to colour its badge, and both do — so those two are + * deliberately not listed. Nothing but the bucket ever needs to name + * "tomorrow", "in N days" or "none", so a file outside the home that contains + * one is a second bucket wearing a different function name. + */ +const MINTED_ONLY_BY_THE_BUCKET = [ + "measurementReminders.nextDue.tomorrow", + "measurementReminders.nextDue.inDays", + "measurementReminders.nextDue.none", +]; + +const FIX_IT = [ + `The due-day bucket lives in src/${HOME} and nowhere else.`, + `Import { relativeDueKey } ${HOME_IMPORT} instead of writing a second one.`, + "If the behaviour needs to change, change it there — both the checkups page", + "and the dashboard card read that body, and they must agree.", +].join(" "); + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .map((p) => p.split(sep).join("/")) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** A line that declares `relativeDueKey`, ignoring comment prose. */ +function declaresTheBucket(source: string): boolean { + return source + .split("\n") + .some( + (line) => + /\b(function|const|let|var)\s+relativeDueKey\b/.test(line) && + !/^\s*(\*|\/\/|\/\*)/.test(line), + ); +} + +describe("the checkup due-day bucket has one home", () => { + it("is declared in exactly one file", () => { + const declaring = sourceFiles().filter((rel) => + declaresTheBucket(read(rel)), + ); + + expect(declaring, FIX_IT).toEqual([HOME]); + }); + + it("is reached by import everywhere it is used", () => { + const localUsers = sourceFiles() + .filter((rel) => rel !== HOME) + .filter((rel) => read(rel).includes("relativeDueKey")) + .filter((rel) => !read(rel).includes(HOME_IMPORT)); + + expect(localUsers, FIX_IT).toEqual([]); + }); + + it("is the only place the tomorrow, in-N-days and none keys are minted", () => { + // Catches a duplicate that was renamed on the way in: whatever it is + // called, a second bucket has to name the same keys to say the same words. + const minters = sourceFiles() + .filter((rel) => rel !== HOME) + .filter((rel) => { + const source = read(rel); + return MINTED_ONLY_BY_THE_BUCKET.some((key) => source.includes(key)); + }); + + expect(minters, FIX_IT).toEqual([]); + }); + + it("is what both checkup screens read their due line from", () => { + for (const rel of RENDERING_SURFACES) { + expect(read(rel), `${rel} no longer reads the shared due line`).toContain( + HOME_IMPORT, + ); + } + }); +});