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, + ); + } + }); +}); 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/__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, + }); + }); +}); 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 }; +}