From 08e6229142c77c30a2391fb968821b17659e2aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:21:47 +0100 Subject: [PATCH] fix(glucose): show targets in the new unit right after a unit change After switching between mg/dL and mmol/L, the target panels on the glucose page kept the old unit for up to ten minutes. Their unit comes from the cached targets payload, and the save only refreshed the account payload. The select now invalidates the targets query, and the route evicts the profile-derived server caches when the unit changes, the same way the thresholds and source-priority writes do. --- src/app/api/auth/me/glucose-unit/route.ts | 7 +++++-- .../settings/__tests__/glucose-unit-select.test.tsx | 13 +++++++++++++ src/components/settings/glucose-unit-select.tsx | 4 +++- src/lib/cache/invalidate.ts | 9 +++++---- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index 719d3f45a..cd315578a 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -34,6 +34,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs"; import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; +import { invalidateUserProfile } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -103,9 +104,11 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); - // The status notes and narratives quote glucose in this unit; re-warm - // them so today's stored text does not keep the old one. + // The targets payload and the status notes and narratives carry glucose in + // this unit: evict the cached payloads and re-warm today's stored text so + // neither keeps the old one. if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) { + invalidateUserProfile(user.id); void refreshTextsAfterUnitChange(user.id); } diff --git a/src/components/settings/__tests__/glucose-unit-select.test.tsx b/src/components/settings/__tests__/glucose-unit-select.test.tsx index 8893020e8..49584a291 100644 --- a/src/components/settings/__tests__/glucose-unit-select.test.tsx +++ b/src/components/settings/__tests__/glucose-unit-select.test.tsx @@ -134,6 +134,19 @@ describe("Settings — GlucoseUnitSelect", () => { expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/); }); + // The targets panel takes its unit from the targets payload, not from the + // account, so a save that only refreshed `/me` left it on the old unit + // until the server cache turned over (up to ten minutes). Both ends have + // to let go of the old payload. + it("refreshes the targets payload on both ends", () => { + expect(componentSource).toMatch(/queryKey: queryKeys\.insightsTargets\(\)/); + const route = readFileSync( + join(process.cwd(), "src/app/api/auth/me/glucose-unit/route.ts"), + "utf8", + ); + expect(route).toMatch(/!== next\) \{\s*invalidateUserProfile\(user\.id\);/); + }); + it("is actually mounted on the settings page", () => { // A control nobody can reach is the same defect as no control. const accountSection = readFileSync( diff --git a/src/components/settings/glucose-unit-select.tsx b/src/components/settings/glucose-unit-select.tsx index 234ec0434..e98274c0b 100644 --- a/src/components/settings/glucose-unit-select.tsx +++ b/src/components/settings/glucose-unit-select.tsx @@ -31,7 +31,8 @@ import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; * Persistence mirrors the unit-system select: a change PATCHes * `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`, * so the dashboard tiles and the glucose insight page re-render on the next - * /me refetch. + * /me refetch, and `queryKeys.insightsTargets()`, whose payload states the + * target ranges in the unit it was built with. */ export function GlucoseUnitSelect({ isAuthenticated, @@ -66,6 +67,7 @@ export function GlucoseUnitSelect({ onSuccess: () => { toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); setOptimistic(null); }, onError: (err) => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 0081f1eea..4df20d5a0 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -242,10 +242,11 @@ export function invalidateUserHealthScore(userId: string): void { * on the very next read. * * Callers: `/api/user/thresholds` PUT + DELETE, `applyProfileUpdate` - * (only when heightCm / dateOfBirth / gender is in the update set), and - * `/api/auth/me/source-priority` PUT. The unit-preference toggle is NOT - * wired in: it only drives the client-side display transform, no - * server-cached payload reads it. + * (only when heightCm / dateOfBirth / gender is in the update set), + * `/api/auth/me/source-priority` PUT, and `/api/auth/me/glucose-unit` PATCH + * (the targets payload states glucose in the account's unit). The + * unit-preference toggle is NOT wired in: it only drives the client-side + * display transform, no server-cached payload reads it. */ export function invalidateUserProfile(userId: string): void { caches.insightsTargets.deleteByPrefix(userId);