mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge work/vorsorge-due-day
This commit is contained in:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user