From 7231c9f3f7aa4b59a8347ce3c6352bb648cd7b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 10:28:40 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.35.1=20=E2=80=94=20stop=20?= =?UTF-8?q?asking=20for=20a=20weekly=20dose=20that=20was=20already=20taken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A medication on a rolling interval had its expected dose anchored on the moment the person actually injected, and was then checked off against the moment it was planned for, with six hours of tolerance. Those two are allowed to sit days apart, because a dose taken late still counts, so the dose someone's own injection created could never be marked done. It stayed open, went overdue, and the app asked for it again. No editing was needed to reach it. Taking a weekly dose a few days late was enough, on any account. Push reminders read the plan rather than the entries and were never affected, so this was on screen, not on a phone. --- CHANGELOG.md | 14 ++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../rolling-taken-dose-not-redue.test.ts | 176 ++++++++++++++++++ src/lib/medications/scheduling/next-due.ts | 19 +- 6 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac579edee..5cac6b968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [1.35.1] — 2026-08-01 + +### Fixed + +- A weekly injection you have already taken is no longer announced again. A + medication dosed on a rolling interval had its expected dose anchored on the + moment you actually injected, and was then checked off against the moment it + was planned for, with six hours of tolerance. Those two are allowed to sit days + apart, because a late dose still counts, so the dose your own injection created + could never be marked done. It stayed open, went overdue, and the app asked for + it again. Anyone on a weekly medication could hit this without ever editing + anything: taking a dose a few days late was enough. Push reminders were never + affected, because they read the plan rather than your entries. + ## [1.35.0] — 2026-07-31 The Health Score becomes something you decide rather than something that happens diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7a36fc111..1634c89f7 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.35.0 + version: 1.35.1 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 8fca01907..00a5fcbaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.35.0", + "version": "1.35.1", "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 43d40a7be..0f50573f3 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.35.0"; + /* @sw-version-fallback */ "v1.35.1"; 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/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts new file mode 100644 index 000000000..1ff7974b3 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts @@ -0,0 +1,176 @@ +/** + * A logged weekly injection must stop being announced — and a genuinely + * missed one must still become overdue. + * + * The rolling retrospective grid anchors an expected slot AT each logged + * intake instant, so the open-overdue search got a band back for the dose the + * user had just taken. That band was tested for "has the user acted on it?" + * against the intake row's `scheduledFor`, which for a late take is the SLOT + * anchor days away from `takenAt` — the two never matched, the band read open, + * and the card announced the already-injected GLP-1 dose as overdue. + * + * The test drives the REAL assembly rather than hand-picking a `scheduledFor`: + * the write path (`resolveSlotForWriteByBand`, over a fake Prisma holding the + * real row shapes) decides what the server persists, and the read path + * (`toResolvedSlotMark` + `computeDisplayDue`, wired with the same feeder + * queries `list-read.ts` and the MCP schedule tool run) decides what the card + * announces. Both ends and the pipe between them. + */ +import { describe, expect, it, vi } from "vitest"; + +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "../next-due"; +import { resolveSlotForWriteByBand } from "../slot-upsert"; +import type { WorkerMedicationRow, WorkerScheduleRow } from "../worker-helpers"; + +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; + +const medication: WorkerMedicationRow = { + id: "med-1", + startsOn: new Date("2026-06-02T00:00:00.000Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +/** Weekly GLP-1 injection: rolling every 7 days at 09:00 Berlin. */ +const schedule: WorkerScheduleRow = { + id: "sched-1", + windowStart: "09:00", + windowEnd: "10:00", + daysOfWeek: null, + timesOfDay: ["09:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +/** One persisted `MedicationIntakeEvent`, in the shape both paths read. */ +interface Row { + scheduledFor: Date; + takenAt: Date; +} + +type BandClient = Parameters[0]["client"]; + +/** + * Fake Prisma over an in-memory row list, answering exactly the two queries + * `loadAttributeMedication` + `rollingIntakeInstantsIfNeeded` issue. + */ +function makeClient(rows: Row[]): BandClient { + return { + medication: { + findFirst: vi.fn(async () => ({ + ...medication, + schedules: [schedule], + scheduleRevisions: [], + })), + }, + medicationIntakeEvent: { + // Latest non-tombstoned takenAt — the rolling anchor. + findFirst: vi.fn(async () => { + const sorted = [...rows].sort( + (a, b) => b.takenAt.getTime() - a.takenAt.getTime(), + ); + return sorted[0] ? { takenAt: sorted[0].takenAt } : null; + }), + // Non-skipped takes at or before `around`, ascending. + findMany: vi.fn(async (args: { where: { takenAt: { lte: Date } } }) => + [...rows] + .filter( + (r) => r.takenAt.getTime() <= args.where.takenAt.lte.getTime(), + ) + .sort((a, b) => a.takenAt.getTime() - b.takenAt.getTime()) + .map((r) => ({ takenAt: r.takenAt })), + ), + }, + } as unknown as BandClient; +} + +/** Log a take through the REAL write-path attribution and persist the row. */ +async function logTake(rows: Row[], takenAt: Date): Promise { + const attribution = await resolveSlotForWriteByBand({ + userId: "u1", + medicationId: medication.id, + userTz: TZ, + takenAt, + now: takenAt, + client: makeClient(rows), + }); + // The intake routes persist `attribution.slotInstant ?? takenAt` verbatim. + const row: Row = { + scheduledFor: attribution.slotInstant ?? takenAt, + takenAt, + }; + rows.push(row); + return row; +} + +/** The card's verdict, wired exactly as `list-read.ts` / the MCP tool wire it. */ +function readDisplayDue(rows: Row[], now: Date) { + const lastIntakeAt = + rows.length === 0 + ? null + : rows.reduce((a, b) => (a.takenAt > b.takenAt ? a : b)).takenAt; + const windowStart = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); + const windowEnd = new Date(now.getTime() + 2 * DAY_MS); + const resolvedSlots: ResolvedSlotMark[] = rows + .filter( + (r) => + r.scheduledFor.getTime() >= windowStart.getTime() && + r.scheduledFor.getTime() <= windowEnd.getTime(), + ) + .map(toResolvedSlotMark); + + return computeDisplayDue({ + medication, + schedules: [schedule], + now, + userTz: TZ, + lastIntakeAt, + resolvedSlots, + eraStart: null, + }); +} + +describe("weekly rolling dose — taken vs missed", () => { + it("stops announcing a weekly dose the user has logged", async () => { + const rows: Row[] = []; + // First shot: Tue 21 July, 09:00 Berlin. + await logTake(rows, new Date("2026-07-21T07:00:00.000Z")); + // The next slot falls Tue 28 July; the user injects four days late, + // Sat 1 August 09:00 Berlin. The band model credits that to the 28 July + // slot, so the persisted row's `scheduledFor` sits four days from its + // `takenAt` — the divergence the open-overdue search must survive. + const late = await logTake(rows, new Date("2026-08-01T07:00:00.000Z")); + expect(late.scheduledFor.toISOString()).toBe("2026-07-28T07:00:00.000Z"); + + // Read the card the next morning. + const display = readDisplayDue(rows, new Date("2026-08-02T08:00:00.000Z")); + + expect(display?.overdue).toBe(false); + // Next injection is a full cycle after the shot actually taken. + expect(display?.at.toISOString()).toBe("2026-08-08T07:00:00.000Z"); + }); + + it("still flags a weekly dose the user genuinely missed", async () => { + const rows: Row[] = []; + await logTake(rows, new Date("2026-07-21T07:00:00.000Z")); + + // Nothing logged since. Two days past the 28 July slot it is open and + // overdue — inside the four-day catch-up tail, so still takeable. + const display = readDisplayDue(rows, new Date("2026-07-30T08:00:00.000Z")); + + expect(display?.overdue).toBe(true); + expect(display?.at.toISOString()).toBe("2026-07-28T07:00:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 05994eeff..562d60e11 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -271,6 +271,7 @@ function findOpenOverdueSlot( if (floor.getTime() >= now.getTime()) return null; const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); + const intakeInstants = lastIntakeAt ? [lastIntakeAt] : []; let latest: { at: Date; availableFrom: Date; @@ -288,9 +289,24 @@ function findOpenOverdueSlot( userTz, range: { from: floor, to: now }, now, - intakeInstants: lastIntakeAt ? [lastIntakeAt] : [], + intakeInstants, includeOpenRollingOccurrence: true, }); + // A rolling cadence's retrospective grid anchors an expected slot AT each + // logged intake instant (`expandRollingRetrospective`), so every instant + // fed in above comes back as a band. Such a band is served BY DEFINITION — + // the take that minted it IS the action on it — but `isResolved` keys on + // the row's `scheduledFor`, which for a late take is the SLOT anchor days + // away from `takenAt`. The two coordinate systems never meet, so the band + // read as an open overdue slot at exactly the instant the dose was taken + // and the card told the user to take a weekly injection again. Drop the + // self-minted anchors before the openness test; the genuine open + // occurrence (`lastIntakeAt + N`, unioned in above) and any back-filled + // missed cycle are untouched. + const selfMinted = + schedule.rollingIntervalDays !== null + ? new Set(intakeInstants.map((instant) => instant.getTime())) + : null; for (const band of bands) { const anchor = band.at.getTime(); // Open overdue: the anchor has passed, now is still inside the @@ -300,6 +316,7 @@ function findOpenOverdueSlot( if (anchor < floor.getTime()) continue; if (anchor >= now.getTime()) continue; if (now.getTime() > band.overdueEnd.getTime()) continue; + if (selfMinted?.has(anchor)) continue; if (isResolved(band.at)) continue; if ( latest === null ||