From 0bf07abd9d47ea47828b69ac6c28fb22cddccc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 17:13:57 +0200 Subject: [PATCH] refactor(query-keys): expand factory and migrate auth, notifications, about Routes the registration-status, auth invalidation, notifications preferences, and Settings about-section api-version reads through the centralised queryKeys factory. Adds factory entries for the remaining settings/admin surfaces a follow-up will migrate, and extends the walker test to fail on any future bare-literal queryKey in src/app/auth, src/app/notifications, and the about-section. Also swaps the dashboard tile Suspense fallback from null to a layout-stable card placeholder so a future RSC hoist of any tile slot can't trigger CLS. --- src/app/auth/login/page.tsx | 4 +- src/app/auth/register/page.tsx | 3 +- src/app/notifications/page.tsx | 18 ++++----- src/app/page.tsx | 20 +++++++++- src/components/settings/about-section.tsx | 3 +- src/lib/__tests__/query-keys.test.ts | 25 ++++++++++++ src/lib/query-keys.ts | 47 +++++++++++++++++++++++ 7 files changed, 106 insertions(+), 14 deletions(-) diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 281d5a23c..af8958252 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -85,7 +85,7 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push(getRedirectTarget()); } catch (err) { const { key, params } = describePasskeyError(err); @@ -114,7 +114,7 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push(getRedirectTarget()); } catch { setError(t("auth.loginFailed")); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index d4dead215..73e56ac8c 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; import { detectBrowserTimezone } from "@/lib/tz/format"; +import { queryKeys } from "@/lib/query-keys"; export default function RegisterPage() { const router = useRouter(); @@ -54,7 +55,7 @@ export default function RegisterPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push("/"); } catch { setError(t("auth.registerFailed")); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 3f9e44d84..73e35020f 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -12,6 +12,7 @@ import { EVENT_DEFAULT_ENABLED, type EventType, } from "@/lib/notifications/types"; +import { queryKeys } from "@/lib/query-keys"; interface ChannelInfo { id: string; @@ -86,7 +87,7 @@ export default function NotificationsPage() { const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { const res = await fetch("/api/notifications/preferences"); if (!res.ok) throw new Error("Failed to load preferences"); @@ -112,12 +113,11 @@ export default function NotificationsPage() { }, onMutate: async ({ channelId, eventType, enabled }) => { await queryClient.cancelQueries({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), }); - const prev = queryClient.getQueryData([ - "notifications", - "preferences", - ]); + const prev = queryClient.getQueryData( + queryKeys.notificationsPreferences(), + ); if (prev) { const existing = prev.preferences.find( (p) => p.channelId === channelId && p.eventType === eventType, @@ -130,7 +130,7 @@ export default function NotificationsPage() { ) : [...prev.preferences, { channelId, eventType, enabled }]; queryClient.setQueryData( - ["notifications", "preferences"], + queryKeys.notificationsPreferences(), { ...prev, preferences: newPreferences }, ); } @@ -139,14 +139,14 @@ export default function NotificationsPage() { onError: (_err, _vars, context) => { if (context?.prev) { queryClient.setQueryData( - ["notifications", "preferences"], + queryKeys.notificationsPreferences(), context.prev, ); } }, onSettled: () => { queryClient.invalidateQueries({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), }); }, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index b265eeb93..a3da7be6e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1413,7 +1413,25 @@ export default function DashboardPage() { * future RSC hoists of any tile slot won't need a * second pass to add the fallback infrastructure. */} - {entry.node} + {/* + * v1.4.41 W-FRONTEND-FACTORY (UX M1) — swap the + * `null` fallback for a layout-stable placeholder + * that mirrors the trend-card chrome. The body + * stays synchronous today so the fallback rarely + * paints, but a future RSC hoist of any tile slot + * would otherwise leave the grid track empty and + * trigger CLS as the cell paints in. + */} + ))} diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 124ebeb88..5f4860240 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface VersionPayload { version: string; @@ -117,7 +118,7 @@ export function AboutSection() { const fmt = useFormatters(); const { data: version, isLoading } = useQuery({ - queryKey: ["api", "version"], + queryKey: queryKeys.apiVersion(), queryFn: async () => { const res = await fetch("/api/version"); if (!res.ok) throw new Error("version-fetch-failed"); diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 4d25e19b7..da18335c8 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -49,6 +49,25 @@ describe("queryKeys factory", () => { expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]); }); + // v1.4.41 W-FRONTEND-FACTORY — pin the shapes that auth/login + the + // notifications surface migrated to so a future rename can't drift + // the cache layout silently. + it("exposes auth + notifications keys for the factory-migrated surfaces", () => { + expect(queryKeys.authRegistrationStatus()).toEqual([ + "auth", + "registration-status", + ]); + expect(queryKeys.notificationsPreferences()).toEqual([ + "notifications", + "preferences", + ]); + expect(queryKeys.notificationsStatus()).toEqual([ + "notifications", + "status", + ]); + expect(queryKeys.apiVersion()).toEqual(["api", "version"]); + }); + it("packs chartData params into a stable tuple prefixed by chart-data", () => { const key = queryKeys.chartData( "WEIGHT", @@ -175,6 +194,12 @@ describe("queryKey factory enforcement", () => { join(repoRoot, "src", "components", "comparison"), 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. + join(repoRoot, "src", "app", "auth"), + join(repoRoot, "src", "app", "notifications"), + join(repoRoot, "src", "components", "settings", "about-section.tsx"), ]; function collect(): string[] { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index df8155542..74f62508a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -93,9 +93,56 @@ export const queryKeys = { passkeys: () => ["passkeys"] as const, notificationsPreferences: () => ["notifications", "preferences"] as const, + notificationsStatus: () => ["notifications", "status"] as const, settingsGlobalServices: () => ["settings", "global-services"] as const, settingsNtfy: () => ["settings", "ntfy"] as const, + settingsReminderThresholds: () => + ["settings", "reminder-thresholds"] as const, + + /** + * v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain, + * insights settings, user provider preference) and the targets editor + * all read these endpoints; centralising the keys keeps invalidation + * symmetrical with the user-thresholds + auth surfaces. + */ + insightsSettings: () => ["insights", "settings"] as const, + insightsProviderChain: () => ["insights", "provider-chain"] as const, + insightsGlp1Timeline: (limit: number | string) => + ["insights", "glp1-timeline", limit] as const, + userAiProvider: () => ["user", "ai-provider"] as const, + userProfile: () => ["user", "profile"] as const, + + apiVersion: () => ["api", "version"] as const, + publicVersion: () => ["public", "version"] as const, + researchMode: () => ["research-mode"] as const, + moodlogStatus: () => ["moodlog-status"] as const, + integrationsStatus: () => ["integrations", "status"] as const, + featureFlags: () => ["feature-flags"] as const, + coachPrefs: () => ["coach-prefs"] as const, + + /** + * v1.4.41 — admin surfaces. Pre-fix every admin section declared its + * own bare-literal `["admin", ""]`. Routing through the factory + * lets a single rename change every consumer in lockstep. + */ + adminAiQuality: () => ["admin", "ai-quality"] as const, + adminAppLogs: ( + traceId: string | undefined, + action: string | undefined, + level: string | undefined, + range: string | undefined, + ) => + ["admin", "app-logs", traceId, action, level, range] as const, + adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, + adminBackups: () => ["admin", "backups"] as const, + adminCoachFeedback: () => ["admin", "coach-feedback"] as const, + adminFeedback: (status: string) => ["admin", "feedback", status] as const, + adminFeedbackRoot: () => ["admin", "feedback"] as const, + adminHostMetrics: (window: string) => + ["admin", "host-metrics", window] as const, + adminAuditActions: () => ["admin", "audit-log", "actions"] as const, + adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const, tokens: () => ["tokens"] as const, telegram: () => ["telegram"] as const,