Merge work/vorsorge-due-day

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 07:50:14 +02:00
commit f322736074
5 files changed
+312 -63

No files matched your search

@@ -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,
);
}
});
});
@@ -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,
@@ -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()`;
@@ -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,
});
});
});
+62
View File
@@ -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 };
}