Merge pull request #428 from MBombeck/release/v1.27.18

Release v1.27.18 — late doses no longer jump the evening slot
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-07 16:11:08 +02:00
commit 421c407811
10 files changed
+524 -33

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [1.27.18] — 2026-07-07 — Late doses no longer jump the evening slot
### Fixed
- Recording a dose late — after its catch-up window has closed, when the card has already advanced to the next scheduled time — no longer binds the intake to that later slot. Previously a morning dose taken in the early afternoon could be recorded against the evening slot, which then made the evening dose look already taken, dropped it from the day, and pushed the next reminder to tomorrow. A late intake now records on its own, leaving both the missed and the upcoming slot intact, and the taken-count reflects only real intakes. The same guard applies to the bulk-intake path.
## [1.27.17] — 2026-07-07 — The document vault
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.17
version: 1.27.18
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.27.17",
"version": "1.27.18",
"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.27.17";
/* @sw-version-fallback */ "v1.27.18";
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/*`
+16 -1
View File
@@ -29,6 +29,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -344,8 +345,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// index carries `source` and cannot catch it — inflating the
// compliance rollup's scheduled count. A defaulted anchor (takenAt /
// now) never names a slot, so the probe is skipped on that hot path.
//
// Dose-safety guard: a TAKEN write must not converge onto a slot whose
// anchor is in the future relative to the take. The card advances its
// display-due to the evening slot once the morning slot's catch-up
// window lapses, so a late-morning "Genommen" posts the evening slot as
// `scheduledFor`; band attribution already rejected the take, and the
// probe must not re-bind it forward onto the evening pending row (a
// late-morning dose silently consuming the 21:00 slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
scheduledFor !== undefined
scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped,
takenAt: resolvedTakenAt,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -476,26 +476,16 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
vi.useRealTimers();
});
it("converges an early taken-write onto the pre-minted pending REMINDER row instead of inserting a sibling", async () => {
// Source-agnostic probe finds the live REMINDER row on the incoming
// instant…
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
id: "row-reminder",
} as never);
// …and the shared slot upsert re-reads + updates it in place.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-reminder",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-reminder",
it("records an early taken-write standalone; it does NOT snap forward onto the future slot's pending row", async () => {
// Dose-safety invariant: a taken dose must never be attributed to a slot
// whose anchor is still in the future (a late-morning take must not
// consume the evening slot just because the card / client supplied it as
// `scheduledFor`). Band attribution already refused this off-window take
// (resolver null); the convergence probe must honour the same
// `mayConvergeOntoSuppliedSlot` guard rather than re-binding the take
// forward onto the pre-minted REMINDER row. It records standalone instead.
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-standalone",
} as never);
const res = await POST(
@@ -517,14 +507,22 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
entries: Array<{ status: string; id?: string }>;
};
};
// The slot did NOT fork into a second live event.
expect(body.data.updated).toBe(1);
expect(body.data.inserted).toBe(0);
expect(body.data.entries[0].status).toBe("updated");
expect(body.data.entries[0].id).toBe("row-reminder");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "row-reminder" } }),
// Records as a fresh ad-hoc row — the future slot is left untouched.
expect(body.data.inserted).toBe(1);
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("inserted");
// The guard short-circuits the convergence probe (no findFirst) and the
// future pending row is never updated.
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
// Ad-hoc contract: the row anchors on its own intake instant.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
scheduledFor: new Date("2026-06-15T00:30:00.000Z"),
takenAt: new Date("2026-06-15T00:30:00.000Z"),
}),
}),
);
});
+15 -1
View File
@@ -53,6 +53,7 @@ import {
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -487,8 +488,21 @@ async function postBulk(request: NextRequest): Promise<Response> {
// Only an explicit client `scheduledFor` names a slot a row could
// already sit on; a defaulted anchor (takenAt / now) never does, so
// the probe is skipped on that path.
//
// Dose-safety guard: a TAKEN entry must not converge onto a slot
// whose anchor is in the future relative to the take. The resolver's
// forward guard already refused to snap a late take onto a future
// slot; the probe must honour the same invariant rather than binding
// the take forward onto that slot's pending REMINDER row (a
// late-morning dose consuming the evening slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
entry.scheduledFor !== undefined
entry.scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped: entry.skipped,
takenAt: entry.takenAt ?? null,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -0,0 +1,81 @@
/**
* Dose-safety guard for the intake routes' ad-hoc convergence probe.
*
* Regression for the "late-morning take consumes the evening slot" bug: the
* medication card advances its display-due to the 21:00 slot once the 09:00
* slot's catch-up window has lapsed, so a "Genommen" tap at 13:08 posts
* `scheduledFor = 21:00` with `takenAt = now`. Band attribution correctly
* refuses the take (it falls in no window), but the source-agnostic
* convergence probe used to bind it to the 21:00 pending REMINDER row —
* recording a morning dose as the evening one and silently consuming a slot
* the user had not reached. The guard keeps a taken write off any slot whose
* anchor is in the future relative to the take.
*/
import { describe, expect, it } from "vitest";
import { mayConvergeOntoSuppliedSlot } from "@/lib/medications/scheduling/slot-upsert";
const DAY = "2026-03-10";
const at = (hm: string) => new Date(`${DAY}T${hm}:00.000Z`);
describe("mayConvergeOntoSuppliedSlot", () => {
it("refuses a taken write onto a slot in the future (the 13:08 → 21:00 bug)", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("13:08"),
suppliedSlot: at("21:00"),
}),
).toBe(false);
});
it("allows a taken write onto a PAST slot (ledger 'mark the missed 09:00 slot taken')", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("13:08"),
suppliedSlot: at("09:00"),
}),
).toBe(true);
});
it("allows a taken write onto the current slot within the forward clock-skew grace", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("08:58"),
suppliedSlot: at("09:00"),
}),
).toBe(true);
});
it("refuses a taken write onto a slot just beyond the forward grace", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("08:50"),
suppliedSlot: at("09:00"),
}),
).toBe(false);
});
it("always allows a deliberate skip to name its slot (past or future)", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: true,
takenAt: null,
suppliedSlot: at("21:00"),
}),
).toBe(true);
});
it("always allows a pending echo (no takenAt) to target a future slot", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: null,
suppliedSlot: at("21:00"),
}),
).toBe(true);
});
});
@@ -114,6 +114,47 @@ const SLOT_ROW_SELECT = {
createdAt: true,
} as const;
/**
* Forward grace a TAKEN write may still snap onto a slot whose anchor sits
* ahead of the take — the same clock-skew / early-dose margin the resolver's
* `TAKEN_FORWARD_GRACE_MS` uses.
*/
export const TAKEN_SLOT_FORWARD_GRACE_MS = 5 * 60 * 1000;
/**
* Whether a write may converge onto a live row already sitting on the
* client-supplied `scheduledFor` slot (the intake routes' "ad-hoc / off-slot"
* fallback probe, after band/canonical attribution returned null).
*
* A TAKEN dose must never fill a slot whose anchor is meaningfully in the
* FUTURE relative to when it was taken. The medication card shows the next
* open slot as its display-due; once a morning slot's catch-up window has
* lapsed the card advances to the EVENING slot, and a "Genommen" tap then
* posts `scheduledFor = <evening slot>` with `takenAt = now`. Band attribution
* correctly refuses that late-morning take (it lands in no window), but the
* source-agnostic convergence probe would otherwise bind it to the evening
* slot's pending REMINDER row — recording the take as the evening dose and
* silently consuming a slot the user has not reached. This is the exact
* "late morning dose snapping forward onto the evening slot" failure the band
* model and the resolver's forward guard already forbid; the probe must honour
* the same invariant.
*
* Skips and pending echoes (no `takenAt`) name their slot deliberately — a
* deliberate future skip or an offline sync echo legitimately targets a
* future slot — so they are unaffected.
*/
export function mayConvergeOntoSuppliedSlot(input: {
skipped: boolean;
takenAt: Date | null;
suppliedSlot: Date;
}): boolean {
if (input.skipped || input.takenAt === null) return true;
return (
input.suppliedSlot.getTime() <=
input.takenAt.getTime() + TAKEN_SLOT_FORWARD_GRACE_MS
);
}
/**
* Deterministically pick the slot row a write should converge onto when
* the canonical instant carries more than one live row (pre-existing
@@ -0,0 +1,336 @@
/**
* Regression: a late-morning take must not consume the evening slot.
*
* The medication card threads the DISPLAYED dose's slot onto the intake POST
* as `scheduledFor`. Once a morning slot's catch-up window has lapsed the card
* advances its display-due to the EVENING slot, so a "Genommen" tap for the
* (missed) morning dose posts `scheduledFor = 21:00` with `takenAt = now`
* (13:08). Band attribution correctly refuses the take (it lands in no
* window), but the route's source-agnostic convergence probe used to bind it
* to the 21:00 pending REMINDER row — recording the morning dose as the
* evening one, silently consuming a slot the user had not reached, and jumping
* the dashboard "next intake" to tomorrow.
*
* These tests drive the SAME shipped helpers the per-medication intake route
* runs for a taken write (`resolveSlotForWriteByBand` → the
* `mayConvergeOntoSuppliedSlot` guard → the convergence probe /
* `applyCanonicalSlotWrite` / standalone insert) against real Postgres for a
* twice-daily 09:00 / 21:00 medication, then assert the read model the
* dashboard composes (`computeDisplayDue` + the taken/scheduled tally) reflects
* the correct state.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import type { PrismaClient } from "@/generated/prisma/client";
import {
applyCanonicalSlotWrite,
mayConvergeOntoSuppliedSlot,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import {
computeDisplayDue,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { localHmAsUtc } from "@/lib/tz/local-day";
const TEST_USER_ID = "user-late-take-future-slot";
const TZ = "Europe/Berlin";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Fixed non-DST-boundary weekday so the slot instants are deterministic.
const DAY = new Date("2026-03-10T12:00:00.000Z");
const slot0900 = () => localHmAsUtc(DAY, TZ, 9, 0);
const slot2100 = () => localHmAsUtc(DAY, TZ, 21, 0);
/** The real recording moment: 13:08, i.e. 8 minutes past the 09:00 slot's
* +4h catch-up cutoff (09:00 on-time ±1h → 10:00, overdue tail → 13:00). */
const takenAt1308 = () => localHmAsUtc(DAY, TZ, 13, 8);
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "late-take-future-slot",
email: "late-take-future-slot@example.test",
timezone: TZ,
},
});
});
async function createTwiceDailyMed(): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Ramipril",
dose: "5mg",
active: true,
startsOn: new Date("2026-01-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "09:00",
windowEnd: "21:00",
timesOfDay: ["09:00", "21:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
/** Mint the day's two pending REMINDER rows exactly as the projector would. */
async function projectPendingSlots(medId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.medicationIntakeEvent.createMany({
data: [
{
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot0900(),
takenAt: null,
skipped: false,
source: "REMINDER",
},
{
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
takenAt: null,
skipped: false,
source: "REMINDER",
},
],
});
}
/**
* Replicate the per-medication intake route's taken-write branch for a card
* "Genommen" tap: band attribution on `takenAt`, then the ad-hoc fallback
* (the `mayConvergeOntoSuppliedSlot` guard gating the convergence probe, else
* a standalone insert). Uses the real shipped helpers so the guard under test
* is the one shipped in the route.
*/
async function recordViaCard(
prisma: PrismaClient,
medId: string,
suppliedSlot: Date,
takenAt: Date,
): Promise<void> {
const attribution = await resolveSlotForWriteByBand({
userId: TEST_USER_ID,
medicationId: medId,
userTz: TZ,
takenAt,
now: takenAt,
});
const canonicalSlot = attribution.slotInstant;
if (canonicalSlot) {
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
return;
}
const existingSlotRow = mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt,
suppliedSlot,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: suppliedSlot,
deletedAt: null,
},
select: { id: true },
})
: null;
if (existingSlotRow) {
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: suppliedSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
} else {
// Genuinely standalone (ad-hoc): anchor on the intake instant.
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "WEB",
},
});
}
}
async function readDisplayDue(medId: string, now: Date): Promise<Date | null> {
const prisma = getPrismaClient();
const med = await prisma.medication.findUniqueOrThrow({
where: { id: medId },
include: { schedules: true },
});
// Mirror `buildMedsTodayBlock`: resolved slots = taken / skipped / autoMissed
// rows, each carrying its anchoring shape.
const resolvedEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: TEST_USER_ID,
medicationId: medId,
deletedAt: null,
OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }],
},
select: { scheduledFor: true, takenAt: true },
});
const display = computeDisplayDue({
medication: {
id: med.id,
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
},
schedules: med.schedules,
now,
userTz: TZ,
lastIntakeAt: null,
resolvedSlots: resolvedEvents.map(toResolvedSlotMark),
});
return display?.at ?? null;
}
describe("late-morning take does not consume the evening slot", () => {
it("records the 13:08 take standalone; the 21:00 slot stays pending", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
// Card shows 21:00 (09:00 catch-up lapsed); the tap posts scheduledFor=21:00.
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
deletedAt: null,
},
});
// The evening slot must NOT have been consumed by the morning take.
expect(eveningRow.takenAt).toBeNull();
expect(eveningRow.skipped).toBe(false);
// Exactly one taken row, anchored ad-hoc on its own 13:08 instant.
const takenRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: TEST_USER_ID,
medicationId: medId,
deletedAt: null,
takenAt: { not: null },
},
});
expect(takenRows).toHaveLength(1);
expect(takenRows[0]!.takenAt!.getTime()).toBe(takenAt1308().getTime());
// Ad-hoc contract: scheduledFor === takenAt (not a slot anchor).
expect(takenRows[0]!.scheduledFor.getTime()).toBe(takenAt1308().getTime());
// The morning slot stays open too (nothing filled it).
const morningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot0900(),
deletedAt: null,
},
});
expect(morningRow.takenAt).toBeNull();
});
it("next intake stays 21:00 today — it does not jump to tomorrow", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
const due = await readDisplayDue(medId, takenAt1308());
expect(due).not.toBeNull();
// The still-open evening slot today, NOT tomorrow's 09:00.
expect(due!.getTime()).toBe(slot2100().getTime());
});
it("the pre-fix convergence would have consumed 21:00 and jumped to tomorrow (mechanism)", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
// Reproduce the OLD behaviour explicitly: converge the 13:08 take onto the
// 21:00 pending row (what the ungated probe did).
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: slot2100(),
takenAt: takenAt1308(),
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
deletedAt: null,
},
});
// Confirms the bug shape: the evening slot carries the morning take.
expect(eveningRow.takenAt!.getTime()).toBe(takenAt1308().getTime());
// And the dashboard next-due jumps past today to tomorrow's 09:00.
const due = await readDisplayDue(medId, takenAt1308());
expect(due).not.toBeNull();
expect(due!.getTime()).toBeGreaterThan(slot2100().getTime());
const tomorrow0900 = localHmAsUtc(
new Date(DAY.getTime() + 24 * 60 * 60 * 1000),
TZ,
9,
0,
);
expect(due!.getTime()).toBe(tomorrow0900.getTime());
});
});