diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 869e18532..9a36baad8 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -33,7 +33,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({ }), })); -import { VorsorgeSection } from "../vorsorge-section"; +import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section"; import { DISPLAY_TIMEZONE } from "@/lib/format-locale"; /** @@ -387,3 +387,60 @@ describe(" loading + empty", () => { expect(html).toContain("Until"); }); }); + +/** + * The postpone sheet's quick chips write a date into the field the person then + * confirms, so a chip that resolves to the wrong day sends the wrong day. + * + * Project convention here is SSR-only (no `@testing-library/react`) and the + * sheet renders nothing until it opens, so the chips are checked through the + * exported resolver rather than through the markup. + */ +describe("postponeOffsetTargets", () => { + const AT_NOON = Date.parse("2026-03-10T12:00:00.000Z"); + + it("offers the three documented offsets in order", () => { + expect(postponeOffsetTargets(AT_NOON, "Europe/Berlin")).toEqual([ + { days: 7, date: "2026-03-17" }, + { days: 30, date: "2026-04-09" }, + { days: 90, date: "2026-06-08" }, + ]); + }); + + it("counts from the day the person is on, not the UTC day", () => { + // 23:30 in Berlin on 10 March is still 22:30 UTC the same day, but in + // Auckland it is already the 11th. Each zone counts from its own date. + const lateBerlin = Date.parse("2026-03-10T22:30:00.000Z"); + expect(postponeOffsetTargets(lateBerlin, "Europe/Berlin")[0]).toEqual({ + days: 7, + date: "2026-03-17", + }); + expect(postponeOffsetTargets(lateBerlin, "Pacific/Auckland")[0]).toEqual({ + days: 7, + date: "2026-03-18", + }); + }); + + it("keeps every offset on the day its label names across a DST shift", () => { + // Both fixtures sit late in the local evening, with a Berlin DST shift + // inside the seven days. Adding 7 × 86 400 000 ms holds the UTC instant + // and lets the changed offset move the wall clock over midnight, which is + // what these two pin against. + // + // Spring: 23:30 local on 27 March (CET). Seven days later Berlin is on + // CEST, so the naive sum reads 00:30 on 4 April — a day past what + // "+7 days" promises. + const beforeSpringForward = Date.parse("2026-03-27T22:30:00.000Z"); + expect( + postponeOffsetTargets(beforeSpringForward, "Europe/Berlin")[0], + ).toEqual({ days: 7, date: "2026-04-03" }); + // Autumn, the other direction: 00:30 local on 24 October (CEST). Berlin + // falls back on the 25th, so the naive sum reads 23:30 on 30 October — a + // day short. + const beforeFallBack = Date.parse("2026-10-23T22:30:00.000Z"); + expect(postponeOffsetTargets(beforeFallBack, "Europe/Berlin")[0]).toEqual({ + days: 7, + date: "2026-10-31", + }); + }); +}); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index c034989be..6ebb1a81b 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -785,7 +785,7 @@ export function VorsorgeSection({ * means due/overdue now, lower means time still remains in the window. * Returns null when there is no interval to measure against (RRULE / unset). */ -/** v1.37.20 (#223) — the postpone sheet's quick-chip offsets, in days. */ +/** v1.37.20 (#223) — the offsets the postpone menu offers, in days. */ const SNOOZE_CHIP_DAYS = [7, 30, 90] as const; /** @@ -797,6 +797,32 @@ function isoDayInTz(atMs: number, tz: string): string { return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz }); } +/** + * The dates the postpone sheet's quick chips resolve to, on the display zone's + * calendar. + * + * Its own function because a chip labelled "+7 days" has to write the day that + * is actually seven days out, including across a DST shift, where adding + * 7 × 86 400 000 ms lands an hour either side of midnight and so can write the + * neighbouring date. + */ +export function postponeOffsetTargets( + nowMs: number, + tz: string, +): { days: number; date: string }[] { + // Count days on the calendar, not in milliseconds: `now + 7 * DAY_MS` keeps + // the wall-clock time only where the offset stays constant, and a DST shift + // in between moves it an hour, which flips the date for anyone acting near + // midnight. Anchoring today's local date at UTC noon and stepping whole + // UTC days keeps every offset on the day its label names. + const anchor = new Date(`${isoDayInTz(nowMs, tz)}T12:00:00.000Z`); + return SNOOZE_CHIP_DAYS.map((days) => { + const target = new Date(anchor); + target.setUTCDate(target.getUTCDate() + days); + return { days, date: target.toISOString().slice(0, 10) }; + }); +} + function intervalProgress( reminder: MeasurementReminder, now: number, @@ -929,16 +955,10 @@ function VorsorgeCard({ {t("measurementReminders.edit")} - {/* v1.37.20 (#223) — postpone lives in the kebab, never in the - primary-action bar (v1.27.5 rule: the action button keeps one - constant look in every state). */} - setPostponeOpen(true)} - > - - {t("measurementReminders.postpone.menuItem")} - + {/* v1.37.20 (#223) put postpone in the kebab. It reads better beside + the primary action, the way the medication card carries "taken" + and "skipped" side by side, so it is a card button now — see + `postponeButton` below. */} {/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to this reminder's type. Only for measurement-linked reminders; a free-text reminder has no readings to show. */} @@ -1003,10 +1023,29 @@ function VorsorgeCard({ ) : null; + // Postponing sits beside the primary action rather than inside the kebab, + // the way the medication card carries "taken" and "skipped" as two buttons + // on the card. Outline against the filled primary: the same pairing, and + // the same rule as everything else in this bar — one constant look, never + // tinted by how overdue the checkup is. + const postponeButton = canManage ? ( + + ) : null; + const primaryButton = canManage ? ( - ); - })} + {postponeOffsetTargets(now, displayTz).map(({ days, date }) => ( + + ))}