diff --git a/src/components/insights/nutrients/water-quick-add-sheet.tsx b/src/components/insights/nutrients/water-quick-add-sheet.tsx index 2b60136cd..78396e516 100644 --- a/src/components/insights/nutrients/water-quick-add-sheet.tsx +++ b/src/components/insights/nutrients/water-quick-add-sheet.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; /** * v1.29 — manual water quick-add sheet, opened from the hydration card. @@ -45,11 +45,21 @@ export function WaterQuickAddSheet({ const [editMode, setEditMode] = useState(false); const [editAmount, setEditAmount] = useState(() => String(todayTotalMl)); + // v1.29.x — the Today digest joins the invalidation. The write route + // already hard-evicts the server snapshot bucket + // (`invalidateUserDashboardSnapshot`), but the client digest/snapshot + // queries are typically unmounted while this sheet is open (the user is + // on `/insights/nutrients`, not the dashboard), so a default ("active") + // invalidation would mark them stale without refetching them — + // `refetchInactiveDailyReads` forces the inactive refetch, mirroring the + // measurement / mood / medication fix. const invalidate = () => { void queryClient.invalidateQueries({ queryKey: ["nutrients"] }); void queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), }); + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void refetchInactiveDailyReads(queryClient); }; const addMutation = useMutation({ diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index d415d1c8c..972036a3d 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -24,7 +24,11 @@ import { import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + measurementDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement"; @@ -326,6 +330,7 @@ export function MeasurementForm({ setPulse(""); setNotes(""); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 03cfed717..67ec1c0f6 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,6 +66,7 @@ import { invalidateKeys, measurementDependentKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import { MEASUREMENT_NOTES_MAX_LENGTH, @@ -544,6 +545,7 @@ export function MeasurementList({ try { await apiPost("/api/measurements/restore", { ids }); await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); } catch { toast.error(t("measurements.restoreError")); @@ -558,6 +560,7 @@ export function MeasurementList({ }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("measurements.deletedToast"), { action: { label: t("common.undo"), @@ -587,6 +590,7 @@ export function MeasurementList({ }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( t("measurements.bulkDeleteSuccess", { count: String(deleted) }), @@ -649,6 +653,7 @@ export function MeasurementList({ }, onSuccess: async () => { await invalidateKeys(queryClient, measurementDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce8d0e536..0e5443d49 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -37,7 +37,11 @@ import { import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; @@ -216,6 +220,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { resetForm(); setMoreTagsOpen(false); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); onSuccess?.(); } catch (err) { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 9fe2e15af..b8622fcc0 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -62,7 +62,12 @@ import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels"; -import { invalidateKeys, moodDependentKeys, queryKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + moodDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { moodSourceEnum } from "@/lib/validations/moodlog"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -268,6 +273,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { try { await apiPost("/api/mood-entries/restore", { ids }); await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); } catch { toast.error(t("mood.restoreError")); @@ -282,6 +288,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: (_data, id) => { void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); toast.success(t("mood.deletedToast"), { action: { label: t("common.undo"), @@ -309,6 +316,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async (deleted, ids) => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); clearSelection(); toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }), { action: { @@ -369,6 +377,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }, onSuccess: async () => { await invalidateKeys(queryClient, moodDependentKeys); + await refetchInactiveDailyReads(queryClient); setEditing(null); setEditError(null); }, diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 4539ec330..6a78c31df 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -7,6 +7,7 @@ import { moodDependentKeys, medicationDependentKeys, invalidateKeys, + refetchInactiveDailyReads, } from "../query-keys"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; @@ -262,6 +263,15 @@ describe("dependent-key bundles", () => { expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); }); + // v1.29.x — a manual measurement write never invalidated the Today + // digest, so its score / rail items lingered stale after a save from + // the values list or the dashboard quick-entry sheet. Mirror the + // v1.29.1 medication fix. + it("measurementDependentKeys bundles the daily digest", () => { + const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["daily", "digest"])); + }); + it("moodDependentKeys bundle covers mood + analytics + targets", () => { const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["mood-entries"])); @@ -279,6 +289,13 @@ describe("dependent-key bundles", () => { expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); }); + // v1.29.x — mirrors the measurement fix: a manual mood write never + // invalidated the Today digest. + it("moodDependentKeys bundles the daily digest", () => { + const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["daily", "digest"])); + }); + it("medicationDependentKeys bundle covers medications + analytics + achievements", () => { const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["medications"])); @@ -467,3 +484,28 @@ describe("invalidateKeys", () => { expect(results[2].status).toBe("fulfilled"); }); }); + +// v1.29.x — the shared helper behind the measurement / mood / water fix. +// `invalidateKeys` (default `refetchType: "active"`) only refetches MOUNTED +// queries, so the dashboard hero and Today digest — both typically unmounted +// while the user is on the measurement/mood/nutrients surface — are marked +// stale but never refetched. Mirrors the v1.29.1 medication-intake fix +// (`invalidateMedicationReads`); this is the shared version every other +// write seam now calls alongside its own `*DependentKeys` bundle. +describe("refetchInactiveDailyReads", () => { + it("forces an inactive refetch of both the dashboard snapshot and the Today digest", async () => { + const invalidate = vi.fn().mockResolvedValue(undefined); + const client = { invalidateQueries: invalidate } as unknown as QueryClient; + + await refetchInactiveDailyReads(client); + + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 29b98099f..4912ee132 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -92,6 +92,14 @@ export const measurementDependentKeys = [ queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), queryKeys.dashboardSnapshot(), + // v1.29.x — the Today digest reads the same snapshot ingredients a + // manual measurement write already hard-evicts server-side; joins the + // bundle so the digest's score / rail items refresh in lockstep with + // the tile strip. Call sites also force an inactive refetch (see + // `refetchInactiveDailyReads` below) since a default invalidation only + // refetches MOUNTED queries and the digest is typically unmounted + // while the user is on the measurement surface. + queryKeys.dailyDigest(), ["chart-data"] as const, // v1.8.5 — re-run the diversity-nudge clustering when readings change. ["measurement-diversity"] as const, @@ -127,6 +135,11 @@ export const moodDependentKeys = [ queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), queryKeys.dashboardSnapshot(), + // v1.29.x — mirrors the measurement fix above: the Today digest reads + // the same snapshot mood block a mood write already hard-evicts + // server-side. Call sites force an inactive refetch too (see + // `refetchInactiveDailyReads`). + queryKeys.dailyDigest(), ]; /** @@ -208,3 +221,35 @@ export function invalidateKeys( keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), ); } + +/** + * Force the dashboard snapshot AND the Today digest to refetch even while + * unmounted. `invalidateKeys` (and every `*DependentKeys` bundle above) marks + * a query stale with the default `refetchType: "active"`, which only + * refetches MOUNTED queries — so a write made from the measurement / mood / + * medication surface leaves the dashboard hero and the Today digest (both + * typically unmounted while the user is elsewhere) stale until a manual + * remount re-fetches under `refetchOnMount: false`. + * + * Originates from the v1.16.11 dashboard-snapshot fix and the v1.29.1 + * medication-intake fix (`invalidateMedicationReads` in + * `components/medications/use-medication-intake.ts`, which predates this + * shared helper and keeps its own inline copy). Call this alongside + * `invalidateKeys(queryClient, xDependentKeys)` from any write whose server + * route already hard-evicts the snapshot bucket — the forced refetch then + * returns post-write data immediately, no server change needed. + */ +export async function refetchInactiveDailyReads( + queryClient: QueryClient, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }), + ]); +}