From 12f7441f82843f7fe8840db291b1137f2b1d517a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 15 Aug 2026 19:26:23 +0200 Subject: [PATCH 1/2] Put postpone beside the checkup's primary action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preventive-care card kept postponing in the overflow kebab, three taps from the thing a person actually wants when a due date does not fit: open the menu, find the row, open the sheet. The medication card has never done that — taken and skipped sit side by side on the card, one tap each — and the checkup card should read the same way. Postpone is a second button in the action row now, outline against the filled primary, at its natural width beside it. The dense list branch gets the same action as an icon button, so neither view offers what the other withholds. The kebab keeps edit, measurements and delete. The sheet behind the button is unchanged in what it offers (quick offsets, a free date, the honest-skip zone), but the offsets are computed on the calendar now rather than by adding 86 400 000 ms per day. Across a DST shift the old arithmetic moved the wall clock over midnight and wrote the neighbouring date, so a chip labelled "+7 days" could fill in the sixth or the eighth. --- .../__tests__/vorsorge-section.test.tsx | 59 ++++++++- .../vorsorge-section.tsx | 117 +++++++++++++----- 2 files changed, 145 insertions(+), 31 deletions(-) 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 }) => ( + + ))}