diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a90bfbdf..f31026925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.37.14] — 2026-08-13 + +### Fixed + +- The Today rail no longer announces a weekly medication as due up to a day early. A weekly dose becomes takeable a day ahead by design, and the rail treated takeable as due, so on Friday the Saturday slot already sat there as "Medication due". The rail now keeps a non-overdue dose off the list until its actual day in your own timezone; taking it early stays possible on the medication card, which says which day it belongs to. Overdue doses appear regardless, as before. +- A dose logged through the quick-add button updates the home page immediately. The quick-add is reachable from every page, but it only refreshed the reads of the page you were on, so coming back to the home page showed the dose as still due for up to two minutes. Logging from anywhere now refreshes the home page's daily reads the same way logging from the home page always did. + ## [1.37.13] — 2026-08-13 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ba89940e5..8562757f0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.13 + version: 1.37.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c1fb25fd3..461dd2c32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.13", + "version": "1.37.14", "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", diff --git a/public/sw.js b/public/sw.js index 993fcd97f..1d5aa27db 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.13"; + /* @sw-version-fallback */ "v1.37.14"; 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/*` diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index a466e4f23..8631c1c2d 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -72,10 +72,6 @@ function count(s: string, re: RegExp): number { * surface where the missing refetch is intentional, grouped by reason. */ const NO_REFETCH_ALLOWLIST = [ - // The digest is ACTIVE on the dashboard where the quick-add lives, so the - // default active invalidation already refetches it (audit B, §1: "not the - // bug"). - "components/dashboard/medication-intake-quick-add.tsx", // Injection-site metadata saved AFTER an already-recorded (and already // refetched) take — it does not change any dose's due/taken state. "components/medications/glp1-medication-card.tsx", diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 207c0865e..c953ec8e1 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -22,9 +22,17 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; +import type { QueryClient as QueryClientType } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { + invalidateMedicationReads, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; // Stub @tanstack/react-query so the SSR render path doesn't try to // reach into a real QueryClient. The default-stub returns the @@ -319,3 +327,58 @@ describe(" — SSR contract", () => { expect(de).toContain("Medikamente angelegt"); }); }); + +describe("submit invalidation — the quick-add is a capture-picker surface, not only a dashboard one", () => { + // This form mounts from the bottom-nav capture picker on EVERY page, so a + // bare `invalidateKeys(medicationDependentKeys)` (default active refetch) + // left the unmounted Today digest + dashboard snapshot stale until the next + // poll — the fourth recurrence of the class `daily-reads-refetch-guard` + // freezes. The fix routes the submit through the blessed + // `invalidateMedicationReads`, exactly like take-all-due (v1.32.19). + + const componentSrc = readFileSync( + resolve(__dirname, "../medication-intake-quick-add.tsx"), + "utf8", + ); + + it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => { + expect(componentSrc).toContain( + "await invalidateMedicationReads(queryClient);", + ); + expect(componentSrc).not.toContain( + "invalidateKeys(queryClient, medicationDependentKeys)", + ); + // The submit handler pairs the blessed helper with the inline + // compliance-chart fan-out AFTER the intake POST landed. + expect(componentSrc).toMatch( + /async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/, + ); + }); + + it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => { + // The helper the submit path awaits — asserted directly, same shape as + // the take-all-due suite: the bundle fans out (N keys), then the two + // daily reads are invalidated with `refetchType: "inactive"`, which is + // what actually refetches them while unmounted. + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const queryClient = { + invalidateQueries, + } as unknown as QueryClientType; + + await invalidateMedicationReads(queryClient); + + expect(invalidateQueries).toHaveBeenCalledTimes( + medicationDependentKeys.length + 2, + ); + const inactive = invalidateQueries.mock.calls.filter( + ([arg]) => + (arg as { refetchType?: string } | undefined)?.refetchType === + "inactive", + ); + const keys = inactive.map(([arg]) => + JSON.stringify((arg as { queryKey?: unknown }).queryKey), + ); + expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot())); + expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest())); + }); +}); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 7febbf9e6..dcc22cbd1 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -18,11 +18,7 @@ import { Loader2, Pill, Plus } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - medicationDependentKeys, - queryKeys, -} from "@/lib/query-keys"; +import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys"; import type { ScheduleWindowInput } from "@/lib/medications/window-status"; import { resolveMedicationSelectionId, @@ -61,8 +57,11 @@ import { * 3. Time taken. Defaults to `now` (local datetime-local format * shaped the same as `MoodForm`). * - * On success: invalidate `medicationDependentKeys` (medications + - * analytics + insights + achievements) PLUS the + * On success: `invalidateMedicationReads` (the bundle invalidation + * paired with the forced inactive refetch of the Today digest and the + * dashboard snapshot — this form also mounts from the capture picker on + * every page, where both daily reads are unmounted and a bare bundle + * invalidation would leave them stale until the next poll) PLUS the * inline per-medication compliance chart key so the detail page tile * refreshes if it happens to be mounted elsewhere in the app. * @@ -219,7 +218,7 @@ export function MedicationIntakeQuickAdd({ ); const eventId = created?.id; - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); // Fan-out to every inline compliance chart key (one per medication) // so the detail-page tile refreshes when the user re-enters it. await queryClient.invalidateQueries({ diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts index a3b294ee0..ba5b9e9a3 100644 --- a/src/lib/daily/__tests__/digest-just-in.test.ts +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -38,6 +38,7 @@ function meds(): MedsTodayBlock { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 239b8f55c..68709685d 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -64,6 +64,8 @@ const briefing: DailyBriefing = { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + // NOW is 09:00Z on 2026-07-16; UTC profile day ends at next midnight. + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, @@ -383,7 +385,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); - it("shows a weekly future slot once its engine-derived availability window opens", () => { + it("shows a weekly slot due later TODAY once its engine-derived availability window opens", () => { const d = buildDailyDigest( input({ medsToday: meds({ @@ -391,7 +393,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { { medicationId: "med-weekly", medicationName: "Weekly dose", - dueAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(), + // Later today (15:00Z, same local day as NOW). + dueAt: new Date(NOW.getTime() + 6 * 60 * 60_000).toISOString(), overdue: false, availableFrom: new Date(NOW.getTime() - 60_000).toISOString(), }, @@ -410,7 +413,8 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { const candidate = { medicationId: "med-weekly", medicationName: "Weekly dose", - dueAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(), + // Later today (19:00Z) so only the availability boundary decides. + dueAt: new Date(NOW.getTime() + 10 * 60 * 60_000).toISOString(), overdue: false, availableFrom: NOW.toISOString(), }; @@ -451,6 +455,111 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); }); + it("keeps a weekly slot due TOMORROW off the rail even when its availability window is open", () => { + // Regression: a weekly/rolling medication with the default window opens + // its availability ~25 h ahead of the anchor (1 day early-days + 60 min + // grace). On Friday the Saturday slot therefore became "available" and + // surfaced as today's pending business. A not-yet-overdue dose that is + // due on a LATER local day is a scheduled event, not today's card. + const d = buildDailyDigest( + input({ + medsToday: meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + // Tomorrow 08:00 — outside today's local day. + dueAt: "2026-07-17T08:00:00.000Z", + overdue: false, + // Window already open (now minus 1 h). + availableFrom: new Date( + NOW.getTime() - 60 * 60_000, + ).toISOString(), + }, + ], + }), + }), + t, + ); + expect(d.worthALook.some((i) => i.kind === "dose_window")).toBe(false); + }); + + it("bounds the rail on the PROFILE-tz day end, exclusive at the boundary instant", () => { + // A Berlin (CEST, UTC+2) profile day ends at 22:00:00Z — the seam passes + // that instant as `todayEndExclusive`, so the builder needs no tz math. + const berlinDayEnd = new Date("2026-07-16T22:00:00.000Z"); + const candidateDue = (dueAtIso: string) => + meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + dueAt: dueAtIso, + overdue: false, + availableFrom: new Date(NOW.getTime() - 60 * 60_000).toISOString(), + }, + ], + }); + + // 23:30 Berlin local — still today, stays on the rail. + const lateToday = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue("2026-07-16T21:30:00.000Z"), + }), + t, + ); + // 00:30 Berlin local TOMORROW (22:30Z) — off the rail. + const earlyTomorrow = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue("2026-07-16T22:30:00.000Z"), + }), + t, + ); + // Exactly local midnight — the bound is exclusive, so this is tomorrow. + const atBoundary = buildDailyDigest( + input({ + todayEndExclusive: berlinDayEnd, + medsToday: candidateDue(berlinDayEnd.toISOString()), + }), + t, + ); + + expect(lateToday.worthALook.some((i) => i.kind === "dose_window")).toBe( + true, + ); + expect(earlyTomorrow.worthALook.some((i) => i.kind === "dose_window")).toBe( + false, + ); + expect(atBoundary.worthALook.some((i) => i.kind === "dose_window")).toBe( + false, + ); + }); + + it("always keeps an OVERDUE candidate on the rail regardless of the day bound", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + dueCandidates: [ + { + medicationId: "med-weekly", + medicationName: "Weekly dose", + // Yesterday — engine says overdue; the day bound never applies. + dueAt: "2026-07-15T08:00:00.000Z", + overdue: true, + availableFrom: "2026-07-14T07:00:00.000Z", + }, + ], + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose).toBeDefined(); + expect(dose?.status).toBe("warning"); + }); + it("keeps a stale cached non-overdue scalar calm instead of dropping or escalating it", () => { const d = buildDailyDigest( input({ diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e51448a84..b98471e6c 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -245,6 +245,15 @@ export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000; /** The fully-resolved, IO-free input the composer folds into a digest. */ export interface DailyDigestInput { now: Date; + /** + * First instant of the user's NEXT local day (profile tz), resolved by the + * IO seam via `getUserTodayBounds`. Bounds the dose-window rail to TODAY'S + * pending business: a weekly/rolling medication's default availability + * window opens ~25 h ahead of its anchor (one early day + the grace + * minutes), so without this bound Friday's rail announced Saturday's slot + * as due. Overdue candidates are never bounded by it. + */ + todayEndExclusive: Date; modules: DigestModuleMap; /** Item kinds allowed to appear in the Today hero rail. */ enabledHeroItemKinds: readonly PriorityItemKind[]; @@ -399,11 +408,18 @@ export const DOSE_DUE_LOOKAHEAD_MS = * * `overdue` is never inferred from the wall clock. A cached non-overdue * candidate whose anchor has passed stays calm/informational until refresh. + * + * A NOT-yet-overdue candidate must also fall due on the user's current local + * day (`dueAt < todayEndExclusive`). The engine's availability window says + * "takeable" — for weekly/rolling schedules that opens up to ~25 h early, and + * takeable-early belongs on the medication card, not on today's rail. An + * overdue candidate always surfaces, whatever day its anchor was. */ function buildDoseWindowItems( meds: MedsTodayBlock, modules: DigestModuleMap, now: Date, + todayEndExclusive: Date, t: Translate, ): PriorityItem[] { if (!moduleEnabled(modules, "medications")) return []; @@ -429,6 +445,10 @@ function buildDoseWindowItems( const dueAt = candidate.dueAt ? Date.parse(candidate.dueAt) : NaN; if (!Number.isFinite(dueAt)) continue; + // Today-only: a dose anchored on a later local day is a scheduled + // event, not today's pending business — however early its window opens. + if (dueAt >= todayEndExclusive.getTime()) continue; + if (candidate.availableFrom !== null) { const availableFrom = Date.parse(candidate.availableFrom); if (!Number.isFinite(availableFrom) || now.getTime() < availableFrom) { @@ -911,7 +931,13 @@ export function buildDailyDigest( // resurfaces on a following day (within its window), never lost. const worthALook: PriorityItem[] = []; worthALook.push( - ...buildDoseWindowItems(input.medsToday, input.modules, input.now, t), + ...buildDoseWindowItems( + input.medsToday, + input.modules, + input.now, + input.todayEndExclusive, + t, + ), ); // A freshly-reached milestone is rare and one-per-day — surface it ahead of // the ambient sync / check-in items so the calm reward is not buried, but diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 5182159d9..7c830bfa9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -22,6 +22,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { isArrivalKind } from "@/lib/arrivals/types"; @@ -357,6 +358,12 @@ export async function loadDailyDigest( // Computed before the fan-out because the arrival read is keyed on it. Pure // (a tz format of `now`), so hoisting it costs nothing. const todayLocalDate = userDayKey(now, user.timezone); + // First instant of the user's NEXT local day (profile tz) — bounds the + // dose-window rail to today's business. `getUserTodayBounds` returns the + // inclusive last millisecond of today; +1 ms is exactly tomorrow's start. + const todayEndExclusive = new Date( + getUserTodayBounds(now, user.timezone).end.getTime() + 1, + ); const [ { body: snapshot, locale }, @@ -607,6 +614,7 @@ export async function loadDailyDigest( const digest = buildDailyDigest( { now, + todayEndExclusive, modules, enabledHeroItemKinds: options.enabledItemKinds ?? diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 312f3bf6d..59c08e10d 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -121,6 +121,7 @@ const NOW = new Date("2026-07-16T09:00:00.000Z"); function degradedDigest() { const input: DailyDigestInput = { now: NOW, + todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], score: { value: 82, band: "good", delta: 3 }, diff --git a/tests/integration/workout-detail-hr-series.test.ts b/tests/integration/workout-detail-hr-series.test.ts index fff801a84..e578ba906 100644 --- a/tests/integration/workout-detail-hr-series.test.ts +++ b/tests/integration/workout-detail-hr-series.test.ts @@ -61,9 +61,18 @@ vi.mock("@/lib/db-compat", () => ({ const { GET } = await import("@/app/api/workouts/[id]/route"); -/** Session window: 07:00 → 07:30 on a fixed day. */ -const STARTED_AT = new Date("2026-05-15T07:00:00.000Z"); -const ENDED_AT = new Date("2026-05-15T07:30:00.000Z"); +/** + * Session window: 07:00 → 07:30 UTC ten days ago. The pulse-window fallback + * only reconstructs a curve inside the dense-intraday retention horizon + * (`DENSE_INTRADAY_RETENTION_DAYS`, 90 days), so the fixture day must stay + * relative to the test run — a fixed calendar date silently aged past the + * horizon and turned every curve assertion red on day 91. + */ +const SESSION_DAY = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); +const STARTED_AT = new Date(`${SESSION_DAY}T07:00:00.000Z`); +const ENDED_AT = new Date(`${SESSION_DAY}T07:30:00.000Z`); const SPAN_SEC = 1800; /**