From bda06c9ec4ba98f7f7c47019d14197d8eb0d1e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 20:57:18 +0200 Subject: [PATCH] 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. --- .../dashboard/medication-intake-quick-add.tsx | 2 +- .../insights/coach-panel/use-coach.ts | 7 ++++--- .../insights/personal-record-badge.tsx | 3 ++- .../measurements/measurement-list.tsx | 2 +- .../medications/SideEffectsSection.tsx | 8 ++------ src/components/onboarding/BaselineForm.tsx | 3 ++- src/components/onboarding/GoalsChipPicker.tsx | 3 ++- src/components/onboarding/SourceCardGrid.tsx | 3 ++- src/components/onboarding/WelcomeCarousel.tsx | 3 ++- .../onboarding/getting-started-checklist.tsx | 2 +- src/components/onboarding/tour-launcher.tsx | 3 ++- src/components/targets/target-edit-sheet.tsx | 10 +++++----- src/lib/pwa/query-persister.ts | 6 +++++- src/lib/query-keys/coach.ts | 14 ++++++++++++++ src/lib/query-keys/measurements.ts | 19 +++++++++++++++++++ src/lib/query-keys/medications.ts | 16 ++++++++++++++++ 16 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index b85d45fa2..25d7f1c4a 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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")); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 3aa7058c9..46c92bf2f 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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 => { if (!id) throw new Error("missing id"); return apiGet(`/api/insights/chat/${id}`, { diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx index fa87dd6a4..00d3562a7 100644 --- a/src/components/insights/personal-record-badge.tsx +++ b/src/components/insights/personal-record-badge.tsx @@ -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( diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 899baa6ba..51765bbdb 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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); diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx index b21ee6d25..834437e05 100644 --- a/src/components/medications/SideEffectsSection.tsx +++ b/src/components/medications/SideEffectsSection.tsx @@ -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(null); const formId = useId(); - const listKey = [ - "medications", - medicationId, - "side-effects", - "list", - ] as const; + const listKey = queryKeys.medicationSideEffects(medicationId); const { data, isLoading } = useQuery({ queryKey: listKey, diff --git a/src/components/onboarding/BaselineForm.tsx b/src/components/onboarding/BaselineForm.tsx index d7a27ec56..f4bed1707 100644 --- a/src/components/onboarding/BaselineForm.tsx +++ b/src/components/onboarding/BaselineForm.tsx @@ -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 = diff --git a/src/components/onboarding/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx index 488acd625..6a9d46e1c 100644 --- a/src/components/onboarding/GoalsChipPicker.tsx +++ b/src/components/onboarding/GoalsChipPicker.tsx @@ -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 = diff --git a/src/components/onboarding/SourceCardGrid.tsx b/src/components/onboarding/SourceCardGrid.tsx index 635f20a6e..85e53cd6d 100644 --- a/src/components/onboarding/SourceCardGrid.tsx +++ b/src/components/onboarding/SourceCardGrid.tsx @@ -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 = diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx index 986a666f7..fde5a41bc 100644 --- a/src/components/onboarding/WelcomeCarousel.tsx +++ b/src/components/onboarding/WelcomeCarousel.tsx @@ -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 = diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 821c21469..750580612 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -239,7 +239,7 @@ export function GettingStartedChecklist() { }); const { data: notificationsData } = useQuery({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); }, diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 02a7e5869..43bc22475 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -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. diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index c43bfec75..27df912ae 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -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("/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); }, diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 302926f17..b1fdc9c1d 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -32,6 +32,8 @@ import { type QueryClient, } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; + const DB_NAME = "healthlog-query-cache"; const STORE = "kv"; const KEY = "react-query"; @@ -143,7 +145,9 @@ interface PersistedPayload { /** Read the current account id from the live `["auth","me"]` cache entry. */ function currentUserId(queryClient: QueryClient): string | null { - const me = queryClient.getQueryData<{ id?: string } | null>(["auth", "me"]); + const me = queryClient.getQueryData<{ id?: string } | null>( + queryKeys.authMe(), + ); return me?.id ?? null; } diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index 61ffa23a2..ed7d6c6b3 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -13,4 +13,18 @@ export const coachKeys = { coachNudgeStatus: () => ["coach-nudge-status"] as const, /** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */ coachMarkSeen: () => ["coach-mark-seen"] as const, + /** + * v1.18.7 — the Coach conversation rail list (`GET /api/insights/chat`). + * Previously a hook-local bare `["coachConversations"]`; centralised so + * the optimistic delete and the streaming hook's post-turn invalidation + * share one factory-routed key. + */ + coachConversations: () => ["coachConversations"] as const, + /** + * v1.18.7 — one decrypted Coach conversation (`GET /api/insights/chat/[id]`). + * Keyed on the conversation id so two open threads never share a cache slot; + * the streaming hook invalidates this slot once the persisted twin lands. + */ + coachConversation: (id: string | null) => + ["coachConversation", id] as const, }; diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 3fa99852e..f1cf9936e 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -126,6 +126,25 @@ export const measurementKeys = { measurementDiversity: (type: string) => ["measurement-diversity", type] as const, + /** + * v1.18.7 — per-day drill-down list for one `MeasurementType` + * (`GET /api/measurements?type=&dayKey=`), opened from a values-list + * day row. Keyed by `(type, dayKey)` so each expanded day caches its own + * slot. Distinct `["measurement-drilldown"]` prefix (not `["measurements"]`) + * because the day list is a stable historical slice that the drain has + * already settled — it does not need a measurement-write fan-out. + */ + measurementDrilldown: (type: string, dayKey: string) => + ["measurement-drilldown", type, dayKey] as const, + + /** + * v1.18.7 — per-metric personal-records read (`GET /api/personal-records`) + * backing the decorative PR badge on a metric tile. Per-metric key so two + * tiles never share a cache slot; its own `["personal-records"]` prefix. + */ + personalRecordsByMetric: (metric: string) => + ["personal-records", "by-metric", metric] as const, + /** * v1.18.7 (Wave E) — bounded last-7 readings for one `MeasurementType`, * powering the discreet 7-day trend strip on a Vorsorge card. Rides under diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 8a5ae44ca..429f5ac08 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -8,6 +8,22 @@ export const medicationKeys = { medicationDetail: (id: string) => ["medications", id] as const, medicationComplianceChart: (medicationId: string) => ["compliance-chart-inline", medicationId] as const, + /** + * v1.18.7 — the bare `["compliance-chart-inline"]` prefix, for fan-out + * invalidation across every per-medication inline compliance tile in one + * tick (e.g. a dashboard quick-add intake). `medicationComplianceChart(id)` + * above keys an individual tile; this routes the parent prefix through the + * factory so the fan-out is not a bare array at the call site. + */ + complianceChartInline: () => ["compliance-chart-inline"] as const, + /** + * v1.18.7 — per-medication side-effects list, last 30 days + * (`GET /api/medications/[id]/side-effects`). Rides under the + * `["medications", id, …]` prefix so a CRUD mutation reaches it through + * `medicationDependentKeys`. + */ + medicationSideEffects: (medicationId: string) => + ["medications", medicationId, "side-effects", "list"] as const, /** * v1.4.42 W3-QUERYKEY-LONGTAIL — per-medication compliance KPI used * by `medication-card` + `glp1-medication-card`. Centralising lets