mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Work out the checkup due phrase on the same clock as the date beside it
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
This commit is contained in:
1 parent
c1b372de9c
commit
e1ca6d9da2
5 files changed
+163
-83
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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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