mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge work/vorsorge-due-day zone fix
This commit is contained in:
commit
8b3c0369d1
5 files changed
+231
-80
No files matched your search
@@ -52,6 +52,9 @@ const MINTED_ONLY_BY_THE_BUCKET = [
|
||||
"measurementReminders.nextDue.none",
|
||||
];
|
||||
|
||||
/** The hook that yields the zone the surrounding dates are printed in. */
|
||||
const DISPLAY_ZONE_HOOK = "useDisplayTimezone";
|
||||
|
||||
const FIX_IT = [
|
||||
`The due-day bucket lives in src/${HOME} and nowhere else.`,
|
||||
`Import { relativeDueKey } ${HOME_IMPORT} instead of writing a second one.`,
|
||||
@@ -59,6 +62,14 @@ const FIX_IT = [
|
||||
"and the dashboard card read that body, and they must agree.",
|
||||
].join(" ");
|
||||
|
||||
const FIX_THE_ZONE = [
|
||||
"The due phrase has to be worked out in the same zone as the date printed",
|
||||
`beside it. Read ${DISPLAY_ZONE_HOOK}() and pass it as the third argument to`,
|
||||
"relativeDueKey. Do not reach for the device clock and do not give the",
|
||||
"parameter a default: a caller that stays silent is exactly how the phrase",
|
||||
"and the date drifted a day apart in the first place.",
|
||||
].join(" ");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
@@ -121,3 +132,42 @@ describe("the checkup due-day bucket has one home", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the due-day bucket asks the same clock as the date beside it", () => {
|
||||
it("never floors a day against the device clock", () => {
|
||||
// `startOfLocalDayInTz(instant, undefined)` means "whatever zone this
|
||||
// machine is set to". That is the reading the phrase used to take while
|
||||
// the date next to it took the profile zone.
|
||||
//
|
||||
// The check is the bare word rather than a shape like
|
||||
// `startOfLocalDayInTz(…, undefined)`, because the first draft of it
|
||||
// matched only the inline form and sailed straight past a mutation that
|
||||
// put the same `undefined` in a variable one line up. There is no honest
|
||||
// use for the word in a file whose whole job is to name a zone; if one
|
||||
// ever appears, that is worth coming here and arguing for.
|
||||
const home = read(HOME);
|
||||
|
||||
expect(home, FIX_THE_ZONE).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("takes the zone as an argument with no default to fall back on", () => {
|
||||
const home = read(HOME);
|
||||
const signature = home.slice(
|
||||
home.indexOf("export function relativeDueKey"),
|
||||
home.indexOf("): RelativeDue"),
|
||||
);
|
||||
|
||||
expect(signature, FIX_THE_ZONE).toContain("timeZone: string");
|
||||
// `timeZone: string = …` or `timeZone = …` would let a caller stay silent.
|
||||
expect(signature, FIX_THE_ZONE).not.toMatch(/timeZone[^,)]*=/);
|
||||
});
|
||||
|
||||
it("is called by surfaces that read the printed date's zone", () => {
|
||||
const silent = sourceFiles()
|
||||
.filter((rel) => rel !== HOME)
|
||||
.filter((rel) => read(rel).includes("relativeDueKey("))
|
||||
.filter((rel) => !read(rel).includes(DISPLAY_ZONE_HOOK));
|
||||
|
||||
expect(silent, FIX_THE_ZONE).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -50,6 +50,9 @@ function resolveLabel(
|
||||
|
||||
export function VorsorgeDashboardCard() {
|
||||
const { t } = useTranslations();
|
||||
// Issue #490 — the same profile zone the checkups page derives its due
|
||||
// phrase in, so the two screens still agree when the device sits elsewhere.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const router = useRouter();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders();
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
@@ -112,7 +115,7 @@ export function VorsorgeDashboardCard() {
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{upcoming.map((reminder) => {
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now, displayTz);
|
||||
const isLinked = reminder.measurementType != null;
|
||||
const isScreening = isScreeningReminderType(
|
||||
reminder.measurementType,
|
||||
|
||||
@@ -789,9 +789,12 @@ function VorsorgeCard({
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
|
||||
// The next-due phrase reads off the SAME zone as the last-done date below
|
||||
// it; they used to disagree by a day for anyone whose device sat in another
|
||||
// zone from their profile.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now, displayTz);
|
||||
const cadence =
|
||||
reminder.intervalDays != null
|
||||
? t("measurementReminders.cadence.everyNDays", {
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
/**
|
||||
* 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.
|
||||
* most of the cases below are ones 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.
|
||||
* The calendar in question is the one the person's PROFILE is set to — the
|
||||
* same clock the date printed beside the phrase is rendered in. The last group
|
||||
* of tests pins that, because it was the second half of this defect: the
|
||||
* phrase used to follow the device while the date beside it followed the
|
||||
* profile, so a laptop set to another zone could show a date and a phrase that
|
||||
* contradicted each other.
|
||||
*
|
||||
* Every instant here carries an explicit offset, so the suite says the same
|
||||
* thing whatever zone the machine running it is set to — 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 profile zone under test. July is deliberate: no IANA zone shifts its
|
||||
* clocks mid-July, so a fixed offset stands for the whole month and building
|
||||
* 00:01 or 23:59 can never land on a skipped or repeated hour.
|
||||
*/
|
||||
const ZONE = "Europe/Berlin";
|
||||
const ZONE_OFFSET = "+02:00";
|
||||
|
||||
/** 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();
|
||||
}
|
||||
const TODAY = { key: "measurementReminders.nextDue.today", days: 0 };
|
||||
const TOMORROW = { key: "measurementReminders.nextDue.tomorrow", days: 1 };
|
||||
const NONE = { key: "measurementReminders.nextDue.none", days: 0 };
|
||||
|
||||
// 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.
|
||||
// Fri 17th, Sat 18th, Sun 19th of July 2026.
|
||||
const FRIDAY = 17;
|
||||
const SATURDAY = 18;
|
||||
const SUNDAY = 19;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/** A wall-clock time in {@link ZONE}, as the ISO string the API hands over. */
|
||||
function zoned(day: number, hour: number, minute = 0): string {
|
||||
return `2026-07-${pad(day)}T${pad(hour)}:${pad(minute)}:00${ZONE_OFFSET}`;
|
||||
}
|
||||
|
||||
/** The same wall clock as the epoch millis a component reads off `Date.now()`. */
|
||||
function at(day: number, hour: number, minute = 0): number {
|
||||
return Date.parse(zoned(day, hour, minute));
|
||||
}
|
||||
|
||||
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 });
|
||||
expect(relativeDueKey(zoned(FRIDAY, 9), at(FRIDAY, 20), ZONE)).toEqual(
|
||||
TODAY,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds 'today' from first thing to last thing, either side of the due hour", () => {
|
||||
@@ -55,71 +59,146 @@ describe("relativeDueKey", () => {
|
||||
// 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 });
|
||||
expect(relativeDueKey(zoned(FRIDAY, 7), at(FRIDAY, 22), ZONE)).toEqual(
|
||||
TODAY,
|
||||
);
|
||||
expect(relativeDueKey(zoned(FRIDAY, 21), at(FRIDAY, 6), ZONE)).toEqual(
|
||||
TODAY,
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(relativeDueKey(zoned(SUNDAY, 1), at(FRIDAY, 23), ZONE)).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 });
|
||||
relativeDueKey(zoned(SATURDAY, 23, 59), at(FRIDAY, 0, 1), ZONE),
|
||||
).toEqual(TOMORROW);
|
||||
});
|
||||
|
||||
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),
|
||||
),
|
||||
relativeDueKey(zoned(FRIDAY, 23, 30), at(SATURDAY, 0, 15), ZONE),
|
||||
).toEqual({ key: "measurementReminders.overdueByDays", days: 1 });
|
||||
});
|
||||
|
||||
it("counts whole calendar days for a checkup further out", () => {
|
||||
expect(relativeDueKey(zoned(FRIDAY + 7, 6), at(FRIDAY, 22), ZONE)).toEqual({
|
||||
key: "measurementReminders.nextDue.inDays",
|
||||
days: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a whole clock-change day as one day", () => {
|
||||
// The two days of the year that are not twenty-four hours long. On the
|
||||
// October day Berlin gains an hour, 01:00 and 23:00 sit twenty-three hours
|
||||
// apart; on the March day it loses one, 00:30 and 23:30 sit twenty-two
|
||||
// apart. Both are one calendar date, and on both an hour count rounds up
|
||||
// and calls the evening "tomorrow".
|
||||
expect(
|
||||
relativeDueKey(
|
||||
localIso(2026, 7, FRIDAY + 7, 6),
|
||||
localNow(2026, 7, FRIDAY, 22),
|
||||
"2026-10-25T22:00:00Z", // 23:00 Berlin, after the clocks go back
|
||||
Date.parse("2026-10-25T00:00:00Z"), // 02:00 Berlin, before they do
|
||||
ZONE,
|
||||
),
|
||||
).toEqual({ key: "measurementReminders.nextDue.inDays", days: 7 });
|
||||
).toEqual(TODAY);
|
||||
expect(
|
||||
relativeDueKey(
|
||||
"2026-03-29T21:30:00Z", // 23:30 Berlin, after the clocks go forward
|
||||
Date.parse("2026-03-28T23:30:00Z"), // 00:30 Berlin, before they do
|
||||
ZONE,
|
||||
),
|
||||
).toEqual(TODAY);
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
expect(relativeDueKey("not-a-date", at(FRIDAY, 12), ZONE)).toEqual(NONE);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
expect(relativeDueKey(null, at(FRIDAY, 12), ZONE)).toEqual(NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeDueKey answers in the zone it is given", () => {
|
||||
// One instant, read from two profiles on opposite sides of midnight. At
|
||||
// 23:30 UTC on the 17th the checkup at 02:00 UTC on the 18th is tomorrow's;
|
||||
// in Auckland it is already the 18th for both, so it is today's. Asserting
|
||||
// BOTH in one test is what makes this bite: an implementation that ignores
|
||||
// the argument and reads the machine's own clock returns the same answer
|
||||
// twice, whatever zone that machine happens to be in.
|
||||
const NOW = Date.parse("2026-07-17T23:30:00Z");
|
||||
const DUE = "2026-07-18T02:00:00Z";
|
||||
|
||||
it("gives two profiles either side of midnight two different answers", () => {
|
||||
expect(relativeDueKey(DUE, NOW, "UTC")).toEqual(TOMORROW);
|
||||
expect(relativeDueKey(DUE, NOW, "Pacific/Auckland")).toEqual(TODAY);
|
||||
});
|
||||
|
||||
it("falls back to the printed date's zone, never the machine's, on a zone it cannot read", () => {
|
||||
// 22:30 UTC is already the 18th in Berlin, so Berlin says today and UTC
|
||||
// says tomorrow. A zone the profile mirror could not make sense of has to
|
||||
// land where the date beside it lands, which is Berlin.
|
||||
const now = Date.parse("2026-07-17T22:30:00Z");
|
||||
const due = "2026-07-18T00:30:00Z";
|
||||
|
||||
expect(relativeDueKey(due, now, "Not/AZone")).toEqual(
|
||||
relativeDueKey(due, now, "Europe/Berlin"),
|
||||
);
|
||||
expect(relativeDueKey(due, now, "Not/AZone")).toEqual(TODAY);
|
||||
expect(relativeDueKey(due, now, "UTC")).toEqual(TOMORROW);
|
||||
});
|
||||
|
||||
/**
|
||||
* The machine's own clock, moved under the function's feet. Whatever the
|
||||
* device is set to, the answer must not move — that is the whole difference
|
||||
* between reading the profile and reading the laptop. Asserting it this way
|
||||
* rather than by running the suite in a different zone means the property
|
||||
* holds on the CI box and on a laptop alike, and it stays proven if someone
|
||||
* reinstates a device-clock fallback for either the given zone or the
|
||||
* unreadable one.
|
||||
*/
|
||||
const HOST_ZONES = ["UTC", "Pacific/Auckland", "America/Los_Angeles"];
|
||||
|
||||
function underEachHostZone(fn: () => unknown): unknown[] {
|
||||
const original = process.env.TZ;
|
||||
try {
|
||||
return HOST_ZONES.map((hostZone) => {
|
||||
process.env.TZ = hostZone;
|
||||
return fn();
|
||||
});
|
||||
} finally {
|
||||
process.env.TZ = original;
|
||||
}
|
||||
}
|
||||
|
||||
it("ignores the zone the machine itself is set to", () => {
|
||||
// 22:30 UTC: the 17th in Los Angeles, the 18th in Auckland and in Berlin.
|
||||
const now = Date.parse("2026-07-17T22:30:00Z");
|
||||
const due = "2026-07-18T00:30:00Z";
|
||||
|
||||
expect(underEachHostZone(() => relativeDueKey(due, now, ZONE))).toEqual([
|
||||
TODAY,
|
||||
TODAY,
|
||||
TODAY,
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores the machine's zone even when the given one is unreadable", () => {
|
||||
const now = Date.parse("2026-07-17T22:30:00Z");
|
||||
const due = "2026-07-18T00:30:00Z";
|
||||
|
||||
expect(
|
||||
underEachHostZone(() => relativeDueKey(due, now, "Not/AZone")),
|
||||
).toEqual([TODAY, TODAY, TODAY]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
* this file touches the database or the network, so a client component can
|
||||
* import it directly.
|
||||
*/
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -31,23 +32,38 @@ export interface RelativeDue {
|
||||
* 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.
|
||||
*
|
||||
* `timeZone` is required on purpose. It used to be implicit — the day floor
|
||||
* silently took the DEVICE clock while the date printed next to it took the
|
||||
* profile clock, so on a laptop set to another zone the card could print a
|
||||
* date and a phrase that contradicted each other. Naming the zone is how a
|
||||
* caller says which of those two clocks it means, and there is no default
|
||||
* because the missing answer was the whole bug: a silent caller got something
|
||||
* plausible-looking rather than an error. Pass what the neighbouring date
|
||||
* label renders in — `useDisplayTimezone()` on the client.
|
||||
*/
|
||||
export function relativeDueKey(
|
||||
nextDueAt: string | null,
|
||||
now: number,
|
||||
timeZone: string,
|
||||
): 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
|
||||
// Resolve exactly as `relativeCalendarDate` and `fmt.date` do (issue #490):
|
||||
// a usable zone stands, anything else lands on Berlin. Note where it does
|
||||
// NOT land — the device. Falling back to the host clock is what let the two
|
||||
// halves of this card drift apart, and a zone the profile mirror could not
|
||||
// make sense of has to end up wherever the printed date ends up.
|
||||
const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE;
|
||||
// v1.18.9 (recon) — compare CALENDAR-day deltas in the user's 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();
|
||||
// mis-bucket both. Floor each instant to its local day start first.
|
||||
const dueDay = startOfLocalDayInTz(due, zone).getTime();
|
||||
const nowDay = startOfLocalDayInTz(new Date(now), zone).getTime();
|
||||
const deltaDays = Math.round((dueDay - nowDay) / DAY_MS);
|
||||
if (deltaDays < 0)
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user