mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
6 files changed
+211
-4
No files matched your search
@@ -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,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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ||
|
||||
|
||||
Reference in new issue
Block a user