Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.37.21] — 2026-08-14

### Fixed

- A dose that reaches the server through the batch route, the path a phone's offline queue drains through, now quiets an active snooze the same way a directly logged dose always has. Before, a take synced in later kept counting as snoozed and the reminder kept ringing.
- Marking a dose through the single-medication intake route now also closes the still-pending dose-due reminder in the web app and refreshes its badge. The other intake paths have done this since v1.18.4; this one, which is exactly the route a phone's offline replay uses, left the web reminder standing.

## [1.37.20] — 2026-08-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.20
version: 1.37.21
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.20",
"version": "1.37.21",
"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",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.37.20";
/* @sw-version-fallback */ "v1.37.21";
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/*`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));

// The route dispatches the PWA clear fire-and-forget after a recorded dose;
// both halves are mocked so the test asserts the dispatch, not web-push.
vi.mock("@/lib/notifications/web-push-clear", () => ({
dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/outstanding-doses", () => ({
countOutstandingDosesToday: vi.fn(),
}));

vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
Expand Down Expand Up @@ -110,6 +119,8 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";

const USER = {
id: "user-1",
Expand Down Expand Up @@ -191,6 +202,32 @@ describe("POST intake — transport-derived source (iOS #64)", () => {
expect(createdSource()).toBe("API");
});

it("closes the pending dose-due web reminder after a recorded intake", async () => {
// Before this route dispatched the clear, an iOS offline drain (which
// replays free intakes through exactly this endpoint) left the web
// dose-due reminder and the app badge standing after the dose landed.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(countOutstandingDosesToday).mockResolvedValue(3);

const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);

// Fire-and-forget: the dispatch runs detached from the response.
await expect
.poll(
() => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length,
{ timeout: 2_000, interval: 25 },
)
.toBe(1);
expect(
vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0],
).toMatchObject({
userId: "user-1",
medicationId: "med-1",
badgeCount: 3,
});
});

it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);

Expand Down
18 changes: 18 additions & 0 deletions src/app/api/medications/[id]/intake/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
Expand Down Expand Up @@ -616,6 +618,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
originDeviceToken: request.headers.get("x-device-id"),
});

// PWA counterpart of the sync wake above: a dose resolved here (taken or
// skipped) closes the still-pending dose-due Web Push reminder for the
// slot and refreshes the app badge. The other intake routes have carried
// this since v1.18.4; this route is the replay target of the iOS offline
// queue, so a drained dose must clear the web reminder the same way.
// Best-effort, fire-and-forget — the canonical row is already persisted.
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor.toISOString(),
badgeCount,
});
})();

// v1.36.x — "somebody else marked your dose". This route has no snooze arm,
// so the state is whichever of the two markings the payload carried. The
// helper refuses on self, so a person marking their own dose is unaffected.
Expand Down
70 changes: 70 additions & 0 deletions src/app/api/medications/intake/bulk/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
// v1.8.2 — the slot resolver loads the med via findFirst.
findFirst: vi.fn(),
// A landed non-skipped dose clears the medication's snooze.
updateMany: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
Expand Down Expand Up @@ -324,6 +326,74 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
});
});

it("a landed taken write clears the medication's active snooze", async () => {
// The single-intake routes null `snoozedUntil` on every recorded take;
// before this parity fix a dose drained through the bulk route (the iOS
// offline-queue path) left the snooze standing and the reminder ringing.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
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-pending",
} as never);

const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
expect(prisma.medication.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["med-1"] }, userId: "user-1" },
data: { snoozedUntil: null },
});
});

it("a landed skip leaves the snooze untouched", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
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-pending",
} as never);

const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
skipped: true,
},
],
}),
);
expect(res.status).toBe(200);
expect(prisma.medication.updateMany).not.toHaveBeenCalled();
});

it("queues exactly ONE intake-sync fan-out for a multi-entry batch", async () => {
// Two pending rows at the day's two slots; the batch resolves both.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
Expand Down
15 changes: 15 additions & 0 deletions src/app/api/medications/intake/bulk/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
string,
{ medicationId: string; scheduledFor: string }
>();
// A landed non-skipped dose quiets an active snooze, exactly like the
// single-intake routes (they null `snoozedUntil` on every recorded take).
// Collected per medication here, cleared once after the loop.
const snoozeClearIds = new Set<string>();

for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
Expand Down Expand Up @@ -786,6 +790,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
if (!entry.skipped) snoozeClearIds.add(entry.medicationId);
}
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
Expand Down Expand Up @@ -909,6 +914,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
// device (`X-Device-Id` = registered `Device.token`). APNs-only,
// best-effort, fire-and-forget: the canonical rows are already
// persisted, so a sync-push miss never affects the batch response.
// The single-intake routes reset a medication's snooze on every recorded
// take; a drained offline batch must quiet the snooze the same way, or a
// dose synced in later keeps counting as snoozed although it was taken.
if (snoozeClearIds.size > 0) {
await prisma.medication.updateMany({
where: { id: { in: [...snoozeClearIds] }, userId: user.id },
data: { snoozedUntil: null },
});
}

if (syncSlots.size > 0) {
queueMedicationIntakeSync({
userId: user.id,
Expand Down
Loading