refactor(query-keys): route long-tail surfaces through the factory

The settings / medications / admin / hooks tree still declared
bare-literal `queryKey: [ … ]` arrays at ~70 call sites. Centralise
them through `queryKeys.<entry>()` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 19:04:22 +02:00
1 parent 6850818271
commit e2018b2d15
43 files changed
+332 -118

No files matched your search

+4 -3
View File
@@ -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");
+2 -1
View File
@@ -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;
@@ -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");
@@ -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");
+5 -4
View File
@@ -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(),
});
},
});
+5 -4
View File
@@ -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();
@@ -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;
@@ -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<FeedbackItem | null>(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 (
+2 -1
View File
@@ -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");
@@ -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");
@@ -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`,
@@ -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");
@@ -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");
@@ -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<AdminUser | null>(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"));
},
@@ -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<ResearchModeStatus | null>({
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<Glp1DetailsResponse | null>({
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`,
@@ -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);
},
@@ -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<CadenceResponse> => {
const res = await fetch(
`/api/medications/${medicationId}/cadence?days=30`,
@@ -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<TitrationResponse> => {
const res = await fetch(`/api/medications/${medicationId}/titration`);
if (!res.ok) {
@@ -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;
@@ -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<IntakeListResponse>({
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,
@@ -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<string | null>(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;
@@ -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<string | null>(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);
+3 -2
View File
@@ -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<string | null>(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) => {
+3 -2
View File
@@ -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<string | null>(null);
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
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 {
+16 -15
View File
@@ -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() })
}
/>
</div>
@@ -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);
+4 -3
View File
@@ -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() });
}
}
@@ -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() });
}
}
@@ -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);
@@ -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() });
},
});
@@ -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");
+3 -2
View File
@@ -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("");
+3 -2
View File
@@ -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"));
@@ -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")),