diff --git a/CHANGELOG.md b/CHANGELOG.md index 66418c187..27b4af685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.37.23] — 2026-08-15 + +### Changed + +- Postponing a checkup is a button on the card now, beside "erledigt" or "jetzt messen", instead of a row inside the overflow menu. That is the shape the medication card has always had, where "genommen" and "übersprungen" sit next to each other, and it puts the action one tap away rather than three. The compact list view carries the same action as an icon button. Edit, measurements and delete stay in the menu. + +### Fixed + +- The quick options in the postpone sheet ("+7 Tage" and its siblings) fill in the day they name even when a daylight-saving change falls inside the span. They used to add a fixed number of hours, which shifts the clock past midnight in the week a country moves its offset, so the option could write the day before or after the one on its label. + ## [1.37.22] — 2026-08-15 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a1f24e691..db1c17547 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.22 + version: 1.37.23 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 3a19812db..706483299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.22", + "version": "1.37.23", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index be5aaff9e..76f3a95c1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.22"; + /* @sw-version-fallback */ "v1.37.23"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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 }) => ( + + ))}