fix(daily-loop): invalidate the Today digest on measurement/mood/water writes

Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.

Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 00:37:24 +02:00
1 parent bf1c052797
commit 844b24c7ba
7 files changed
+125 -4

No files matched your search

@@ -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({
@@ -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) {
@@ -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);
},
+6 -1
View File
@@ -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) {
+10 -1
View File
@@ -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);
},
+42
View File
@@ -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",
});
});
});
+45
View File
@@ -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<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "inactive",
}),
queryClient.invalidateQueries({
queryKey: queryKeys.dailyDigest(),
refetchType: "inactive",
}),
]);
}