Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -387,3 +387,60 @@ describe("<VorsorgeSection> 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",
});
});
});
117 changes: 87 additions & 30 deletions src/components/measurement-reminders/vorsorge-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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,
Expand Down Expand Up @@ -929,16 +955,10 @@ function VorsorgeCard({
<Pencil className="mr-2 h-4 w-4" />
{t("measurementReminders.edit")}
</DropdownMenuItem>
{/* 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). */}
<DropdownMenuItem
disabled={busy}
onSelect={() => setPostponeOpen(true)}
>
<CalendarClock className="mr-2 h-4 w-4" />
{t("measurementReminders.postpone.menuItem")}
</DropdownMenuItem>
{/* 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. */}
Expand Down Expand Up @@ -1003,10 +1023,29 @@ function VorsorgeCard({
</Button>
) : 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 ? (
<Button
type="button"
variant="outline"
className="min-h-11"
data-slot="vorsorge-postpone"
onClick={() => setPostponeOpen(true)}
disabled={busy}
>
<CalendarClock className="h-4 w-4" />
{t("measurementReminders.postpone.menuItem")}
</Button>
) : null;

const primaryButton = canManage ? (
<Button
type="button"
className="min-h-11 w-full"
className="min-h-11 flex-1"
onClick={onPrimaryAction}
disabled={busy}
>
Expand Down Expand Up @@ -1098,20 +1137,17 @@ function VorsorgeCard({
{/* Quick chips write the date field rather than firing directly, so
the person always sees (and can adjust) what will be sent. */}
<div className="flex flex-wrap gap-2">
{SNOOZE_CHIP_DAYS.map((days) => {
const chipDate = isoDayInTz(now + days * DAY_MS, displayTz);
return (
<Button
key={days}
type="button"
size="sm"
variant={snoozeDate === chipDate ? "secondary" : "outline"}
onClick={() => setSnoozeDate(chipDate)}
>
{t("measurementReminders.postpone.chip", { days })}
</Button>
);
})}
{postponeOffsetTargets(now, displayTz).map(({ days, date }) => (
<Button
key={days}
type="button"
size="sm"
variant={snoozeDate === date ? "secondary" : "outline"}
onClick={() => setSnoozeDate(date)}
>
{t("measurementReminders.postpone.chip", { days })}
</Button>
))}
</div>
<div className="space-y-2">
<Label htmlFor={`snooze-date-${reminder.id}`}>
Expand Down Expand Up @@ -1255,8 +1291,24 @@ function VorsorgeCard({
) : null}
{/* Icon-only in the dense list, labelled in the cards branch —
the same action either way, never one branch offering what
the other withholds. Free-text reminders only: a typed /
screening reminder has no practice visit behind it. */}
the other withholds. */}
{canManage ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-postpone"
aria-label={t("measurementReminders.postpone.menuItem")}
title={t("measurementReminders.postpone.menuItem")}
onClick={() => setPostponeOpen(true)}
disabled={busy}
>
<CalendarClock className="h-4 w-4" aria-hidden />
</Button>
) : null}
{/* Free-text reminders only: a typed / screening reminder has no
practice visit behind it. */}
{canManage && !isLinked ? (
<Button
type="button"
Expand Down Expand Up @@ -1399,10 +1451,15 @@ function VorsorgeCard({
</p>
)}

{/* Bottom-pinned single primary action — green when due (MOD-06). */}
{/* Bottom-pinned action row: the primary action takes the width it
can, postpone sits beside it at its natural size — the same
two-button shape the medication card uses for taken / skipped. */}
{primaryButton ? (
<div className="mt-auto space-y-1.5 pt-0">
{primaryButton}
<div className="flex gap-2">
{primaryButton}
{postponeButton}
</div>
{fileVisitButton}
</div>
) : null}
Expand Down
Loading