From e2018b2d15f2684b761fb0efa4b7efa1bb5c2d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 19:02:49 +0200 Subject: [PATCH] refactor(query-keys): route long-tail surfaces through the factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings / medications / admin / hooks tree still declared bare-literal `queryKey: [ … ]` arrays at ~70 call sites. Centralise them through `queryKeys.()` so mutation-invalidation prefixes stay symmetrical and a future rename only touches one location. New factory entries cover per-medication compliance / titration / cadence / glp1-details / drug-level-chart / intake-list, the Withings status read, the paginated admin audit-log filter, and the workouts-recent list opts. Existing entries cover everything else (passkeys, tokens, settings, telegram, integrations, research-mode, admin status / users / backups / feedback / host-metrics / app-logs / ai-quality / assistant-flags, coach-prefs, feature-flags, etc.). Lockstep updates: - `eslint-plugins/healthlog/queryKey-factory.js` extends `GUARDED_DIRECTORIES` to cover `src/components/{settings, medications,admin}` + `src/hooks`, plus the three guarded files under `src/app/medications/**` and `src/app/targets/page.tsx`. - `src/lib/__tests__/query-keys.test.ts` mirrors the guard's `guardedRoots` and pins the shape of every new factory entry. Tests stay green: 4 736 unit; the factory walker reports zero bare-literal `queryKey:` in any newly-guarded surface. --- eslint-plugins/healthlog/queryKey-factory.js | 12 +++ src/app/medications/[id]/history/page.tsx | 3 +- src/app/medications/page.tsx | 8 +- src/app/targets/page.tsx | 10 +-- src/components/admin/_shared.tsx | 7 +- src/components/admin/ai-quality-section.tsx | 3 +- .../admin/api-token-overview-section.tsx | 3 +- .../admin/app-log-preview-section.tsx | 3 +- src/components/admin/assistant-section.tsx | 9 ++- src/components/admin/backups-section.tsx | 9 ++- .../admin/coach-feedback-section.tsx | 3 +- .../admin/feedback-inbox-section.tsx | 5 +- src/components/admin/host-metrics-chart.tsx | 3 +- .../admin/login-overview-section.tsx | 10 +-- src/components/admin/recent-audit-preview.tsx | 3 +- .../admin/system-status-section.tsx | 3 +- .../admin/system-status-summary.tsx | 3 +- .../admin/user-management-section.tsx | 5 +- src/components/medications/DrugLevelChart.tsx | 7 +- .../ResearchModeAcknowledgmentDialog.tsx | 3 +- .../medications/SchedulingSection.tsx | 3 +- .../medications/TitrationSection.tsx | 3 +- .../medications/glp1-medication-card.tsx | 12 ++- .../medications/intake-history-list-v2.tsx | 15 ++-- .../medications/medication-card.tsx | 10 ++- .../medications/phase-config-dialog.tsx | 7 +- src/components/settings/account-section.tsx | 5 +- src/components/settings/advanced-section.tsx | 5 +- src/components/settings/ai-section.tsx | 31 +++---- src/components/settings/api-section.tsx | 7 +- .../settings/integrations-section.tsx | 28 ++++--- .../settings/mood-reminder-card.tsx | 5 +- .../settings/notification-status-card.tsx | 7 +- .../settings/notifications-section.tsx | 3 +- src/components/settings/ntfy-card.tsx | 5 +- src/components/settings/telegram-card.tsx | 5 +- .../settings/thresholds-editor-section.tsx | 10 +-- src/hooks/use-coach-prefs.ts | 3 +- src/hooks/use-feature-flags.ts | 4 +- src/hooks/use-insights-analytics.ts | 3 +- src/hooks/use-workouts.ts | 10 ++- src/lib/__tests__/query-keys.test.ts | 80 ++++++++++++++++++- src/lib/query-keys.ts | 77 ++++++++++++++++++ 43 files changed, 332 insertions(+), 118 deletions(-) diff --git a/eslint-plugins/healthlog/queryKey-factory.js b/eslint-plugins/healthlog/queryKey-factory.js index 7caffd1ec..f48e929ac 100644 --- a/eslint-plugins/healthlog/queryKey-factory.js +++ b/eslint-plugins/healthlog/queryKey-factory.js @@ -53,6 +53,13 @@ const GUARDED_DIRECTORIES = [ // at the same boundary the unit-test walker does. "src/app/auth", "src/app/notifications", + // v1.4.42 W3-QUERYKEY-LONGTAIL — the settings / medications / + // admin / hooks surface is now factory-only. Every long-tail + // bare-literal flagged by audit-H1 routes through `queryKeys.()`. + "src/components/settings", + "src/components/medications", + "src/components/admin", + "src/hooks", ]; // Individual guarded files outside the directories above. @@ -61,6 +68,11 @@ const GUARDED_FILES = [ "src/hooks/use-auth.ts", // v1.4.41 W-FRONTEND-FACTORY — about-section migrated. "src/components/settings/about-section.tsx", + // v1.4.42 W3-QUERYKEY-LONGTAIL — medications + targets pages now + // route every read through the factory. + "src/app/medications/page.tsx", + "src/app/medications/[id]/history/page.tsx", + "src/app/targets/page.tsx", ]; function isGuarded(filename) { diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 37b14b5f5..746a11d9e 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -13,6 +13,7 @@ import { IntakeHistoryListV2 } from "@/components/medications/intake-history-lis import { ArrowLeft, Loader2 } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; export default function IntakeHistoryPage({ params, @@ -31,7 +32,7 @@ export default function IntakeHistoryPage({ }, [authLoading, isAuthenticated, router]); const { data: medication, isLoading: medLoading } = useQuery({ - queryKey: ["medications", id], + queryKey: queryKeys.medicationDetail(id), queryFn: async () => { const res = await fetch(`/api/medications/${id}`); if (!res.ok) throw new Error("Failed to fetch"); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 9797f4b0e..50a9a23e5 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -4,7 +4,11 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { MedicationForm } from "@/components/medications/medication-form"; import { MedicationCard } from "@/components/medications/medication-card"; @@ -90,7 +94,7 @@ export default function MedicationsPage() { isError, refetch: refetchMedications, } = useQuery({ - queryKey: ["medications"], + queryKey: queryKeys.medications(), queryFn: async () => { const res = await fetch("/api/medications"); if (!res.ok) throw new Error("Failed to fetch"); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 599ffa235..0a8b524d9 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -6,6 +6,7 @@ import { AlertCircle, Loader2 } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { Card, CardContent } from "@/components/ui/card"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { @@ -46,9 +47,8 @@ interface TargetsResponse { /** * v1.4.25 W3e — provider-chain status used to gate the per-card Coach * CTA. Returns true when at least one provider is configured AND - * enabled. The Settings → AI surface owns the same query - * (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the - * cache is shared. + * enabled. The Settings → AI surface owns the same query (via + * `queryKeys.insightsProviderChain()`) so the cache is shared. */ interface ProviderChainStatus { activeProvider: string | null; @@ -132,7 +132,7 @@ export default function TargetsPage() { const { isAuthenticated } = useAuth(); const { data, isLoading } = useQuery({ - queryKey: ["insights", "targets"], + queryKey: queryKeys.insightsTargets(), queryFn: async () => { const res = await fetch("/api/insights/targets"); if (!res.ok) throw new Error(t("targets.loadError")); @@ -146,7 +146,7 @@ export default function TargetsPage() { // user has no configured provider, `activeProvider` is null and the // per-card Coach CTA hides entirely (no broken-button state). const { data: chainStatus } = useQuery({ - queryKey: ["insights", "provider-chain"], + queryKey: queryKeys.insightsProviderChain(), queryFn: async () => { const res = await fetch("/api/insights/provider-chain"); if (!res.ok) return null; diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 441750dbf..a88ddc549 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input"; /** @@ -225,7 +226,7 @@ export function SettingsToggle({ export function useAdminSettings() { return useQuery({ - queryKey: ["admin", "settings"], + queryKey: queryKeys.adminSettings(), queryFn: async () => { const res = await fetch("/api/admin/settings"); if (!res.ok) throw new Error("Failed"); @@ -249,7 +250,7 @@ export function useUpdateSettings() { } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin", "settings"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminSettings() }); toast.success(t("common.saved")); }, onError: (err) => { @@ -523,7 +524,7 @@ export function auditLogCsvHeaderLabels( export function useSystemStatus() { return useQuery({ - queryKey: ["admin", "status"], + queryKey: queryKeys.adminStatus(), queryFn: async () => { const res = await fetch("/api/admin/status"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index be96674e5..99bc14c4a 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface FeedbackBucket { severity: string; @@ -63,7 +64,7 @@ export function AiQualitySection() { const fmt = useFormatters(); const query = useQuery({ - queryKey: ["admin", "ai-quality"], + queryKey: queryKeys.adminAiQuality(), queryFn: async () => { const res = await fetch("/api/admin/ai-quality"); const json = (await res.json()) as AiQualityResponse; diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 1ab015c19..fc5faf58f 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; /** @@ -147,7 +148,7 @@ export function ApiTokenOverviewSection() { // one card the toggle hides the entire surface. the maintainer reported it // as "sinnlos" — gone. const { data: tokens, isLoading } = useQuery({ - queryKey: ["admin", "tokens"], + queryKey: queryKeys.adminTokens(), queryFn: async () => { const res = await fetch("/api/admin/tokens"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 3bdb5da33..62d0e6290 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -47,6 +47,7 @@ import { } from "@/components/ui/dialog"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import type { WideEvent, LogLevel } from "@/lib/logging/types"; type RangePreset = "15m" | "1h" | "6h" | "all"; @@ -100,7 +101,7 @@ export function AppLogPreviewSection() { }, [traceId, actionFilter, level, range]); const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: ["admin", "app-logs", traceId, actionFilter, level, range], + queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range), queryFn: async () => { const res = await fetch(`/api/admin/app-logs?${params.toString()}`); if (!res.ok) throw new Error("Failed to load app logs"); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index abac363fd..e091bee8d 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { SettingsToggle, getApiErrorMessage } from "./_shared"; /** @@ -45,7 +46,7 @@ interface AssistantFlagsResponse { function useAssistantFlags() { return useQuery({ - queryKey: ["admin", "settings", "assistant-flags"], + queryKey: queryKeys.adminAssistantFlags(), queryFn: async () => { const res = await fetch("/api/admin/settings/assistant-flags"); if (!res.ok) throw new Error("Failed"); @@ -73,12 +74,12 @@ function useUpdateAssistantFlags() { }, onSuccess: (data) => { client.setQueryData( - ["admin", "settings", "assistant-flags"], + queryKeys.adminAssistantFlags(), data, ); // Bust the runtime `/api/feature-flags` cache so the operator // sees the toggled surface react within the same session. - client.invalidateQueries({ queryKey: ["feature-flags"] }); + client.invalidateQueries({ queryKey: queryKeys.featureFlags() }); toast.success(t("common.saved")); }, onError: (err) => { @@ -88,7 +89,7 @@ function useUpdateAssistantFlags() { : t("admin.settingsSaveError"), ); client.invalidateQueries({ - queryKey: ["admin", "settings", "assistant-flags"], + queryKey: queryKeys.adminAssistantFlags(), }); }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 374638108..13a4c913e 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -41,6 +41,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; import { getApiErrorMessage } from "./_shared"; @@ -172,7 +173,7 @@ export function BackupsSection() { const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery({ - queryKey: ["admin", "backups"], + queryKey: queryKeys.adminBackups(), queryFn: async () => { const res = await fetch("/api/admin/backups"); if (!res.ok) throw new Error("Failed"); @@ -194,7 +195,7 @@ export function BackupsSection() { // shows up without leaving the user wondering whether the click // did anything. setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, 2000); }, onError: (err) => { @@ -245,7 +246,7 @@ export function BackupsSection() { toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); - queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); }, onError: (err) => { toast.error( @@ -290,7 +291,7 @@ export function BackupsSection() { }, onSuccess: () => { toast.success(t("admin.section.backups.restoreSuccess")); - queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() }); // Restore touches every personal-data table; nuke the broader // cache so dashboards / lists rebuild against the new state. queryClient.invalidateQueries(); diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 05db00be1..27bf6c62e 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, Sparkles } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface CoachFeedbackBucket { promptVersion: string; @@ -55,7 +56,7 @@ export function CoachFeedbackSection() { const fmt = useFormatters(); const query = useQuery({ - queryKey: ["admin", "coach-feedback"], + queryKey: queryKeys.adminCoachFeedback(), queryFn: async () => { const res = await fetch("/api/admin/ai-quality"); const json = (await res.json()) as CoachFeedbackResponse; diff --git a/src/components/admin/feedback-inbox-section.tsx b/src/components/admin/feedback-inbox-section.tsx index 22ce4cfc6..d17600f9a 100644 --- a/src/components/admin/feedback-inbox-section.tsx +++ b/src/components/admin/feedback-inbox-section.tsx @@ -30,6 +30,7 @@ import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { FEEDBACK_STATUS_TABS, type FeedbackCategoryType, @@ -50,7 +51,7 @@ export function FeedbackInboxSection() { const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ - queryKey: ["admin", "feedback", activeStatus], + queryKey: queryKeys.adminFeedback(activeStatus), queryFn: async () => { const res = await fetch( `/api/admin/feedback?status=${activeStatus}&limit=100`, @@ -63,7 +64,7 @@ export function FeedbackInboxSection() { const counts = data?.meta?.countsByStatus ?? {}; function refresh() { - queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() }); } return ( diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index aa32c462d..972196949 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -37,6 +37,7 @@ import { } from "recharts"; import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface HostMetricsApiSample { capturedAt: string; @@ -130,7 +131,7 @@ export function HostMetricsChart() { const fmt = useFormatters(); const { data, isLoading, isError } = useQuery({ - queryKey: ["admin", "host-metrics", "2h"], + queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { const res = await fetch("/api/admin/host-metrics?since=2h"); if (!res.ok) throw new Error("Failed to load host metrics"); diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 05c4072af..33750c58e 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/select"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { toCSV } from "@/lib/export"; import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { useAuth } from "@/hooks/use-auth"; @@ -114,10 +115,7 @@ export function LoginOverviewSection() { }, [page, perPage, actor, actionFilter, target, range]); const { data, isLoading } = useQuery({ - queryKey: [ - "admin", - "audit-log", - "filtered", + queryKey: queryKeys.adminAuditLogFiltered({ filter, page, perPage, @@ -125,7 +123,7 @@ export function LoginOverviewSection() { actionFilter, target, range, - ], + }), queryFn: async () => { const params = new URLSearchParams(queryParams); // Quick-filter pill stacks on top of the new filters. @@ -140,7 +138,7 @@ export function LoginOverviewSection() { // Distinct actions for the dropdown — populated lazily. const { data: actionsData } = useQuery({ - queryKey: ["admin", "audit-log", "actions"], + queryKey: queryKeys.adminAuditActions(), queryFn: async () => { const res = await fetch("/api/admin/audit-log/actions"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index a1b648100..506fe1a3a 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -21,6 +21,7 @@ import { import { EmptyState } from "@/components/ui/empty-state"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; const PREVIEW_LIMIT = 10; @@ -34,7 +35,7 @@ export function RecentAuditPreview() { const { t } = useTranslations(); const { data, isLoading, isError } = useQuery({ - queryKey: ["admin", "audit-log", "overview-preview"], + queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { const res = await fetch( `/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`, diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 221287c11..506ee5305 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { formatDateTime } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { StatusItem, useSystemStatus } from "./_shared"; interface VersionResponse { @@ -33,7 +34,7 @@ interface VersionResponse { function useOfflineGeoState() { return useQuery({ - queryKey: ["public", "version"], + queryKey: queryKeys.publicVersion(), queryFn: async () => { const res = await fetch("/api/version"); if (!res.ok) throw new Error("Failed to load version"); diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 985423b2a..e5742156e 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -25,6 +25,7 @@ import { import { useQuery } from "@tanstack/react-query"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { StatusItem, useSystemStatus } from "./_shared"; interface VersionResponse { @@ -36,7 +37,7 @@ interface VersionResponse { function useVersion() { return useQuery({ - queryKey: ["public", "version"], + queryKey: queryKeys.publicVersion(), queryFn: async () => { const res = await fetch("/api/version"); if (!res.ok) throw new Error("Failed to load version"); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 754c8ea2b..cc4f33dd9 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -23,6 +23,7 @@ import { import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; /** @@ -49,7 +50,7 @@ export function UserManagementSection() { const [logoutTarget, setLogoutTarget] = useState(null); const { data: users } = useQuery({ - queryKey: ["admin", "users"], + queryKey: queryKeys.adminUsers(), queryFn: async () => { const res = await fetch("/api/admin/users"); if (!res.ok) throw new Error("Failed"); @@ -81,7 +82,7 @@ export function UserManagementSection() { if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() }); setEditingUser(null); toast.success(t("common.saved")); }, diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index 5895270bf..f5435c7dc 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -52,6 +52,7 @@ import { import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { computeOneCompartment, type DoseEvent, @@ -129,7 +130,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const { data: researchMode, isLoading: rmLoading } = useQuery({ - queryKey: ["research-mode"], + queryKey: queryKeys.researchMode(), queryFn: async () => { const res = await fetch("/api/auth/me/research-mode"); if (!res.ok) return null; @@ -146,7 +147,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const { data: details, isLoading: detailsLoading } = useQuery({ - queryKey: ["medications", medication.id, "glp1-details"], + queryKey: queryKeys.medicationGlp1Details(medication.id), queryFn: async () => { const res = await fetch(`/api/medications/${medication.id}/glp1`); if (!res.ok) return null; @@ -160,7 +161,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { }); const { data: intakeEnvelope, isLoading: intakeLoading } = useQuery({ - queryKey: ["medications", medication.id, "intake", "drug-level-chart"], + queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id), queryFn: async () => { const res = await fetch( `/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`, diff --git a/src/components/medications/ResearchModeAcknowledgmentDialog.tsx b/src/components/medications/ResearchModeAcknowledgmentDialog.tsx index 659a5cb29..61b2e380c 100644 --- a/src/components/medications/ResearchModeAcknowledgmentDialog.tsx +++ b/src/components/medications/ResearchModeAcknowledgmentDialog.tsx @@ -45,6 +45,7 @@ import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; export interface ResearchModeAcknowledgmentDialogProps { open: boolean; @@ -94,7 +95,7 @@ export function ResearchModeAcknowledgmentDialog({ onSuccess: () => { setErrorMessage(null); toast.success(t("medications.researchMode.dialog.successToast")); - queryClient.invalidateQueries({ queryKey: ["research-mode"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() }); onAcknowledged?.(); onOpenChange(false); }, diff --git a/src/components/medications/SchedulingSection.tsx b/src/components/medications/SchedulingSection.tsx index 0dd0e1bae..eb7fabda4 100644 --- a/src/components/medications/SchedulingSection.tsx +++ b/src/components/medications/SchedulingSection.tsx @@ -8,6 +8,7 @@ import { Loader2, BellRing, BellOff, Pencil } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** @@ -92,7 +93,7 @@ export function SchedulingSection({ const fmt = useFormatters(); const { data, isLoading, error } = useQuery({ - queryKey: ["medications", medicationId, "cadence"], + queryKey: queryKeys.medicationCadence(medicationId), queryFn: async (): Promise => { const res = await fetch( `/api/medications/${medicationId}/cadence?days=30`, diff --git a/src/components/medications/TitrationSection.tsx b/src/components/medications/TitrationSection.tsx index 96955a0bf..41b8c8f2a 100644 --- a/src/components/medications/TitrationSection.tsx +++ b/src/components/medications/TitrationSection.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, ExternalLink } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** @@ -62,7 +63,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) { const { t } = useTranslations(); const { data, isLoading, error } = useQuery({ - queryKey: ["medications", medicationId, "titration"], + queryKey: queryKeys.medicationTitration(medicationId), queryFn: async (): Promise => { const res = await fetch(`/api/medications/${medicationId}/titration`); if (!res.ok) { diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 2fec10c8f..a3ebdca42 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -29,7 +29,11 @@ import { import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; import { Progress } from "@/components/ui/progress"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; import { formatDateTime, formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; @@ -179,7 +183,7 @@ export function Glp1MedicationCard({ const [, forceUpdate] = useReducer((x: number) => x + 1, 0); const { data: compliance } = useQuery({ - queryKey: ["medications", medication.id, "compliance"], + queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { const res = await fetch(`/api/medications/${medication.id}/compliance`); if (!res.ok) return null; @@ -194,7 +198,7 @@ export function Glp1MedicationCard({ // medication card so the take-now / overdue / very-overdue pill // tiers identically on both surfaces. const { data: thresholds } = useQuery({ - queryKey: ["settings", "reminder-thresholds"], + queryKey: queryKeys.settingsReminderThresholds(), queryFn: async () => { const res = await fetch("/api/settings/reminder-thresholds"); if (!res.ok) return null; @@ -208,7 +212,7 @@ export function Glp1MedicationCard({ // pen inventory). Lives behind a dedicated endpoint so the standard // medications grid loads in parallel without paying for it. const { data: details } = useQuery({ - queryKey: ["medications", medication.id, "glp1-details"], + queryKey: queryKeys.medicationGlp1Details(medication.id), queryFn: async () => { const res = await fetch(`/api/medications/${medication.id}/glp1`); if (!res.ok) return null; diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 15070d4fc..494d098b8 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -15,6 +15,7 @@ import { import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -111,13 +112,13 @@ export function IntakeHistoryListV2({ const offset = page * pageSize; const { data, isLoading, isError } = useQuery({ - queryKey: [ - "medications", - medicationId, - "intake", - "list", - { sortBy, sortDir, limit: pageSize, offset, status: STATUS_FILTER }, - ], + queryKey: queryKeys.medicationIntakeList(medicationId, { + sortBy, + sortDir, + limit: pageSize, + offset, + status: STATUS_FILTER, + }), queryFn: async () => { const params = new URLSearchParams({ sortBy, diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 2f46bdd4e..b67d97468 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -29,7 +29,11 @@ import { } from "lucide-react"; import Link from "next/link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; interface Schedule { id: string; @@ -128,7 +132,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) { const [intakeLoading, setIntakeLoading] = useState(null); const { data: compliance } = useQuery({ - queryKey: ["medications", medication.id, "compliance"], + queryKey: queryKeys.medicationCompliance(medication.id), queryFn: async () => { const res = await fetch(`/api/medications/${medication.id}/compliance`); if (!res.ok) return null; @@ -141,7 +145,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) { const [, forceUpdate] = useReducer((x: number) => x + 1, 0); const { data: thresholds } = useQuery({ - queryKey: ["settings", "reminder-thresholds"], + queryKey: queryKeys.settingsReminderThresholds(), queryFn: async () => { const res = await fetch("/api/settings/reminder-thresholds"); if (!res.ok) return null; diff --git a/src/components/medications/phase-config-dialog.tsx b/src/components/medications/phase-config-dialog.tsx index 541d5073f..fb18611b2 100644 --- a/src/components/medications/phase-config-dialog.tsx +++ b/src/components/medications/phase-config-dialog.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Loader2, RotateCcw } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface PhaseConfigData { greenValue: number; @@ -76,7 +77,7 @@ export function PhaseConfigDialog({ const [statusMessage, setStatusMessage] = useState(null); const { data, isLoading } = useQuery({ - queryKey: ["phase-config", medicationId], + queryKey: queryKeys.medicationPhaseConfig(medicationId), queryFn: async () => { const res = await fetch(`/api/medications/${medicationId}/phase-config`); const json = await res.json(); @@ -101,7 +102,7 @@ export function PhaseConfigDialog({ onSuccess: () => { setLocalForm(null); queryClient.invalidateQueries({ - queryKey: ["phase-config", medicationId], + queryKey: queryKeys.medicationPhaseConfig(medicationId), }); setStatusMessage(t("medications.phaseSaved")); setTimeout(() => setStatusMessage(null), 2000); @@ -119,7 +120,7 @@ export function PhaseConfigDialog({ onSuccess: () => { setLocalForm(null); queryClient.invalidateQueries({ - queryKey: ["phase-config", medicationId], + queryKey: queryKeys.medicationPhaseConfig(medicationId), }); setStatusMessage(t("medications.phaseReset")); setTimeout(() => setStatusMessage(null), 2000); diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index d5fbb7590..9f8712f78 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -44,6 +44,7 @@ import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { describePasskeyError } from "@/lib/passkey-errors"; +import { queryKeys } from "@/lib/query-keys"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -726,7 +727,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { const [deleteMsg, setDeleteMsg] = useState(null); const { data: passkeys } = useQuery({ - queryKey: ["passkeys"], + queryKey: queryKeys.passkeys(), queryFn: async () => { const res = await fetch("/api/auth/passkeys"); if (!res.ok) throw new Error("Failed"); @@ -742,7 +743,7 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { if (!res.ok) throw new Error(json.error || t("common.error")); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["passkeys"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); setDeleteMsg(null); }, onError: (err: Error) => { diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 0d580eeb7..c0b9c88e1 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -20,6 +20,7 @@ import { Switch } from "@/components/ui/switch"; import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { type ResearchModeStatus, researchModeGateState, @@ -102,7 +103,7 @@ function ResearchModeCard() { const [errorMessage, setErrorMessage] = useState(null); const { data: status, isLoading } = useQuery({ - queryKey: ["research-mode"], + queryKey: queryKeys.researchMode(), queryFn: async () => { const res = await fetch("/api/auth/me/research-mode"); if (!res.ok) return null; @@ -140,7 +141,7 @@ function ResearchModeCard() { setErrorMessage(t("settings.researchMode.disableError")); return; } - queryClient.invalidateQueries({ queryKey: ["research-mode"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() }); } catch { setErrorMessage(t("settings.researchMode.disableError")); } finally { diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index d3d54ded5..a3bbc632e 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -37,6 +37,7 @@ import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface InsightsSettings { codexStatus: string; @@ -186,7 +187,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) { const searchParams = useSearchParams(); const { data: insightsSettings } = useQuery({ - queryKey: ["insights", "settings"], + queryKey: queryKeys.insightsSettings(), queryFn: async () => { const res = await fetch("/api/insights/settings"); if (!res.ok) return null; @@ -197,7 +198,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) { }); const { data: userProvider } = useQuery({ - queryKey: ["user", "ai-provider"], + queryKey: queryKeys.userAiProvider(), queryFn: async () => { const res = await fetch("/api/user/ai-provider"); if (!res.ok) return null; @@ -208,7 +209,7 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) { }); const { data: chainData } = useQuery({ - queryKey: ["insights", "provider-chain"], + queryKey: queryKeys.insightsProviderChain(), queryFn: async () => { const res = await fetch("/api/insights/provider-chain"); if (!res.ok) return null; @@ -296,10 +297,10 @@ function AiInsightsCard({ isAuthenticated }: { isAuthenticated: boolean }) { privacyMode={insightsSettings?.privacyMode ?? "aggregated"} lastInsightAt={insightsSettings?.lastInsightAt ?? null} onRegenerated={() => - queryClient.invalidateQueries({ queryKey: ["insights"] }) + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }) } onPrivacyChanged={() => - queryClient.invalidateQueries({ queryKey: ["insights"] }) + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }) } /> @@ -479,7 +480,7 @@ function CodexProviderForm({ url.searchParams.delete("codex_error"); window.history.replaceState({}, "", url.toString()); if (oauthOutcome.kind === "connected") { - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); } }, [oauthOutcome, queryClient]); @@ -536,7 +537,7 @@ function CodexProviderForm({ setDevicePolling(false); setMsg(t("settings.codexConnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); return; } if (!cancelled) setTimeout(tick, intervalMs); @@ -575,7 +576,7 @@ function CodexProviderForm({ } setMsg(t("settings.codexDisconnected")); setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); } catch (err) { setMsg(err instanceof Error ? err.message : t("settings.savingError")); setMsgType("error"); @@ -793,8 +794,8 @@ function OpenAIProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); @@ -979,8 +980,8 @@ function AnthropicProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); @@ -1135,8 +1136,8 @@ function LocalProviderForm({ setOk(true); setMsg(t("settings.ai.saved")); setApiKey(""); - queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); @@ -1323,7 +1324,7 @@ function FallbackChainCard({ onSuccess: () => { setOk(true); setMsg(t("settings.ai.providerChain.saved")); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); }, onError: (e) => { setOk(false); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 3309f025a..2374e3aa7 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -21,6 +21,7 @@ import { Input } from "@/components/ui/input"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface ApiTokenInfo { id: string; @@ -166,7 +167,7 @@ function ApiTokensCard() { const [showRevokedTokens, setShowRevokedTokens] = useState(false); const { data: tokens } = useQuery({ - queryKey: ["tokens"], + queryKey: queryKeys.tokens(), queryFn: async () => { const res = await fetch("/api/tokens"); if (!res.ok) throw new Error("Failed"); @@ -191,7 +192,7 @@ function ApiTokensCard() { if (res.ok) { setNewToken(json.data.token); setNewName(""); - queryClient.invalidateQueries({ queryKey: ["tokens"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } else { setTokenMsg(json.error || t("common.error")); } @@ -205,7 +206,7 @@ function ApiTokensCard() { async function handleRevoke(tokenId: string) { const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" }); if (res.ok) { - queryClient.invalidateQueries({ queryKey: ["tokens"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } } diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index e3a3a7067..e68c5cba7 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -33,7 +33,11 @@ import { IntegrationStatusPill } from "@/components/settings/integration-status- import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; interface MoodLogStatus { configured: boolean; @@ -91,7 +95,7 @@ interface IntegrationStatusEnvelope { */ function useIntegrationStatuses(enabled: boolean) { return useQuery({ - queryKey: ["integrations", "status"], + queryKey: queryKeys.integrationsStatus(), queryFn: async () => { const res = await fetch("/api/integrations/status"); if (!res.ok) throw new Error("Failed"); @@ -155,7 +159,7 @@ export function IntegrationsSection() { const { isAuthenticated } = useAuth(); const { data: globalServices } = useQuery({ - queryKey: ["settings", "global-services"], + queryKey: queryKeys.settingsGlobalServices(), queryFn: async () => { const res = await fetch("/api/settings/global-services"); if (!res.ok) throw new Error("Failed"); @@ -219,7 +223,7 @@ function WithingsCard({ const queryClient = useQueryClient(); const { data: status } = useQuery({ - queryKey: ["withings", "status"], + queryKey: queryKeys.withingsStatus(), queryFn: async () => { const res = await fetch("/api/withings/status"); if (!res.ok) throw new Error("Failed"); @@ -247,8 +251,8 @@ function WithingsCard({ if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["withings"] }); - queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); + queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); }, }); @@ -273,7 +277,7 @@ function WithingsCard({ ); setSyncMsgType("success"); void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { setSyncMsg(json.error || t("settings.withingsSyncFailed")); setSyncMsgType("error"); @@ -307,7 +311,7 @@ function WithingsCard({ setCredsMsgType("success"); setClientId(""); setClientSecret(""); - queryClient.invalidateQueries({ queryKey: ["withings"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); } else { try { const json = await res.json(); @@ -601,7 +605,7 @@ function MoodLogCard({ const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const { data: status, refetch: refetchStatus } = useQuery({ - queryKey: ["moodlog-status"], + queryKey: queryKeys.moodlogStatus(), queryFn: async () => { const res = await fetch("/api/integrations/moodlog/status"); if (!res.ok) throw new Error("Failed"); @@ -624,7 +628,7 @@ function MoodLogCard({ setUrl(""); setApiKey(""); await refetchStatus(); - queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { const json = await res.json(); setMsg(json.error || t("settings.savingError")); @@ -652,7 +656,7 @@ function MoodLogCard({ ); setMsgType("success"); await refetchStatus(); - queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); @@ -670,7 +674,7 @@ function MoodLogCard({ setMsg(t("settings.moodLogDisconnected")); setMsgType("success"); await refetchStatus(); - queryClient.invalidateQueries({ queryKey: ["integrations", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } } diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 2029a9d66..2ad479fa8 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -7,6 +7,7 @@ import { SmilePlus } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface ProfileShape { moodReminderEnabled: boolean; @@ -25,7 +26,7 @@ export function MoodReminderCard({ const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const { data: profile } = useQuery({ - queryKey: ["user", "profile"], + queryKey: queryKeys.userProfile(), queryFn: async () => { const res = await fetch("/api/user/profile"); if (!res.ok) throw new Error("Failed to load profile"); @@ -55,7 +56,7 @@ export function MoodReminderCard({ : t("notifications.moodReminder.disabledToast"), ); setMsgType("success"); - await queryClient.invalidateQueries({ queryKey: ["user", "profile"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() }); setOptimistic(null); } else { setOptimistic(null); diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx index ae766c654..0d27b151b 100644 --- a/src/components/settings/notification-status-card.tsx +++ b/src/components/settings/notification-status-card.tsx @@ -33,6 +33,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH"; @@ -69,7 +70,7 @@ export function NotificationStatusCard() { const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ - queryKey: ["notifications", "status"], + queryKey: queryKeys.notificationsStatus(), queryFn: async () => { const res = await fetch("/api/notifications/status"); if (!res.ok) throw new Error("Failed"); @@ -90,7 +91,7 @@ export function NotificationStatusCard() { if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["notifications", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() }); }, }); @@ -103,7 +104,7 @@ export function NotificationStatusCard() { } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["notifications", "status"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.notificationsStatus() }); }, }); diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 2977b0b62..74b16e1ed 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; import { NotificationStatusCard } from "@/components/settings/notification-status-card"; import { NtfyCard } from "@/components/settings/ntfy-card"; @@ -25,7 +26,7 @@ export function NotificationsSection() { const { isAuthenticated } = useAuth(); const { data: globalServices } = useQuery({ - queryKey: ["settings", "global-services"], + queryKey: queryKeys.settingsGlobalServices(), queryFn: async () => { const res = await fetch("/api/settings/global-services"); if (!res.ok) throw new Error("Failed"); diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index d7508e576..a4deb5803 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -11,6 +11,7 @@ import { Switch } from "@/components/ui/switch"; import { PasswordInput } from "@/components/ui/password-input"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface NtfySettings { enabled: boolean; @@ -31,7 +32,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { ); const { data: settings } = useQuery({ - queryKey: ["settings", "ntfy"], + queryKey: queryKeys.settingsNtfy(), queryFn: async () => { const res = await fetch("/api/settings/ntfy"); if (!res.ok) throw new Error("Failed"); @@ -70,7 +71,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() }); setSaveMsg(t("settings.saved")); setSaveMsgType("success"); setAuthToken(""); diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index a943b03c2..db4a08927 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -11,6 +11,7 @@ import { Switch } from "@/components/ui/switch"; import { PasswordInput } from "@/components/ui/password-input"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface TelegramSettings { enabled: boolean; @@ -34,7 +35,7 @@ export function TelegramCard({ const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const { data: settings } = useQuery({ - queryKey: ["telegram", "settings"], + queryKey: queryKeys.telegramSettings(), queryFn: async () => { const res = await fetch("/api/settings/telegram"); if (!res.ok) throw new Error("Failed"); @@ -78,7 +79,7 @@ export function TelegramCard({ setMsg(t("settings.telegramSaved")); setMsgType("success"); setBotToken(""); - queryClient.invalidateQueries({ queryKey: ["telegram"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); } else { const json = await res.json(); setMsg(json.error || t("settings.savingError")); diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index fea2ad95c..82329ac81 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -62,7 +62,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) { const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ - queryKey: ["user", "thresholds"], + queryKey: queryKeys.userThresholds(), queryFn: async () => { const res = await fetch("/api/user/thresholds"); if (!res.ok) throw new Error("failed"); @@ -86,10 +86,10 @@ export function ThresholdsEditorSection({ id }: { id: string }) { } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); // Every chart/band depends on these thresholds — invalidate everything. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); toast.success(t("thresholds.saveSuccess")); }, onError: (err) => @@ -107,9 +107,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { if (!res.ok) throw new Error("reset failed"); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); toast.success(t("thresholds.resetSuccess")); }, onError: () => toast.error(t("thresholds.saveError")), diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts index b57f72005..7468f55fa 100644 --- a/src/hooks/use-coach-prefs.ts +++ b/src/hooks/use-coach-prefs.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; import { DEFAULT_COACH_PREFS, type CoachPrefs, @@ -24,7 +25,7 @@ import { */ export function useCoachPrefs(opts?: { enabled?: boolean }) { return useQuery({ - queryKey: ["coach-prefs"], + queryKey: queryKeys.coachPrefs(), queryFn: async () => { const res = await fetch("/api/auth/me/coach-prefs"); if (!res.ok) return DEFAULT_COACH_PREFS; diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts index 2ac5d05ab..15eee31be 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -3,6 +3,8 @@ import { useContext } from "react"; import { QueryClientContext, useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; + /** * v1.4.31 — Client-side accessor for the operator's assistant * feature-flag matrix. Mirrors `GET /api/feature-flags`'s response @@ -86,7 +88,7 @@ export function useFeatureFlags(): AssistantFlagSet { function useFeatureFlagsQuery(): AssistantFlagSet { const query = useQuery({ - queryKey: ["feature-flags"], + queryKey: queryKeys.featureFlags(), queryFn: fetchFeatureFlags, staleTime: 60_000, gcTime: 300_000, diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts index 8debe56f9..540f30a55 100644 --- a/src/hooks/use-insights-analytics.ts +++ b/src/hooks/use-insights-analytics.ts @@ -13,7 +13,8 @@ import type { SubPageAnalyticsData } from "@/types/analytics"; * `schlaf`) each used to declare: * * - a private `AnalyticsData` interface, - * - a `useQuery({ queryKey: ["analytics"], staleTime: 60_000 })` block, + * - a `useQuery` block keyed on `["analytics"]` with a 60-second + * `staleTime`, * - an `if (isAuthenticated && analytics && !hasMetricData(...)) { … }` * short-circuit that rendered a sub-page-specific empty state. * diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 08b1e705d..2da2d46f9 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -92,10 +92,12 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { const { isAuthenticated } = useAuth(); const query = useQuery({ - queryKey: [ - ...queryKeys.workoutsRecent(), - { limit: opts.limit, offset: opts.offset, since: opts.since, sportType: opts.sportType }, - ] as const, + queryKey: queryKeys.workoutsRecentList({ + limit: opts.limit, + offset: opts.offset, + since: opts.since, + sportType: opts.sportType, + }), queryFn: () => fetchWorkouts(opts), enabled: isAuthenticated, staleTime: 60 * 1000, diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index da18335c8..4011311b8 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -100,6 +100,74 @@ describe("queryKeys factory", () => { "dashboard-medication-compliance", ); }); + + // v1.4.42 W3-QUERYKEY-LONGTAIL — pin the shapes of the new factory + // entries so a future rename can't drift the cache layout silently. + it("exposes the per-medication sub-keys under the medications prefix", () => { + expect(queryKeys.medicationCompliance("med1")).toEqual([ + "medications", + "med1", + "compliance", + ]); + expect(queryKeys.medicationTitration("med1")).toEqual([ + "medications", + "med1", + "titration", + ]); + expect(queryKeys.medicationCadence("med1")).toEqual([ + "medications", + "med1", + "cadence", + ]); + expect(queryKeys.medicationGlp1Details("med1")).toEqual([ + "medications", + "med1", + "glp1-details", + ]); + expect(queryKeys.medicationIntakeDrugLevelChart("med1")).toEqual([ + "medications", + "med1", + "intake", + "drug-level-chart", + ]); + const listKey = queryKeys.medicationIntakeList("med1", { + sortBy: "takenAt", + sortDir: "desc", + limit: 25, + offset: 0, + status: "completed", + }); + expect(listKey[0]).toBe("medications"); + expect(listKey[1]).toBe("med1"); + expect(listKey[2]).toBe("intake"); + expect(listKey[3]).toBe("list"); + }); + + it("exposes withingsStatus under the withings prefix", () => { + expect(queryKeys.withingsStatus()).toEqual(["withings", "status"]); + }); + + it("packs adminAuditLogFiltered params into a stable filtered shape", () => { + const key = queryKeys.adminAuditLogFiltered({ + filter: "all", + page: 1, + perPage: 50, + actor: "", + actionFilter: "", + target: "", + range: "7d", + }); + expect(key[0]).toBe("admin"); + expect(key[1]).toBe("audit-log"); + expect(key[2]).toBe("filtered"); + expect(key.length).toBe(10); + }); + + it("packs workoutsRecentList opts under the workouts-recent prefix", () => { + const key = queryKeys.workoutsRecentList({ limit: 3 }); + expect(key[0]).toBe("workouts"); + expect(key[1]).toBe("recent"); + }); }); describe("dependent-key bundles", () => { @@ -195,11 +263,19 @@ describe("queryKey factory enforcement", () => { join(repoRoot, "src", "app", "page.tsx"), join(repoRoot, "src", "hooks", "use-auth.ts"), // v1.4.41 W-FRONTEND-FACTORY — auth, notifications, and the - // about-section migrated to the factory. Future waves extend - // this list as they migrate their own surface. + // about-section migrated to the factory. join(repoRoot, "src", "app", "auth"), join(repoRoot, "src", "app", "notifications"), join(repoRoot, "src", "components", "settings", "about-section.tsx"), + // v1.4.42 W3-QUERYKEY-LONGTAIL — settings / medications / + // admin / hooks now route every read through the factory. + join(repoRoot, "src", "components", "settings"), + join(repoRoot, "src", "components", "medications"), + join(repoRoot, "src", "components", "admin"), + join(repoRoot, "src", "hooks"), + join(repoRoot, "src", "app", "medications", "page.tsx"), + join(repoRoot, "src", "app", "medications", "[id]", "history", "page.tsx"), + join(repoRoot, "src", "app", "targets", "page.tsx"), ]; function collect(): string[] { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 74f62508a..b761cb484 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -73,6 +73,38 @@ export const queryKeys = { medicationDetail: (id: string) => ["medications", id] as const, medicationComplianceChart: (medicationId: string) => ["compliance-chart-inline", medicationId] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — per-medication compliance KPI used + * by `medication-card` + `glp1-medication-card`. Centralising lets + * the intake mutation invalidate every compliance read through the + * `["medications"]` prefix instead of one bare-literal at a time. + */ + medicationCompliance: (medicationId: string) => + ["medications", medicationId, "compliance"] as const, + medicationTitration: (medicationId: string) => + ["medications", medicationId, "titration"] as const, + medicationCadence: (medicationId: string) => + ["medications", medicationId, "cadence"] as const, + medicationGlp1Details: (medicationId: string) => + ["medications", medicationId, "glp1-details"] as const, + medicationIntakeDrugLevelChart: (medicationId: string) => + ["medications", medicationId, "intake", "drug-level-chart"] as const, + /** + * v1.4.42 — intake-history list with sort / paging / status filter. + * The opaque params object lives at index 4 so the + * `["medications", id, "intake", "list"]` prefix invalidates every + * sort/page combination on an intake mutation. + */ + medicationIntakeList: ( + medicationId: string, + params: { + sortBy: string; + sortDir: string; + limit: number; + offset: number; + status: string; + }, + ) => ["medications", medicationId, "intake", "list", params] as const, /** * v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate * across every scheduled medication) was a bare `["medication- @@ -143,11 +175,44 @@ export const queryKeys = { ["admin", "host-metrics", window] as const, adminAuditActions: () => ["admin", "audit-log", "actions"] as const, adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — paginated + filtered audit-log + * read used by the login-overview admin section. The `filtered` + * discriminator at index 2 keeps the no-arg `adminAuditLog(filter)` + * cache slot byte-distinct so its consumers don't collide. + */ + adminAuditLogFiltered: (params: { + filter: string; + page: number; + perPage: number; + actor: string; + actionFilter: string; + target: string; + range: string; + }) => + [ + "admin", + "audit-log", + "filtered", + params.filter, + params.page, + params.perPage, + params.actor, + params.actionFilter, + params.target, + params.range, + ] as const, tokens: () => ["tokens"] as const, telegram: () => ["telegram"] as const, telegramSettings: () => ["telegram", "settings"] as const, withings: () => ["withings"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — the per-card Withings status read. + * Shares the `["withings"]` prefix with `withings()` so a disconnect + * mutation invalidates both at once. + */ + withingsStatus: () => ["withings", "status"] as const, // v1.4.32 — workout list + detail caches. `workouts()` is the // root key invalidated by the batch-ingest mutation; the recent + @@ -155,6 +220,18 @@ export const queryKeys = { // detail page share a cache slot with the list page. workouts: () => ["workouts"] as const, workoutsRecent: () => ["workouts", "recent"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — the `useWorkouts` hook used to + * spread `workoutsRecent()` and append an opts object inline. The + * factory now owns the full shape so the hook never reaches for a + * literal-array wrapper. + */ + workoutsRecentList: (opts: { + limit?: number; + offset?: number; + since?: string; + sportType?: string; + }) => ["workouts", "recent", opts] as const, workoutDetail: (id: string) => ["workouts", id] as const, adminSettings: () => ["admin", "settings"] as const,