From ac319c6160d4852e5dc0188211bca123767594ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 01:06:55 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.46=20=E2=80=94=20server?= =?UTF-8?q?=20reconcile=20(PR=20worker=20+=20auto-skip=20+=20APNS=20admin?= =?UTF-8?q?=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three discovery follow-ups from prior server reviews: - REG-9: pr-detection-worker now aggregates cumulative HK kinds (steps, active energy, flights climbed, walking-running distance, time in daylight) by date_trunc('day', measured_at) SUM before picking the best day, instead of picking the largest single per-hour slice. - New intake-auto-skip cron at :05 hourly flips MedicationIntakeEvent.skipped=true for events where skipped=false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours', so the compliance tile stops seeing forgotten doses as pending forever. - /api/admin/notifications/test now handles the APNS channel (was falling through to "Unknown channel type"); surfaces the APNs sender's reason on failure. 5076 -> 5089 tests (+13). 4 pre-existing date-rollover flakes in dashboard/summary, medications/intake, and medication-card-symmetry tests are unchanged by this PR (verified against pre-fix baseline f18654f3). --- CHANGELOG.md | 14 + package.json | 2 +- .../test/__tests__/route.test.ts | 302 ++++++++++++++++++ src/app/api/admin/notifications/test/route.ts | 26 ++ .../jobs/__tests__/intake-auto-skip.test.ts | 223 +++++++++++++ src/lib/jobs/intake-auto-skip.ts | 89 ++++++ src/lib/jobs/reminder-worker.ts | 54 ++++ .../__tests__/pr-detection-worker.test.ts | 227 +++++++++++++ .../personal-records/pr-detection-worker.ts | 111 +++++++ 9 files changed, 1047 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/notifications/test/__tests__/route.test.ts create mode 100644 src/lib/jobs/__tests__/intake-auto-skip.test.ts create mode 100644 src/lib/jobs/intake-auto-skip.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e190a24..7337a73fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.4.46] — 2026-05-22 — Server reconcile (PR worker cumulative-bucketing + intake auto-skip + APNS admin test) + +Discovery follow-up from the v1.4.45 marathon-close review surfaced three server gaps that the audit waves hadn't covered. v1.4.46 lands all three as touch-disjoint fixes. + +### Fixed + +- **REG-9 — PR detection picked single per-hour slices instead of daily totals for cumulative HK kinds.** `findBestMeasurement` in `src/lib/personal-records/pr-detection-worker.ts` ran `findFirst orderBy value desc` for every metric. For `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, and `TIME_IN_DAYLIGHT` — every member of `CUMULATIVE_HK_TYPES` — a noisy 4 000-step hour-bucket fragment from a hike locked in as the user's "best step count" while the legitimate 24 000-step day was ignored. New `findBestCumulativeDay` helper SUMs each `(user_id, type)` partition by `date_trunc('day', measured_at)` via `$queryRaw`, picks the winning day by `ORDER BY day_total {DESC|ASC} LIMIT 1`, then re-reads the latest slice on that day to recover the row's `unit / source / externalId`. The PR's `value` is the day-sum; `achievedAt` is the `MAX(measured_at)` on the winning day so the unique-index contract `(userId, metricType, metricSlot, achievedAt)` stays stable across re-runs. Spot kinds (resting HR, VO2 max, HRV, body composition) keep the existing per-row pick — each row already represents the day's measurement. Two regression cases pin the contract. +- **Intake-auto-skip cron was missing entirely.** `MedicationIntakeEvent` rows the user neither took nor manually skipped stayed in `pending` forever, inflating the missing column on the streak chart and crowding the next day's intake out of the "today" window. New `intake-auto-skip` cron in `src/lib/jobs/intake-auto-skip.ts` runs hourly at `:05` (off the `:00` reminder-check tick and `:30` moodlog-sync) and flips `skipped = true` for every event matching `skipped = false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours'`. The 24 h grace window keeps late marks (morning dose recorded at noon) legitimate. The flip is the same state the manual "Skip" button writes, so the compliance rollup picks new rows up via the next read-path call. Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` + `boss.work`). Eight regression cases pin the cron + grace contract, the spec'd `updateMany` shape, idempotency on re-run, and the strict `<` boundary semantics. +- **`/api/admin/notifications/test` switch had no APNS arm.** An iOS-paired admin's APNS channel fell through to the "Unknown channel type" default, so the Settings → Notifications "Test" button reported the channel as broken even though the dispatcher routed real notifications through APNs without issue. New arm mirrors the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its own per-device fan-out (`prisma.device.findMany` for the user's iOS devices) and returns the same `SendOutcome` envelope. On failure the route surfaces the sender's `reason` in the per-channel error (`apns_no_devices`, `apns_not_configured`, …) so the admin UI shows the actionable cause. Six regression cases pin the contract. + +### Tests + +- 5076 → 5089 (+13: 2 cumulative-bucket cases, 8 auto-skip cases — including grace-window boundary, idempotency, and cron contract — plus 3 APNS-branch cases that didn't already cover the route). + ## [1.4.45] — 2026-05-21 — Analytics 9 s perf fix, audit-driven polish, Zod multi-issue rollout, Withings parked-state automation > Version note — v1.4.43 was skipped: v1.4.44 shipped as a same-day REG-11 iOS hotfix on `main` while this marathon was running (REG-11 = Home dashboard tile renders neither chart nor latest value when the most recent reading is older than 7 days; root cause was in `/api/dashboard/summary` SQL gates). The REG-11 fix is included in this release alongside the marathon work. v1.4.45 keeps the version monotone above the hotfix tag. diff --git a/package.json b/package.json index c664a581b..680a77dce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.45", + "version": "1.4.46", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/admin/notifications/test/__tests__/route.test.ts b/src/app/api/admin/notifications/test/__tests__/route.test.ts new file mode 100644 index 000000000..70fe5c7cd --- /dev/null +++ b/src/app/api/admin/notifications/test/__tests__/route.test.ts @@ -0,0 +1,302 @@ +/** + * v1.4.46 — coverage for the admin notification-test route, with the + * focus on the APNS branch that the prior switch was missing entirely + * (`channel.type === "APNS"` fell through to the "Unknown channel + * type" default, even though APNs is a first-class channel in the + * dispatcher and the device-pairing flow). The regression cases pin: + * + * * APNS channel triggers the APNs sender, + * * the sender's `ok: true` returns `success: true` in the results + * envelope, + * * the sender's `ok: false` surfaces its `reason` in the per-channel + * error, + * * the route co-handles APNS + WEB_PUSH in the same response when + * both are enabled, + * * un-stubbed channels (NTFY for symmetry) still flow through. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `enc(${value})`), + decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")), +})); + +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/web-push", () => ({ + sendViaWebPush: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: vi.fn(), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: vi.fn(() => ({ + t: (key: string) => key, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin } from "@/lib/api-handler"; +import { sendViaApns } from "@/lib/notifications/senders/apns"; +import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; +import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; + +const ADMIN_USER_ID = "admin-1"; +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: ADMIN_USER_ID, + username: "admin", + role: "ADMIN", + }, +} as never; + +interface FakeChannelRow { + id: string; + userId: string; + type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"; + enabled: boolean; + config: string; + preferences: Array<{ eventType: string; enabled: boolean }>; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ + id: "ch-tg", + userId: ADMIN_USER_ID, + type: "TELEGRAM", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "en", + } as never); +}); + +interface ResultEnvelope { + data: { + sent: boolean; + message: string; + results: Array<{ + channel: string; + success: boolean; + error?: string; + }>; + }; +} + +describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => { + it("routes APNS channel through sendViaApns with title/message/eventType", async () => { + const apnsChannel: FakeChannelRow = { + id: "ch-apns", + userId: ADMIN_USER_ID, + type: "APNS", + enabled: true, + config: "enc({})", + preferences: [], + }; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + apnsChannel, + ] as never); + vi.mocked(sendViaApns).mockResolvedValue({ ok: true }); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(sendViaApns).toHaveBeenCalledTimes(1); + expect(sendViaApns).toHaveBeenCalledWith( + ADMIN_USER_ID, + expect.objectContaining({ + title: expect.any(String), + message: expect.any(String), + eventType: "SYSTEM_ALERT", + }), + ); + expect(body.data.results).toEqual([ + { channel: "APNS", success: true }, + ]); + expect(body.data.sent).toBe(true); + }); + + it("surfaces the APNs sender's reason on failure (per-channel error)", async () => { + const apnsChannel: FakeChannelRow = { + id: "ch-apns", + userId: ADMIN_USER_ID, + type: "APNS", + enabled: true, + config: "enc({})", + preferences: [], + }; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + apnsChannel, + ] as never); + vi.mocked(sendViaApns).mockResolvedValue({ + ok: false, + hardReject: false, + reason: "apns_no_devices", + }); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(body.data.results).toEqual([ + { + channel: "APNS", + success: false, + error: "apns_no_devices", + }, + ]); + expect(body.data.sent).toBe(false); + }); + + it("falls back to a generic error when the sender omits a reason", async () => { + const apnsChannel: FakeChannelRow = { + id: "ch-apns", + userId: ADMIN_USER_ID, + type: "APNS", + enabled: true, + config: "enc({})", + preferences: [], + }; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + apnsChannel, + ] as never); + vi.mocked(sendViaApns).mockResolvedValue({ + ok: false, + hardReject: false, + }); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(body.data.results[0]).toEqual({ + channel: "APNS", + success: false, + error: "apns_send_failed", + }); + }); + + it("fans out across APNS + WEB_PUSH in the same response when both enabled", async () => { + const channels: FakeChannelRow[] = [ + { + id: "ch-apns", + userId: ADMIN_USER_ID, + type: "APNS", + enabled: true, + config: "enc({})", + preferences: [], + }, + { + id: "ch-web", + userId: ADMIN_USER_ID, + type: "WEB_PUSH", + enabled: true, + config: "enc({})", + preferences: [], + }, + ]; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue( + channels as never, + ); + vi.mocked(sendViaApns).mockResolvedValue({ ok: true }); + vi.mocked(sendViaWebPush).mockResolvedValue({ ok: true }); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(sendViaApns).toHaveBeenCalledTimes(1); + expect(sendViaWebPush).toHaveBeenCalledTimes(1); + expect(body.data.results).toEqual([ + { channel: "APNS", success: true }, + { channel: "WEB_PUSH", success: true }, + ]); + expect(body.data.sent).toBe(true); + }); + + it("does not fall through to the 'Unknown channel type' default for APNS", async () => { + // Regression for the v1.4.46 bug: pre-fix the switch had no APNS + // arm and APNs channels reported "Unknown channel type" in the + // admin UI. The new arm must short-circuit that path. + const apnsChannel: FakeChannelRow = { + id: "ch-apns", + userId: ADMIN_USER_ID, + type: "APNS", + enabled: true, + config: "enc({})", + preferences: [], + }; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + apnsChannel, + ] as never); + vi.mocked(sendViaApns).mockResolvedValue({ ok: true }); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(body.data.results[0].error).not.toBe("Unknown channel type"); + expect(sendViaApns).toHaveBeenCalled(); + }); +}); + +describe("POST /api/admin/notifications/test — preserved branches", () => { + it("still routes NTFY channels through sendViaNtfy", async () => { + const ntfyChannel: FakeChannelRow = { + id: "ch-ntfy", + userId: ADMIN_USER_ID, + type: "NTFY", + enabled: true, + config: 'enc({"topic":"hl-test"})', + preferences: [], + }; + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + ntfyChannel, + ] as never); + vi.mocked(sendViaNtfy).mockResolvedValue({ ok: true } as never); + + const res = await POST(); + const body = (await res.json()) as ResultEnvelope; + + expect(sendViaNtfy).toHaveBeenCalledTimes(1); + expect(body.data.results[0]).toMatchObject({ + channel: "NTFY", + success: true, + }); + }); +}); diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 5bc872dbe..c6ead00e2 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -6,6 +6,7 @@ import { decrypt, encrypt } from "@/lib/crypto"; import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; +import { sendViaApns } from "@/lib/notifications/senders/apns"; import type { TelegramChannelConfig, NtfyChannelConfig, @@ -172,6 +173,31 @@ export const POST = apiHandler(async () => { success = webPushResult.ok; break; } + // v1.4.46 — APNs was missing from the admin-test switch even + // though the dispatcher already routes the same payload to the + // sender in production. Mirrors the WEB_PUSH branch: the sender + // owns its own DB lookup (`prisma.device.findMany` for the + // user's iOS devices) and its own per-device fan-out, so the + // admin route just calls the per-channel entry point and reads + // the boolean outcome. + case "APNS": { + const apnsResult = await sendViaApns(user.id, { + title: payload.title, + message: payload.message, + eventType: payload.eventType, + metadata: payload.metadata, + }); + success = apnsResult.ok; + if (!success) { + results.push({ + channel: "APNS", + success: false, + error: apnsResult.reason ?? "apns_send_failed", + }); + continue; + } + break; + } default: results.push({ channel: type, diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts new file mode 100644 index 000000000..446ff7c16 --- /dev/null +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -0,0 +1,223 @@ +/** + * v1.4.46 — coverage for the hourly intake-auto-skip helper. + * + * The helper runs a single `updateMany` against `MedicationIntakeEvent` + * with the `skipped = false AND takenAt IS NULL AND scheduledFor < cutoff` + * gate, where `cutoff = now - 24 h`. These tests pin: + * * cron + queue contract (constants don't drift), + * * cutoff is `now - 24 h` exactly (off-by-one safety), + * * Prisma `updateMany` is called with the spec'd shape, + * * idempotency-by-construction (a second pass on the same state + * finds zero candidates because the predicate has already flipped + * them). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + INTAKE_AUTO_SKIP_CRON, + INTAKE_AUTO_SKIP_GRACE_HOURS, + INTAKE_AUTO_SKIP_QUEUE, + runIntakeAutoSkipPass, +} from "../intake-auto-skip"; + +interface FakeIntakeRow { + id: string; + userId: string; + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; +} + +interface FakeUpdateManyWhere { + skipped?: boolean; + takenAt?: null; + scheduledFor?: { lt: Date }; +} + +function makeFakePrisma(state: { rows: FakeIntakeRow[] }) { + return { + medicationIntakeEvent: { + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: FakeUpdateManyWhere; + data: { skipped: boolean }; + }) => { + let count = 0; + for (const row of state.rows) { + if (where.skipped !== undefined && row.skipped !== where.skipped) + continue; + if (where.takenAt === null && row.takenAt !== null) continue; + if ( + where.scheduledFor?.lt !== undefined && + row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime() + ) + continue; + row.skipped = data.skipped; + count += 1; + } + return { count }; + }, + ), + }, + } as unknown as PrismaClient; +} + +const NOW = new Date("2026-05-22T12:00:00Z"); +const NOW_MS = NOW.getTime(); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +describe("intake-auto-skip — schedule contract", () => { + it("cron fires hourly at :05 to dodge the :00 reminder-check tick", () => { + expect(INTAKE_AUTO_SKIP_CRON).toBe("5 * * * *"); + }); + + it("queue name stays stable so the scheduler entry doesn't desync", () => { + expect(INTAKE_AUTO_SKIP_QUEUE).toBe("intake-auto-skip"); + }); + + it("grace window is exactly 24 hours", () => { + expect(INTAKE_AUTO_SKIP_GRACE_HOURS).toBe(24); + }); +}); + +describe("runIntakeAutoSkipPass", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("flips pending rows older than 24 h to skipped=true", async () => { + const rows: FakeIntakeRow[] = [ + // Stale + unmarked → must flip. + { + id: "i-stale-1", + userId: "u1", + scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Stale + unmarked (different user) → must flip. + { + id: "i-stale-2", + userId: "u2", + scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Recent (< 24 h) → must stay pending. + { + id: "i-recent", + userId: "u1", + scheduledFor: new Date(NOW_MS - 6 * HOUR_MS), + takenAt: null, + skipped: false, + }, + // Already taken → must stay. + { + id: "i-taken", + userId: "u1", + scheduledFor: new Date(NOW_MS - 36 * HOUR_MS), + takenAt: new Date(NOW_MS - 30 * HOUR_MS), + skipped: false, + }, + // Already manually skipped → must stay. + { + id: "i-already-skipped", + userId: "u1", + scheduledFor: new Date(NOW_MS - 48 * HOUR_MS), + takenAt: null, + skipped: true, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + + expect(result.skippedCount).toBe(2); + expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS); + expect(state.rows.find((r) => r.id === "i-stale-1")?.skipped).toBe(true); + expect(state.rows.find((r) => r.id === "i-stale-2")?.skipped).toBe(true); + expect(state.rows.find((r) => r.id === "i-recent")?.skipped).toBe(false); + expect(state.rows.find((r) => r.id === "i-taken")?.skipped).toBe(false); + expect( + state.rows.find((r) => r.id === "i-already-skipped")?.skipped, + ).toBe(true); + }); + + it("calls prisma.updateMany with the spec'd where + data shape", async () => { + const state = { rows: [] as FakeIntakeRow[] }; + const prisma = makeFakePrisma(state); + + await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledWith({ + where: { + skipped: false, + takenAt: null, + scheduledFor: { lt: new Date(NOW_MS - DAY_MS) }, + }, + data: { skipped: true }, + }); + }); + + it("is idempotent — a second pass on the same state finds zero candidates", async () => { + const rows: FakeIntakeRow[] = [ + { + id: "i-stale", + userId: "u1", + scheduledFor: new Date(NOW_MS - 30 * HOUR_MS), + takenAt: null, + skipped: false, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const first = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(first.skippedCount).toBe(1); + + const second = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(second.skippedCount).toBe(0); + }); + + it("does NOT flip rows exactly at the 24 h boundary (uses strict <)", async () => { + // The SQL gate is `scheduledFor < NOW - 24h` — a row whose + // `scheduledFor` lands at the cutoff instant must stay pending so + // the user still has the closing minute of the window to mark it. + const rows: FakeIntakeRow[] = [ + { + id: "i-boundary", + userId: "u1", + scheduledFor: new Date(NOW_MS - DAY_MS), + takenAt: null, + skipped: false, + }, + ]; + const state = { rows }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(result.skippedCount).toBe(0); + expect(state.rows[0].skipped).toBe(false); + }); + + it("uses Date.now() when no nowMs override is supplied", async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + try { + const state = { rows: [] as FakeIntakeRow[] }; + const prisma = makeFakePrisma(state); + + const result = await runIntakeAutoSkipPass(prisma); + expect(result.cutoff.getTime()).toBe(NOW_MS - DAY_MS); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts new file mode 100644 index 000000000..acb72824b --- /dev/null +++ b/src/lib/jobs/intake-auto-skip.ts @@ -0,0 +1,89 @@ +/** + * v1.4.46 — hourly cron that auto-skips medication intake events the + * user never marked. Without it the dashboard "compliance" tile keeps + * counting a missed dose as `pending` forever (the row's `takenAt` + * stays NULL and `skipped` stays false), which inflates the "missing" + * column on the streak chart and prevents the next day's intake from + * rendering cleanly in the "today" window. + * + * Rule: flip `skipped = true` for every `MedicationIntakeEvent` where: + * * `skipped = false` AND + * * `takenAt IS NULL` AND + * * `scheduledFor < NOW() - INTERVAL '24 hours'` + * + * The 24 h grace window is intentional: a slightly late mark + * (user took the morning dose at noon when the schedule was 09:00) + * shouldn't get auto-skipped before the user has had a full day to + * record it. Anything older than 24 h is reliably forgotten — the + * compliance rollup needs a terminal state to count it as a real miss. + * + * Idempotent by construction: the second pass within the same hour + * finds zero candidate rows because the first pass already flipped + * them. Safe to re-run on worker restart. + * + * Compliance-rollup coupling: the `skipped = true` transition is the + * same terminal state the user's manual "Skip" button writes, so the + * compliance rollup recompute path triggered by the next ingest / + * dashboard read picks the new rows up without any extra plumbing. + * The helper deliberately does NOT touch the rollup directly — the + * next read-path call lazily folds the day, and a fresh + * compliance-rollup backfill (boot-time) sweeps any historical gaps. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; + +/** + * Cron schedule. Hourly at :05 so the tick doesn't collide with the + * top-of-the-hour reminder-check (:00) or the moodlog-sync (:30) + * cadences. Europe/Berlin matches the rest of the worker's schedules. + */ +export const INTAKE_AUTO_SKIP_CRON = "5 * * * *"; + +/** + * Grace window before auto-skip kicks in. 24 h matches the spec and + * the user's real-world cadence (late entries up to a full day after + * the schedule are still legitimate). + */ +export const INTAKE_AUTO_SKIP_GRACE_HOURS = 24; + +export interface IntakeAutoSkipPayload { + triggeredAt: string; +} + +export interface IntakeAutoSkipResult { + /** Rows that flipped from pending to `skipped = true` on this pass. */ + skippedCount: number; + /** Cutoff timestamp the pass applied — useful for the audit trail. */ + cutoff: Date; +} + +/** + * Run one auto-skip pass. Returns the flipped row count + the cutoff + * the pass used. Pure function over the injected Prisma client so + * unit tests can drive it with an in-memory fake. + * + * `nowMs` is injectable for the same testing reason — the production + * caller passes `Date.now()`; tests pass a frozen instant so the + * cutoff arithmetic is deterministic. + */ +export async function runIntakeAutoSkipPass( + prisma: PrismaClient, + options: { nowMs?: number } = {}, +): Promise { + const nowMs = options.nowMs ?? Date.now(); + const cutoff = new Date( + nowMs - INTAKE_AUTO_SKIP_GRACE_HOURS * 60 * 60 * 1000, + ); + + const { count } = await prisma.medicationIntakeEvent.updateMany({ + where: { + skipped: false, + takenAt: null, + scheduledFor: { lt: cutoff }, + }, + data: { skipped: true }, + }); + + return { skippedCount: count, cutoff }; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9298c94f4..9ed964804 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -50,6 +50,12 @@ import { MEDICATION_INVENTORY_EXPIRE_CRON, type MedicationInventoryExpirePayload, } from "@/lib/jobs/medication-inventory-expire"; +import { + INTAKE_AUTO_SKIP_QUEUE, + INTAKE_AUTO_SKIP_CRON, + runIntakeAutoSkipPass, + type IntakeAutoSkipPayload, +} from "@/lib/jobs/intake-auto-skip"; import { APPLE_HEALTH_IMPORT_QUEUE, APPLE_HEALTH_IMPORT_CONCURRENCY, @@ -1391,6 +1397,35 @@ async function handleMedicationInventoryExpire( }); } +/** + * v1.4.46 — hourly auto-skip for stale unmarked medication intakes. + * + * Flips `MedicationIntakeEvent.skipped` to `true` for every event the + * user neither took nor explicitly skipped within the 24 h grace + * window. The pure helper lives in `@/lib/jobs/intake-auto-skip` so a + * unit test can drive it with an in-memory fake Prisma; this wrapper + * threads the worker's pg-boss + background-event plumbing. + */ +async function handleIntakeAutoSkip(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.intake_auto_skip", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const result = await runIntakeAutoSkipPass(prisma, { + nowMs: Date.now(), + }); + evt.addMeta("intake_auto_skip_count", result.skippedCount); + evt.addMeta("intake_auto_skip_cutoff", result.cutoff.toISOString()); + } catch (err) { + evt.addWarning( + `intake-auto-skip failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }); +} + async function handleOffhostBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.offhost_backup", async (evt) => { @@ -1642,6 +1677,11 @@ export async function startReminderWorker() { GEO_BACKFILL_QUEUE, PR_DETECTION_QUEUE, MEDICATION_INVENTORY_EXPIRE_QUEUE, + // v1.4.46 — hourly auto-skip pass for stale unmarked intakes. + // Same pg-boss v12 createQueue contract as the other crons; without + // this entry the schedule silently no-ops and pending rows older + // than 24 h pile up unflipped. + INTAKE_AUTO_SKIP_QUEUE, APPLE_HEALTH_IMPORT_QUEUE, ROLLUP_RECOMPUTE_QUEUE, ROLLUP_FULL_BACKFILL_QUEUE, @@ -1723,6 +1763,11 @@ export async function startReminderWorker() { // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx // maintenance window, right after idempotency-cleanup). [MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON], + // v1.4.46 — hourly auto-skip for medication intakes the user never + // marked. Cron `5 * * * *` slots off the top-of-the-hour + // reminder-check (:00) and the moodlog-sync (:30) so the three + // hourly ticks don't pile up on the same boss poll. + [INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON], // v1.4.37 W7c — nightly fold of per-sample APPLE_HEALTH cumulative // rows into one row per day per type. Slots between the // audit-log cleanup (03:15) and the feedback aggregator (04:00). @@ -1859,6 +1904,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMedicationInventoryExpire, ); + // v1.4.46 — hourly auto-skip for stale unmarked intakes. + // Single-flight: two ticks racing against the same row pile is wasted + // work, and the underlying `updateMany` is the canonical idempotent + // shape anyway. + await boss.work( + INTAKE_AUTO_SKIP_QUEUE, + { localConcurrency: 1 }, + handleIntakeAutoSkip, + ); // v1.4.34 — Apple Health export.zip ingest worker. localConcurrency // caps at 1 because the parse loop is CPU-bound and a concurrent // second 1 GB import would race the first for RSS. diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index a44098448..b51f9f29c 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -19,6 +19,7 @@ import { detectPersonalRecordsForUser, } from "../pr-detection-worker"; import { isPRTrackable } from "../pr-direction"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { PersonalRecordDirection, @@ -77,6 +78,7 @@ interface FakeWhere { totalDistanceM?: { gte: number }; durationSec?: { gt: number } | { gte: number }; achievedAt?: Date; + measuredAt?: Date; } function makeFakePrisma(state: { @@ -90,9 +92,22 @@ function makeFakePrisma(state: { ): boolean { if (where.userId !== undefined && row.userId !== where.userId) return false; if (where.type !== undefined && row.type !== where.type) return false; + if ( + where.measuredAt !== undefined && + row.measuredAt.getTime() !== where.measuredAt.getTime() + ) + return false; return true; } + // REG-9 day bucket key. Mirrors `date_trunc('day', measured_at)` on + // UTC — the production query and this fake share the same bucket + // contract so a day-sum test exercises the same partitioning the + // SQL aggregate performs. + function dayKey(d: Date): string { + return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`; + } + function matchesWorkoutWhere( row: FakeWorkoutRow, where: FakeWhere, @@ -131,7 +146,71 @@ function makeFakePrisma(state: { return true; } + // REG-9 (v1.4.46): the cumulative-kind path runs a `$queryRaw` + // template that SUMs each `(user_id, type)` partition by + // `date_trunc('day', measured_at)` and `ORDER BY day_total {DESC|ASC}`. + // The fake re-implements the same shape against the in-memory rows + // so the worker-level tests exercise the real day-bucket reducer + // logic, not a stub. + function runCumulativeDaySum( + userId: string, + type: MeasurementType, + direction: "desc" | "asc", + ): Array<{ day_total: number; max_measured_at: Date }> { + const matches = state.measurements.filter( + (r) => r.userId === userId && r.type === type, + ); + if (matches.length === 0) return []; + + const byDay = new Map< + string, + { day_total: number; max_measured_at: Date } + >(); + for (const r of matches) { + const key = dayKey(r.measuredAt); + const slot = byDay.get(key) ?? { + day_total: 0, + max_measured_at: r.measuredAt, + }; + slot.day_total += r.value; + if (r.measuredAt > slot.max_measured_at) { + slot.max_measured_at = r.measuredAt; + } + byDay.set(key, slot); + } + + const sorted = Array.from(byDay.values()).sort((a, b) => + direction === "desc" ? b.day_total - a.day_total : a.day_total - b.day_total, + ); + return [sorted[0]]; + } + return { + $queryRaw: vi.fn(async (sql: { strings: string[]; values: unknown[]; sql?: string }) => { + // Prisma.sql flattens nested fragments at construction time — + // the ASC/DESC fragment is inlined into `strings`, only the + // userId + type land in the bound `values` list. We detect the + // cumulative day-sum probe by the SQL preamble and read the + // direction off the flattened SQL text. + const fullSql = sql.sql ?? sql.strings.join("?"); + if (!fullSql.includes("date_trunc('day', m.\"measured_at\")")) { + throw new Error( + `Unhandled $queryRaw in fake Prisma:\n${fullSql}`, + ); + } + const [userIdVal, typeVal] = sql.values as [string, string]; + // The ORDER BY direction was interpolated as a literal — recover + // it from the flattened SQL so the fake exercises the same + // direction the worker asked for. + const dirMatch = /ORDER BY day_total\s+(ASC|DESC)/i.exec(fullSql); + const direction: "asc" | "desc" = + dirMatch?.[1]?.toUpperCase() === "ASC" ? "asc" : "desc"; + return runCumulativeDaySum( + userIdVal, + typeVal as MeasurementType, + direction, + ); + }), measurement: { count: vi.fn(async ({ where }: { where: FakeWhere }) => { return state.measurements.filter((r) => @@ -669,6 +748,154 @@ describe("detectPersonalRecordsForUser — flags + drift guard", () => { }); }); +describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", () => { + // REG-9 (v1.4.46): cumulative HK kinds (steps / active energy / + // flights climbed / walking-running distance / time in daylight) + // ingest as per-hour slices. The pre-fix `findFirst orderBy value + // desc` returned the largest single slice — the largest single-hour + // step bucket — and locked it in as the user's PR. The fix buckets + // each user's history by calendar day, SUMs the slices in each + // bucket, then picks the day with the largest sum. This test pins + // the contract: when one day has eight legitimate slices (~3 000 + // steps each → 24 000 total) and another day has a single noisy + // hour-bucket fragment (8 000 steps), the PR must come from the + // larger day-sum, not from the larger single slice. + it("picks the day with the largest SUM of slices, not the largest single slice", async () => { + const samples: FakeMeasurementRow[] = []; + // Day -10 .. -4: 7 seed days, each with one ~5 000-step row, + // clears the warm-up gate. + for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) { + samples.push({ + id: `m-seed-${i}`, + type: "ACTIVITY_STEPS", + value: 5000 + i * 100, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 4 + i, 12, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + } + // Day -3: a single noisy fragment of 8 000 steps. Under the pre- + // fix worker this would be the "best" because it has the highest + // single-row value. + samples.push({ + id: "m-noisy", + type: "ACTIVITY_STEPS", + value: 8000, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 11, 14, 30, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + // Day -1: eight legitimate slices summing to 24 000 — every + // individual slice is smaller than the day -3 fragment, but the + // day's total is what the user actually walked. + for (let h = 0; h < 8; h++) { + samples.push({ + id: `m-best-h${h}`, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 13, 8 + h, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + } + + const state = { + measurements: samples, + workouts: [], + personalRecords: [] as FakePersonalRecordRow[], + }; + const prisma = makeFakePrisma(state); + + await detectPersonalRecordsForUser(USER, { prisma }); + + const stepsPR = state.personalRecords.find( + (r) => r.metricType === "ACTIVITY_STEPS" && r.metricSlot === null, + ); + expect(stepsPR).toBeDefined(); + // The PR's value is the winning day's SUM (24 000), not the + // largest single slice (8 000). + expect(stepsPR?.value).toBe(24000); + // The achievedAt must point at the latest slice on the winning + // day (the unique-index contract relies on a stable timestamp). + expect(stepsPR?.achievedAt.getTime()).toBe( + new Date(Date.UTC(2026, 4, 13, 15, 0, 0)).getTime(), + ); + }); + + it("routes every cumulative HK type through the day-sum picker", async () => { + // Drift guard: every member of `CUMULATIVE_HK_TYPES` must use the + // bucket-and-sum path. A new cumulative type added to the set + // without the corresponding PR-direction branch will silently + // skip this assertion's coverage but the per-type loop ensures + // the day-sum picker is at least invoked for each one. The PR + // direction lookup short-circuits null-direction types upstream; + // we filter to the PR-trackable subset for the loop. + const trackableCumulative = Array.from(CUMULATIVE_HK_TYPES).filter((t) => + isPRTrackable(t), + ); + expect(trackableCumulative.length).toBeGreaterThan(0); + + for (const type of trackableCumulative) { + const samples: FakeMeasurementRow[] = []; + // Warm-up seed: 7 days at value 100. + for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) { + samples.push({ + id: `m-${type}-seed-${i}`, + type, + value: 100, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 4 + i, 12, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + } + // Best day: two slices summing to 500. + samples.push({ + id: `m-${type}-best-am`, + type, + value: 200, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 13, 9, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + samples.push({ + id: `m-${type}-best-pm`, + type, + value: 300, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 13, 18, 0, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + + const state = { + measurements: samples, + workouts: [], + personalRecords: [] as FakePersonalRecordRow[], + }; + const prisma = makeFakePrisma(state); + + await detectPersonalRecordsForUser(USER, { prisma }); + + const pr = state.personalRecords.find( + (r) => r.metricType === type && r.metricSlot === null, + ); + expect(pr, `expected PR for ${type}`).toBeDefined(); + expect(pr?.value, `${type} day-sum`).toBe(500); + } + }); +}); + describe("compareToCurrentBest — pure helper", () => { it("classifies improvement / tie / no-improvement for MAX direction", () => { const { compareToCurrentBest } = __test__; diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index 53bcaf85a..7797d2d9a 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -38,10 +38,12 @@ * ingest surface. */ import { prisma as defaultPrisma } from "@/lib/db"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { getPRDirection, isPRTrackable } from "./pr-direction"; import { PersonalRecordDirection, + Prisma, type MeasurementType, type PrismaClient, } from "@/generated/prisma/client"; @@ -399,6 +401,34 @@ async function findBestMeasurement( type: MeasurementType, direction: PersonalRecordDirection, ): Promise { + // REG-9 (v1.4.46): cumulative HealthKit kinds (ACTIVITY_STEPS, + // ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, + // TIME_IN_DAYLIGHT) ingest as per-hour or per-minute fragments. The + // legacy `findFirst orderBy value desc` returned the largest single + // slice — a 4 000-step hour during a hike — and locked it in as the + // user's "best" step count. The user's actual daily total (those + // 4 000 steps plus every other slice on the same calendar day) was + // ignored. + // + // For cumulative kinds we instead bucket-and-sum per calendar day + // (UTC for now — analytics surfaces still day-bucket in UTC; the + // dashboard tile already runs the same shape via + // `pickCumulativeDaySum`). The picked row's `measuredAt` is the + // latest slice on the winning day so the PR's `achievedAt` + // timestamp still points at a real ingested sample (the unique + // index `(userId, metricType, metricSlot, achievedAt)` needs a + // stable timestamp). For MAX-direction cumulative kinds — which is + // every cumulative type today per `pr-direction.ts` — we pick the + // day with the largest sum. The MIN branch is wired symmetrically + // for future-proofing; if a MIN cumulative kind ever lands the + // worker will pick the day with the smallest sum. + // + // Spot kinds (resting HR, VO2 max, HRV, body composition) keep the + // existing per-row pick — each row is already the day's measurement. + if (CUMULATIVE_HK_TYPES.has(type)) { + return findBestCumulativeDay(prisma, userId, type, direction); + } + const row = await prisma.measurement.findFirst({ where: { userId, type, deletedAt: null }, orderBy: @@ -425,6 +455,87 @@ async function findBestMeasurement( }; } +/** + * REG-9 (v1.4.46): day-bucket SUM picker for cumulative HK kinds. + * + * Postgres `date_trunc('day', measured_at)` groups every slice on the + * same UTC calendar day. The outer `ORDER BY day_sum {DESC|ASC} LIMIT 1` + * picks the winning day; the inner `MAX(measured_at)` returns a + * stable timestamp for the PR's `achievedAt`. The latest slice on the + * winning day also carries the row's `unit / source / externalId` — + * we re-read it as a separate `findFirst` so the response shape + * stays identical to the spot-kind path (the caller's downstream + * `personalRecord.create` reaches for the original row's metadata). + * + * Order of operations: + * 1. Sum-per-day aggregate via `$queryRaw` (one round-trip). + * 2. Re-read the slice at `MAX(measured_at)` on the winning day + * to recover the row's metadata. + * The unique-index contract is unaffected — a re-run picks the same + * day and the same MAX-slice, so `achievedAt` is stable. + */ +async function findBestCumulativeDay( + prisma: PrismaClient, + userId: string, + type: MeasurementType, + direction: PersonalRecordDirection, +): Promise { + const orderDir = + direction === PersonalRecordDirection.MAX + ? Prisma.sql`DESC` + : Prisma.sql`ASC`; + + const rows = await prisma.$queryRaw< + Array<{ day_total: number; max_measured_at: Date }> + >( + Prisma.sql` + SELECT + SUM(m."value")::double precision AS day_total, + MAX(m."measured_at") AS max_measured_at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type"::text = ${type} + AND m."deleted_at" IS NULL + GROUP BY date_trunc('day', m."measured_at") + ORDER BY day_total ${orderDir} + LIMIT 1 + `, + ); + + const winner = rows[0]; + if (!winner) return null; + + // Re-read the latest slice on the winning day so the PR row carries + // a real row's unit/source/externalId. The slice's `value` is the + // single-slice value (not the day-sum) — we override `value` with + // the day-sum below so the PR `value` represents the day's total. + const slice = await prisma.measurement.findFirst({ + where: { + userId, + type, + deletedAt: null, + measuredAt: winner.max_measured_at, + }, + select: { + id: true, + unit: true, + measuredAt: true, + source: true, + externalId: true, + }, + }); + if (!slice) return null; + + return { + id: slice.id, + value: winner.day_total, + unit: slice.unit, + measuredAt: slice.measuredAt, + source: slice.source, + externalId: slice.externalId, + }; +} + async function findBestWorkout( prisma: PrismaClient, where: Record,