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

@@ -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.<entry>()`.
"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) {
+2 -1
View File
@@ -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");
+6 -2
View File
@@ -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");
+5 -5
View File
@@ -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;
+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")),
+2 -1
View File
@@ -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<CoachPrefs>({
queryKey: ["coach-prefs"],
queryKey: queryKeys.coachPrefs(),
queryFn: async () => {
const res = await fetch("/api/auth/me/coach-prefs");
if (!res.ok) return DEFAULT_COACH_PREFS;
+3 -1
View File
@@ -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,
+2 -1
View File
@@ -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.
*
+6 -4
View File
@@ -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,
+78 -2
View File
@@ -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[] {
+77
View File
@@ -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,