Merge pull request #673 from MBombeck/release/v1.35.1

release/v1.35.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-01 10:52:52 +02:00
commit fb1bf710e6
6 files changed
+211 -4

No files matched your search

+14
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<typeof resolveSlotForWriteByBand>[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<Row> {
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");
});
});
+18 -1
View File
@@ -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 ||