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 ||