diff --git a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts
index e65751449..21e0dec33 100644
--- a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts
+++ b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts
@@ -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,37 @@ 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.
+ const home = read(HOME);
+
+ expect(home, FIX_THE_ZONE).not.toMatch(
+ /startOfLocalDayInTz\([^)]*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([]);
+ });
+});
diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
index 50d820432..3db90c565 100644
--- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
+++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx
@@ -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() {
) : (
{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,
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index e1a5f945c..941f66f29 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -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", {
diff --git a/src/lib/measurement-reminders/__tests__/due-day.test.ts b/src/lib/measurement-reminders/__tests__/due-day.test.ts
index d653f24b7..e5038b585 100644
--- a/src/lib/measurement-reminders/__tests__/due-day.test.ts
+++ b/src/lib/measurement-reminders/__tests__/due-day.test.ts
@@ -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,80 @@ 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(
- localIso(2026, 7, FRIDAY + 7, 6),
- localNow(2026, 7, FRIDAY, 22),
- ),
- ).toEqual({ key: "measurementReminders.nextDue.inDays", days: 7 });
+ expect(relativeDueKey(zoned(FRIDAY + 7, 6), at(FRIDAY, 22), ZONE)).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 },
- );
+ 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);
});
});
diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts
index be07a84db..855df0943 100644
--- a/src/lib/measurement-reminders/due-day.ts
+++ b/src/lib/measurement-reminders/due-day.ts
@@ -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 {