refactor(query-keys): route remaining bare arrays through the factory

The target editor invalidated a bare ["insights"] array on save and reset.
That literal sat outside the centralised factory, so the broad insights
fan-out it intended never matched the reads, which key through
insightsTargets/insightsRoot. Route the editor's reads and invalidations
through userThresholds(), insightsRoot(), insightsTargets() and analytics()
so a saved target actually refreshes every insights surface.

Migrate the other call sites the lint rule does not reach onto the factory:
the Coach conversation rail + thread reads, the per-metric personal-records
badge, the per-day measurement drill-down, the dashboard intake
compliance-chart fan-out, the per-medication side-effects list, the
onboarding auth invalidations, the notification-preferences read, and the
PWA persister's auth-me lookup.

Add the matching factory keys: coachConversations, coachConversation,
personalRecordsByMetric, measurementDrilldown, complianceChartInline and
medicationSideEffects. Each new key reuses the prefix its reads already use
so no invalidation shape drifts.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 20:57:18 +02:00
1 parent 3e04035de8
commit bda06c9ec4
16 files changed
+80 -24

No files matched your search

@@ -281,7 +281,7 @@ export function MedicationIntakeQuickAdd({
// 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({
queryKey: ["compliance-chart-inline"],
queryKey: queryKeys.complianceChartInline(),
});
toast.success(t("common.saved"));
@@ -13,6 +13,7 @@ import type {
CoachSuggestion,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach
@@ -32,8 +33,8 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
*/
const QUERY_KEYS = {
list: () => ["coachConversations"] as const,
one: (id: string) => ["coachConversation", id] as const,
list: () => queryKeys.coachConversations(),
one: (id: string) => queryKeys.coachConversation(id),
};
/**
@@ -74,7 +75,7 @@ export function useCoachConversation(id: string | null) {
// never fires when `id` is null. Key directly on `id` (TanStack
// Query accepts `null` in the array) — the previous "null"
// placeholder branch was structurally equivalent dead code.
queryKey: ["coachConversation", id] as const,
queryKey: queryKeys.coachConversation(id),
queryFn: async (): Promise<CoachConversationDetailDTO> => {
if (!id) throw new Error("missing id");
return apiGet<CoachConversationDetailDTO>(`/api/insights/chat/${id}`, {
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
@@ -75,7 +76,7 @@ export function PersonalRecordBadge({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: ["personal-records", "by-metric", metricType],
queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet<PersonalRecordRow[] | null>(
@@ -1508,7 +1508,7 @@ function DayDrillDown({
const { t, locale } = useTranslations();
const { isAuthenticated } = useAuth();
const { data, isLoading, error } = useQuery({
queryKey: ["measurement-drilldown", type, dayKey],
queryKey: queryKeys.measurementDrilldown(type, dayKey),
queryFn: async () => {
const params = new URLSearchParams();
params.set("type", type);
@@ -33,6 +33,7 @@ import {
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
@@ -114,12 +115,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const formId = useId();
const listKey = [
"medications",
medicationId,
"side-effects",
"list",
] as const;
const listKey = queryKeys.medicationSideEffects(medicationId);
const { data, isLoading } = useQuery({
queryKey: listKey,
+2 -1
View File
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
@@ -141,7 +142,7 @@ export function BaselineForm() {
}
}
await apiPost("/api/onboarding/step", { step: 4 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/4");
} catch (err) {
const message =
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W14b-Content — onboarding step 1 (goals).
@@ -127,7 +128,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
step: 2,
goals: Array.from(selected),
});
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/2");
} catch (err) {
const message =
+2 -1
View File
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* Onboarding step 2 (source).
@@ -105,7 +106,7 @@ export function SourceCardGrid() {
setAdvancing(true);
try {
await apiPost("/api/onboarding/step", { step: 3 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/3");
} catch (err) {
const message =
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
@@ -152,7 +153,7 @@ export function WelcomeCarousel() {
});
}
await apiPost("/api/onboarding/step", { step: 1 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/1");
} catch (err) {
const message =
@@ -239,7 +239,7 @@ export function GettingStartedChecklist() {
});
const { data: notificationsData } = useQuery<NotificationsPreferences>({
queryKey: ["notifications", "preferences"],
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet("/api/notifications/preferences");
},
+2 -1
View File
@@ -45,6 +45,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -371,7 +372,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
if (user?.id) writeSessionDismissed(user.id);
try {
await apiPost("/api/onboarding/tour", { completed: true, outcome });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
} catch {
// Swallow — the session-dismiss flag prevents a re-open.
// Next visit will retry via the same code path.
+5 -5
View File
@@ -144,7 +144,7 @@ function TargetEditSheetBody({
// tone vs. the persisted override). The body only mounts when the
// dialog is open, so we don't need a separate `enabled` flag.
const { data: thresholdsData } = useQuery({
queryKey: ["user", "thresholds"],
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
return apiGet<ThresholdsApiResponse>("/api/user/thresholds");
},
@@ -227,10 +227,10 @@ function TargetEditSheetBody({
await apiPut("/api/user/thresholds", payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
toast.success(t("targets.edit.saveSuccess"));
onOpenChange(false);
},
@@ -253,10 +253,10 @@ function TargetEditSheetBody({
await apiDelete(`/api/user/thresholds?metric=${metric}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
toast.success(t("targets.edit.resetSuccess"));
onOpenChange(false);
},