Merge wave-qol-mechanical: route stray query keys through the factory

Fix the target-edit-sheet invalidation that used a bare ["insights"] array
not matching the factory prefix the reads nest under (a silent no-op), and
migrate 12 further bare queryKey call sites onto the centralised factory.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 20:58:16 +02:00
commit 572ff3502e
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);
},
+5 -1
View File
@@ -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;
}
+14
View File
@@ -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,
};
+19
View File
@@ -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
+16
View File
@@ -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