Merge pull request #621 from MBombeck/release/v1.32.19

release/v1.32.19 — Today view refreshes immediately after a write, plus a guard so it cannot regress
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-24 14:13:30 +02:00
commit 1d3963bebf
22 files changed
+487 -85

No files matched your search

+31
View File
@@ -2,6 +2,37 @@
## [Unreleased]
## [1.32.19] — 2026-07-24
After you logged a dose, the Today view could keep showing it as still due
until you reloaded the page. The Today hero and the dashboard read from a cache
that refreshes on a timer, and a write made from another screen marked that
cache stale without actually re-reading it. The change reached the server, but
the screen you came back to still showed the old state for up to two minutes,
or until a hard refresh.
- **A dose you take now clears from Today at once.** This covers the "Take all
due" button on the medications page, editing or deleting a logged dose, and
importing intake history. The Today view and the dashboard now re-read the
moment the write lands instead of waiting for the timer.
- **A preventive-care reminder marked done leaves the Today rail immediately.**
Marking one off, editing it, or deleting it updates the Today view straight
away rather than after a refresh.
- **A lab reading you add updates its cards right away.** Adding a reading
refreshes that marker's assessment on the same page and the "what changed
since your last panel" card, instead of leaving them on their earlier text.
- **A briefing you regenerate shows up on Today without a reload.** The new
briefing now reaches the Today hero and the dashboard as soon as it is ready.
- **Saving a dashboard layout keeps its safeguard against a conflicting edit.**
A visit to the home page could leave the layout editor without the token that
guards a save, which quietly turned off the protection added in v1.32.16
against two edits overwriting each other. The editor now always loads its own
copy, so the guard stays on.
Thanks to @lutzkind, whose run of reports surfaced the stale Today view.
Refs #581
## [1.32.18] — 2026-07-24
When a Fitbit token was revoked or expired, the hourly sync noticed the failure
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.18
version: 1.32.19
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.32.18",
"version": "1.32.19",
"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.32.18";
/* @sw-version-fallback */ "v1.32.19";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,171 @@
/**
* Structural guard on the "stale Today hero / dashboard after a write" class.
*
* The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()`
* (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation
* that invalidates one of the daily-reads-bearing bundles
* (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`)
* with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a
* write made from a surface where the daily reads are UNMOUNTED (the medications
* page, a detail page, the mood/measurement forms) marks them stale but never
* refetches them. Returning to `/` then serves the pre-write cache until the
* poll ticks or the tab is hard-reloaded.
*
* The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its
* wrapper `invalidateMedicationReads`), which forces the two inactive reads to
* refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1
* digest, v1.32.19 "Take all due") because the bundle cannot express
* `refetchType`, so every new call site re-decides it by hand. This guard makes
* a fourth recurrence fail CI.
*
* Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show
* an allowlisted exception is correct — only that no NEW site invalidates a
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
* without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** The factory itself DEFINES the bundles + helpers; it is never a call site. */
const FACTORY = "lib/query-keys/index.ts";
/**
* A direct-bundle invalidation call site: `invalidateKeys(<qc>, <bundle>)` or
* `invalidateKeys(<qc>, [...<bundle>, <localKey>])`. Captures every form the
* codebase actually uses (bare bundle, or spread into an array with an extra
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
* match — the wrapper carries the refetch internally, so they are compliant by
* construction and correctly invisible here.
*/
const INVALIDATION =
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g;
// Match the CALL form only (open paren), never the bare import specifier —
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
// count and let a file drop one paired call while still "passing".
const REFETCH = /refetchInactiveDailyReads\(/g;
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function count(s: string, re: RegExp): number {
return (s.match(re) ?? []).length;
}
/**
* Files that invalidate a daily bundle WITHOUT pairing the inactive refetch,
* deliberately. Frozen: a new entry is a conscious decision that must land in a
* reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a
* 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",
"components/medications/medication-card.tsx",
// Pull-to-refresh: a current-surface read gesture on the medications page,
// not a hero-affecting write (audit B, B-5).
"app/medications/page-client.tsx",
// Medication configuration / metadata surfaces — rename, dose, schedule
// times, phase config, push preferences, API tokens, the create wizard.
// None records an intake; a schedule edit's next-due shift converges via the
// standard poll from a deep-config surface. Tighten in a later pass if a
// config surface is shown to strand the rail.
"components/medications/scheduling/schedule-times-editor.tsx",
"components/medications/sections/api-tokens-row.tsx",
"components/medications/sections/notifications-section.tsx",
"components/medications/sections/phase-config-sheet.tsx",
"components/medications/sections/settings-section.tsx",
"components/medications/wizard/medication-wizard-dialog.tsx",
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
].sort();
describe("daily-reads refetch guard", () => {
it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => {
const src = read(FACTORY);
// The one canonical entry point every intake surface routes through.
expect(src).toMatch(/export async function invalidateMedicationReads\(/);
// It must do BOTH — invalidate the bundle AND force the inactive refetch.
const body = src.slice(
src.indexOf("export async function invalidateMedicationReads("),
);
expect(body).toMatch(
/invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/,
);
expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/);
});
it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => {
// For each file that invalidates a daily bundle, the count of paired
// refetches must be at least the count of invalidations. A count rule (not
// a line-proximity heuristic) catches BOTH a new unpaired file AND a new
// unpaired call site slipped into an already-compliant file (its refetch
// count would then fall below its invalidation count).
const nonCompliant: string[] = [];
for (const rel of sourceFiles()) {
if (rel === FACTORY) continue;
const s = read(rel);
const invalidations = count(s, INVALIDATION);
if (invalidations === 0) continue;
const refetches = count(s, REFETCH);
if (refetches < invalidations) nonCompliant.push(rel);
}
// The set of deliberately-unpaired sites must be EXACTLY the allowlist:
// a new intake mutation that forgets the refetch appears here but not in
// the allowlist → fail; a paired site that loses its refetch drops into
// this set → fail; a deliberate new exception must be added above.
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
});
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
// as by the count assertion above.
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
];
for (const rel of routedThroughHelper) {
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
}
const pairInPlace = [
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history-add-dialog.tsx",
];
for (const rel of pairInPlace) {
expect(read(rel)).toMatch(REFETCH);
}
});
});
+14 -10
View File
@@ -14,7 +14,7 @@ import type {
DailyBriefing as DailyBriefingPayload,
TrendAnnotations,
} from "@/lib/ai/schema";
import { queryKeys } from "@/lib/query-keys";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
@@ -381,15 +381,19 @@ export function useInsightsAdvisorQuery(
// their query subtree so the per-section text below the advisor card
// re-fetches.
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
// v1.18.10 — the dashboard hero surfaces the briefing headline from the
// SNAPSHOT (server-lifted `User.insightsCachedText`), not from this
// advisor cache. A regenerate rewrites that server cache, so invalidate
// the snapshot too — otherwise returning to the Startseite shows the
// previous briefing under the greeting until the snapshot's own stale
// window lapses. Closes "the briefing doesn't update" on the dashboard.
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
});
// v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing
// headline from the SNAPSHOT (server-lifted `User.insightsCachedText`),
// and the Today hero's lead line is the SAME briefing lifted into the
// digest — neither is this advisor cache. A regenerate rewrites that
// server cache, so both the snapshot and the digest must refetch.
// Regenerate runs on `/insights`, where both are UNMOUNTED, so the
// earlier active-only snapshot invalidation only marked it stale (never
// refetched, `refetchOnMount: false`) and never touched the digest at
// all — returning to `/` then showed the previous briefing under the
// greeting AND in the Today lead for up to 120 s. `refetchInactiveDailyReads`
// forces both to refetch right here so the return paints the fresh
// briefing at once.
void refetchInactiveDailyReads(queryClient);
},
});
+19 -3
View File
@@ -93,7 +93,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
@@ -225,16 +225,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
// v1.32.19 — a new reading for this marker changes the same-page AI
// assessment card (`insightsBiomarkerAssessment`, mounted here) and the
// Insights "what changed since your last panel" read (`insightsLabsChanges`).
// Both used to stay on their pre-write text until their own stale window
// lapsed; bust them alongside the result list so the assessment repaints as
// soon as the reading lands.
function invalidateAfterReadingWrite() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
}
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
invalidateAfterReadingWrite();
}
// v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`,
// minus the close. Useful here too — logging several past readings for
// this one biomarker back-to-back without re-opening the sheet each time.
function afterAddKeepOpen() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
invalidateAfterReadingWrite();
}
function afterEditMarker() {
+7
View File
@@ -108,6 +108,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
// v1.32.19 — a lab reading edit / delete / restore shifts the two most
// recent panels the Insights "what changed since your last panel" card
// reads, so bust it too instead of leaving it on its pre-write deltas
// until the 60 s stale window lapses.
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
}
async function restore(id: string) {
@@ -467,13 +467,45 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith(
expect.stringContaining("successToast"),
);
// The landed take must reach every dependent consumer.
// The landed take must reach every dependent consumer AND force the two
// inactive daily reads (dashboard snapshot + Today digest) to refetch —
// the blessed `invalidateMedicationReads` fans out the bundle (N keys)
// then invalidates those two with `refetchType: "inactive"`.
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(
medicationDependentKeys.length,
medicationDependentKeys.length + 2,
);
vi.unstubAllGlobals();
});
it("forces the inactive Today hero + dashboard reads to refetch after a landed take (v1.32.19 stale-hero fix)", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-a"));
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
await runTakeAllDue({
medications: [dueTwo[0]],
t,
queryClient,
});
// The batch button lives on `/medications`, where the digest + snapshot
// are UNMOUNTED. A bare bundle invalidation (default active refetch) would
// leave them stale — so the helper MUST invalidate both with
// `refetchType: "inactive"`, which is what actually refetches them.
const calls = vi.mocked(queryClient.invalidateQueries).mock.calls;
const inactive = 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(["dashboard", "snapshot"]));
expect(keys).toContain(JSON.stringify(["daily", "digest"]));
vi.unstubAllGlobals();
});
it("reports total failure without invalidating (nothing changed server-side)", async () => {
vi.stubGlobal(
"fetch",
@@ -491,13 +523,15 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
vi.unstubAllGlobals();
});
it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => {
// The bundle includes the dashboard snapshot key (v1.16.11), so a
// batch take refreshes the hero dose tally too. Pin the source so a
// refactor to a hand-rolled key list cannot slip through.
it("routes the batch invalidation through the blessed invalidateMedicationReads (source pin)", () => {
// v1.32.19 — the batch take now routes through the one blessed helper,
// which fans out `medicationDependentKeys` AND forces the inactive digest
// + snapshot to refetch (the maintainer's stale-hero repro). Pin the
// source so a revert to a bare bundle invalidation cannot slip through.
const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8");
expect(src).toContain(
"await invalidateKeys(queryClient, medicationDependentKeys)",
expect(src).toContain("await invalidateMedicationReads(queryClient)");
expect(src).not.toContain(
"invalidateKeys(queryClient, medicationDependentKeys)",
);
});
});
@@ -28,7 +28,11 @@ import { Label } from "@/components/ui/label";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import type { QueryKey } from "@tanstack/react-query";
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
@@ -174,6 +178,10 @@ export function LedgerAddDialog({
...medicationDependentKeys,
ledgerKey,
]);
// A ledger add can land a today-dated dose, so clear the unmounted
// Today hero + dashboard too (v1.32.19 — same class as the ledger's
// own log/edit/delete sites).
await refetchInactiveDailyReads(queryClient);
setNudgeOpen(false);
onOpenChange(false);
} catch {
@@ -87,6 +87,7 @@ import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
@@ -295,6 +296,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
@@ -334,6 +339,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
} catch {
toast.error(t("medications.detail.intake.edit.failed"));
@@ -365,6 +374,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
toast.error(t("medications.detail.intake.edit.failed"));
}
@@ -380,6 +393,10 @@ export function DoseHistoryLedger({
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
toast.error(t("medications.detail.intake.deleteRow.failed"));
}
@@ -32,7 +32,7 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiPut } from "@/lib/api/api-fetch";
export interface IntakeEditDialogProps {
@@ -129,7 +129,10 @@ function IntakeEditDialogBody({
body.takenAt = null;
}
await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
// Editing today's dose (un-skip, retime) changes its due/taken state —
// route through the blessed helper so the Today hero + dashboard clear
// at once even when they are unmounted behind the detail page.
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
onClose();
} catch {
@@ -40,7 +40,7 @@ import {
type IntakeHistorySortKey,
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
@@ -94,7 +94,7 @@ export function IntakeHistoryEditable({
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
eventIds: ids,
});
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
clearSelection();
@@ -111,7 +111,7 @@ export function IntakeHistoryEditable({
const id = pendingDeleteId;
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
// Drop the deleted row from the pending selection so the
@@ -32,7 +32,7 @@ import {
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
MedicationIntakeImportError,
@@ -124,7 +124,10 @@ export function IntakeImportDialog({
: ""),
);
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
// An import can land today-dated doses, so clear the Today hero +
// dashboard through the blessed helper (they are unmounted behind the
// import dialog on the detail page).
void invalidateMedicationReads(queryClient);
} catch (err) {
if (err instanceof MedicationIntakeImportError) {
setResult(t("medications.importFailed"));
@@ -46,11 +46,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
@@ -89,7 +85,7 @@ export function LifecycleManageBody({
// v1.5.5 C-E3-2 — body carries `{ active }` only; server derives
// pausedAt. No client-side pausedAt literal.
await apiPut(`/api/medications/${medicationId}`, { active: !pauseNext });
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(
pauseNext
? t("medications.detail.zone.pause.pausedToast")
@@ -110,7 +106,7 @@ export function LifecycleManageBody({
try {
const today = new Date().toISOString();
await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
onAfterAction?.();
@@ -246,7 +242,7 @@ export function DangerZoneBody({
setTier3aBusy(true);
try {
await apiDelete(`/api/medications/${medicationId}/intake/purge`);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.purge.toast"));
setPurgeDialogOpen(false);
onAfterAction?.();
@@ -281,7 +277,7 @@ export function DangerZoneBody({
// Fire-and-forget the dependent-bundle invalidation (list /
// analytics / compliance / inline chart). Not awaited: navigation
// already happened and a transient refetch must not block it.
void invalidateKeys(queryClient, medicationDependentKeys);
void invalidateMedicationReads(queryClient);
} catch {
toast.error(t("medications.detail.zone.delete.failed"));
setTier3bBusy(false);
+11 -6
View File
@@ -39,7 +39,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
type ScheduleWindowInput,
@@ -230,12 +230,17 @@ export async function runTakeAllDue(deps: {
}
}
// One invalidation for the whole batch (the bundle includes the
// dashboard snapshot key, so the hero dose tally refreshes too). A
// zero-success run changed nothing server-side — skip the refetch and
// keep the due set as-is for the retry.
// One invalidation for the whole batch through the blessed helper: it
// fans out the dependent-key bundle AND forces the inactive digest +
// snapshot to refetch. The batch button lives on `/medications`, where the
// Today hero and dashboard are UNMOUNTED, so a bare bundle invalidation
// (default `refetchType: "active"`) marked them stale but never refetched —
// the taken doses stayed "due" on the hero until the 120 s poll or a hard
// reload (the maintainer's exact repro, v1.32.19). A zero-success run
// changed nothing server-side — skip the refetch and keep the due set for
// the retry.
if (taken > 0) {
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
}
if (failed === 0) {
@@ -5,44 +5,18 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
/**
* Invalidate every read that reflects a dose's taken/due state after an
* intake write. `invalidateKeys` invalidates with the default
* `refetchType: "active"`, which only refetches mounted queries — so the
* dashboard snapshot AND the Today digest, both inactive while the user is on
* the medication card or detail page, are marked stale but never refetched. On
* navigating back each remounts under `refetchOnMount: false`, so the pre-write
* cache is served and the "due" prompt (snapshot dose tally) plus the digest's
* dose-window rail item both linger until a hard reload. Force the inactive
* queries to refetch so the dashboard clears as soon as the dose is recorded.
*
* v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday`
* reads the SAME server snapshot cell the intake route already hard-evicts, so
* the refetch returns post-write dose state immediately (no server change
* needed — the eviction seam is reused).
* Every intake write in this hook clears the Today hero + dashboard through
* the one blessed `invalidateMedicationReads` (in `@/lib/query-keys`): it
* pairs the `medicationDependentKeys` bundle invalidation with a forced
* inactive refetch of the digest + snapshot, so a taken / skipped / undone
* dose repaints the moment it lands rather than on the 120 s poll. The helper
* used to live inline here (v1.29.1); v1.32.19 lifted it into the factory as
* the single canonical entry point every intake surface routes through.
*/
async function invalidateMedicationReads(
queryClient: QueryClient,
): Promise<void> {
await invalidateKeys(queryClient, medicationDependentKeys);
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "inactive",
}),
queryClient.invalidateQueries({
queryKey: queryKeys.dailyDigest(),
refetchType: "inactive",
}),
]);
}
type Translator = (
key: string,
@@ -238,6 +238,21 @@ export function DashboardLayoutSection({ id }: { id: string }) {
queryFn: async () => {
return apiGet<DashboardLayoutWithToken>("/api/dashboard/widgets");
},
// v1.32.19 (B-3, belt for the #581 fix) — the home RSC seeds
// `dashboardWidgets()` from the dashboard SNAPSHOT layout, which carries
// NO `updatedAt` optimistic-concurrency token (it is a token-less
// `DashboardLayout`, not the `DashboardLayoutWithToken` this section's GET
// returns). Within the global 5-min `staleTime` a visit to `/` then Settings
// read that seeded token-less value with no mount refetch, so `readBaseToken()`
// returned undefined, the Save sent no `baseUpdatedAt`, and the route's
// compat fallback silently reverted to last-write-wins — disabling the
// just-shipped 409 belt. Force a mount refetch so this section always holds
// its own tokened GET response and every write carries the base token. The
// smallest correct option: the alternatives (a distinct seed key, or
// threading the token through the snapshot layout) touch the home client and
// the server snapshot shape; this is one line, config-independent, and makes
// the settings query authoritative for its own tokened read.
refetchOnMount: "always",
});
/**
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from "vitest";
import type { QueryClient } from "@tanstack/react-query";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
/**
* v1.32.19 — a Vorsorge reminder marked done (or created / edited / deleted)
* is the exact Hero-dose analog on the Today rail. Its mutations run
* `invalidateReminderReads`, which must NOT only invalidate the reminders root
* (that leaves the digest + dashboard snapshot stale-but-unrefetched while the
* user is on the Vorsorge section) but ALSO force those two inactive daily
* reads to refetch — otherwise the reminder lingers on the Today rail until
* the 120 s poll. This pins the actual refetch behaviour, not just an
* invalidation.
*/
function fakeQueryClient(): QueryClient {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
} as unknown as QueryClient;
}
describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract", () => {
it("invalidates the reminders root AND forces the inactive daily reads to refetch", async () => {
const qc = fakeQueryClient();
await invalidateReminderReads(qc);
const calls = vi.mocked(qc.invalidateQueries).mock.calls;
// The reminders root (drives the section list + the dashboard tile).
const reminderKeys = calls.map(([arg]) =>
JSON.stringify((arg as { queryKey?: unknown }).queryKey),
);
expect(reminderKeys).toContain(JSON.stringify(["measurement-reminders"]));
// The two daily reads must be invalidated with `refetchType: "inactive"` —
// the ONLY variant that refetches them while unmounted on the Vorsorge
// surface. A plain (active) invalidation would repeat the stale-hero bug.
const inactive = calls.filter(
([arg]) =>
(arg as { refetchType?: string } | undefined)?.refetchType ===
"inactive",
);
const inactiveKeys = inactive.map(([arg]) =>
JSON.stringify((arg as { queryKey?: unknown }).queryKey),
);
expect(inactiveKeys).toContain(JSON.stringify(["dashboard", "snapshot"]));
expect(inactiveKeys).toContain(JSON.stringify(["daily", "digest"]));
});
});
+29 -4
View File
@@ -9,10 +9,36 @@
* tile repaint in lockstep. The DTO is the server-authoritative shape —
* the client renders `nextDueAt` as-is, never recomputing.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
/**
* v1.32.19 — the read fan-out every Vorsorge reminder mutation runs on
* success. Extracted (and exported) so it is unit-testable against a real
* `QueryClient` without a React render, mirroring the medication intake
* helpers. A reminder is the exact Hero-dose analog on the Today rail:
* creating, editing, deleting or marking one done changes what the digest
* surfaces as due. Marking it done from the section (or the tile) leaves the
* digest + dashboard snapshot UNMOUNTED, so invalidating only the reminders
* root marked the daily reads stale but never refetched them
* (`refetchOnMount: false`) — the reminder lingered on the Today rail until
* the 120 s poll. Force the inactive daily reads to refetch alongside the
* reminders invalidation so the rail clears at once, the same freshness
* contract the medication intake surfaces use.
*/
export function invalidateReminderReads(qc: QueryClient): Promise<unknown> {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }),
refetchInactiveDailyReads(qc),
]);
}
export interface MeasurementReminder {
id: string;
@@ -68,8 +94,7 @@ export function useMeasurementReminders(enabled = true) {
export function useMeasurementReminderMutations() {
const qc = useQueryClient();
const invalidate = () =>
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() });
const invalidate = () => invalidateReminderReads(qc);
const create = useMutation({
mutationKey: queryKeys.measurementReminderCreate(),
+9
View File
@@ -43,6 +43,15 @@ export function useDailyDigest(enabled = true) {
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: "always",
// B-4 (INTENDED, not a bug): a dose logged on ANOTHER device (iOS, a
// Telegram intake, a background sync) has no web invalidation channel, so
// an open web tab converges on it only via this 120 s foreground poll or
// the window-focus "always" refetch above — a bounded ≤120 s window on an
// idle tab. This is deliberate and is NOT the v1.32.19 stale-hero class,
// which was an in-tab write on the SAME device that marked these reads
// stale without refetching them (fixed via `invalidateMedicationReads` /
// `refetchInactiveDailyReads`). Do not conflate the two: a same-device
// action must clear the hero at once; a cross-device one may lag the poll.
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
// One retry on transient network / 5xx (never 401/403) so a single
+34
View File
@@ -253,3 +253,37 @@ export async function refetchInactiveDailyReads(
}),
]);
}
/**
* The ONE blessed entry point for any intake-affecting medication write. It
* pairs the dependent-key bundle invalidation with the forced inactive
* refetch so a taken / skipped / edited / deleted / imported dose clears the
* Today hero and the dashboard the instant it is recorded — never after the
* 120 s poll or a hard reload.
*
* Route EVERY medication mutation through this instead of calling
* `invalidateKeys(queryClient, medicationDependentKeys)` directly. The bundle
* cannot express `refetchType`, so a bare invalidation only refetches MOUNTED
* queries; the digest and snapshot are typically unmounted while the user is
* on the medications page (the "Take all due" repro) or a detail page, so
* they were marked stale but never refetched. This helper closes that gap in
* one call. The class it closes — a bundle invalidation that never refetches
* the unmounted daily reads — has recurred three times (v1.16.11, v1.29.1,
* v1.32.19); `daily-reads-refetch-guard.test.ts` now fails CI if a new site
* invalidates the bundle without the paired refetch.
*
* The server intake routes already hard-evict the `${userId}|` analytics
* prefix, so the refetch this triggers returns post-write data immediately —
* no server change is needed at any call site.
*
* Sites that need to invalidate an EXTRA key alongside the bundle (the
* dose-history ledger's own window key) keep their `invalidateKeys([...bundle,
* localKey])` call and add `refetchInactiveDailyReads(queryClient)` directly;
* the guard accepts either form.
*/
export async function invalidateMedicationReads(
queryClient: QueryClient,
): Promise<void> {
await invalidateKeys(queryClient, medicationDependentKeys);
await refetchInactiveDailyReads(queryClient);
}