From 587ea35d34ffa16fc586f36009b97ec15b2cc105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 10:29:36 +0200 Subject: [PATCH 1/3] fix(coach): bind the delete journal to the account and record it was sent for A tab outlives a logout, another login and a switch into a managed profile. A delete re-sent in another scope met a 404 that read as "already gone" and dropped the entry, or tripped the record fence and toasted a failure for a row the person could not see. Entries now carry their owner, only the current owner's are re-sent, and a session end clears the journal with the rest of the session state. --- .../__tests__/use-coach-delete.test.ts | 35 +++++- .../insights/coach-panel/use-coach.ts | 72 ++++-------- .../__tests__/use-auth-session-clear.test.ts | 12 ++ src/hooks/use-auth.ts | 4 + .../ai/coach/__tests__/delete-journal.test.ts | 96 +++++++++++++++ src/lib/ai/coach/delete-journal.ts | 111 ++++++++++++++++++ 6 files changed, 282 insertions(+), 48 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/delete-journal.test.ts create mode 100644 src/lib/ai/coach/delete-journal.ts diff --git a/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts index 09d83dbe8..7def301bb 100644 --- a/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts +++ b/src/components/insights/coach-panel/__tests__/use-coach-delete.test.ts @@ -6,6 +6,10 @@ import type { CoachConversationsPage, } from "@/lib/ai/coach/types"; import { queryKeys } from "@/lib/query-keys"; +import { + readCoachDeleteJournal, + writeCoachDeleteJournal, +} from "@/lib/ai/coach/delete-journal"; import { CONVERSATION_DELETE_UNDO_MS, commitCoachConversationDelete, @@ -180,18 +184,45 @@ describe("Coach conversation delete caches and request", () => { respond(404); const client = seeded(); const onFailed = vi.fn(); - commitCoachConversationDelete(client, "c1", onFailed); + commitCoachConversationDelete(client, "c1", null, onFailed); await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); await new Promise((resolve) => setTimeout(resolve, 0)); expect(onFailed).not.toHaveBeenCalled(); expect(headIds(client)).toEqual(["c2"]); }); + it("settles a 404 against the journal entry of its own scope only", async () => { + const map = new Map(); + vi.stubGlobal("window", { + sessionStorage: { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + removeItem: (key: string) => void map.delete(key), + }, + }); + try { + // The same id journaled in another record must survive this settle. + writeCoachDeleteJournal("user-a:managed-1", "c1", true); + respond(404); + const client = seeded(); + const onFailed = vi.fn(); + commitCoachConversationDelete(client, "c1", "user-a:own", onFailed); + expect(readCoachDeleteJournal("user-a:own")).toEqual(["c1"]); + await vi.waitFor(() => + expect(readCoachDeleteJournal("user-a:own")).toEqual([]), + ); + expect(onFailed).not.toHaveBeenCalled(); + expect(readCoachDeleteJournal("user-a:managed-1")).toEqual(["c1"]); + } finally { + vi.unstubAllGlobals(); + } + }); + it("restores the lists and reports a failed delete", async () => { respond(500); const client = seeded(); const onFailed = vi.fn(); - commitCoachConversationDelete(client, "c1", onFailed); + commitCoachConversationDelete(client, "c1", null, onFailed); expect(headIds(client)).toEqual(["c2"]); await vi.waitFor(() => expect(onFailed).toHaveBeenCalledOnce()); expect(headIds(client)).toEqual(["c1", "c2"]); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 1572f2880..3aa90e76a 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -46,6 +46,13 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import { getRecordScope } from "@/lib/query-keys/record-scope"; +import { + coachDeleteJournalOwner, + resendCoachDeleteJournal, + writeCoachDeleteJournal, +} from "@/lib/ai/coach/delete-journal"; +import { useAuth } from "@/hooks/use-auth"; /** * v1.4.20 phase B2b — TanStack Query + SSE client for the AI Coach @@ -430,46 +437,6 @@ function subscribeHiddenConversations(listener: () => void): () => void { return () => hiddenConversationListeners.delete(listener); } -/** - * Deletes sent but not yet confirmed, kept in `sessionStorage` so they - * outlive a reload. A reload inside the undo window sends the DELETE with - * `keepalive` from the old page, and the new page's list read can reach the - * server before it: without the journal that read brings the row back. The - * new page hides every journaled id and sends its DELETE again; a 404 then - * means it is already gone. - */ -const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes"; - -function readDeleteJournal(): string[] { - try { - const raw = window.sessionStorage.getItem(DELETE_JOURNAL_KEY); - const parsed: unknown = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) - ? parsed.filter((id): id is string => typeof id === "string") - : []; - } catch { - return []; - } -} - -function writeDeleteJournal(id: string, present: boolean): void { - try { - const ids = new Set(readDeleteJournal()); - if (present) ids.add(id); - else ids.delete(id); - if (ids.size === 0) { - window.sessionStorage.removeItem(DELETE_JOURNAL_KEY); - } else { - window.sessionStorage.setItem( - DELETE_JOURNAL_KEY, - JSON.stringify([...ids]), - ); - } - } catch { - // Storage unavailable: the keepalive request still carries the delete. - } -} - const deletesInFlight = new Set(); /** @@ -477,16 +444,20 @@ const deletesInFlight = new Set(); * `pagehide` is on the wire before the page is torn down. Every list cache * drops the row first; a failure restores them, shows the row again and * calls `onFailed`. A 404 is success: the row is gone, which is the point. + * That reading holds because the delete is only ever sent in the scope it + * was journaled under (`owner`, see `@/lib/ai/coach/delete-journal`); with no + * known owner nothing is journaled and the keepalive request is all there is. */ export function commitCoachConversationDelete( queryClient: QueryClient, id: string, + owner: string | null, onFailed: () => void, ): void { if (deletesInFlight.has(id)) return; deletesInFlight.add(id); setConversationHidden(id, true); - writeDeleteJournal(id, true); + if (owner !== null) writeCoachDeleteJournal(owner, id, true); const request = deleteCoachConversationRequest(id); const snapshot = removeCoachConversationFromCaches(queryClient, id); request @@ -506,7 +477,7 @@ export function commitCoachConversationDelete( ) .finally(() => { deletesInFlight.delete(id); - writeDeleteJournal(id, false); + if (owner !== null) writeCoachDeleteJournal(owner, id, false); void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }); }); } @@ -522,7 +493,7 @@ export function commitCoachConversationDelete( * sent to the background, a tab switched away), on `pagehide` (reload, * close, navigation away) and when the owning component unmounts; the * request goes out with `keepalive`, and a delete the page could not confirm - * is sent again by the next one (see the journal above). Before that, a + * is sent again by the next one (see `@/lib/ai/coach/delete-journal`). Before that, a * delete followed by a reload inside the window was never sent, and the * conversation came back. * @@ -532,6 +503,11 @@ export function commitCoachConversationDelete( export function useDeleteCoachConversationWithUndo() { const queryClient = useQueryClient(); const { t } = useTranslations(); + const { user } = useAuth(); + // The account and the record this page acts on; the journal is bound to it. + const owner = user + ? coachDeleteJournalOwner(user.id, getRecordScope()) + : null; const pendingDeleteIds = useSyncExternalStore( subscribeHiddenConversations, () => hiddenConversationIds, @@ -544,7 +520,7 @@ export function useDeleteCoachConversationWithUndo() { useEffect(() => { const failedMessage = t("insights.coach.historyDeleteFailed"); commitRef.current = (id) => - commitCoachConversationDelete(queryClient, id, () => + commitCoachConversationDelete(queryClient, id, owner, () => toast.error(failedMessage), ); }); @@ -560,9 +536,13 @@ export function useDeleteCoachConversationWithUndo() { ); useEffect(() => { - // Finish what a previous page of this tab could not confirm. - for (const id of readDeleteJournal()) commitRef.current(id); + // Finish what a previous page of this tab could not confirm, in this + // scope only: another account's or another record's entries stay put. + if (owner === null) return; + resendCoachDeleteJournal(owner, (id) => commitRef.current(id)); + }, [owner]); + useEffect(() => { const onVisibilityChange = () => { if (document.visibilityState === "hidden") getQueue().flush(); }; diff --git a/src/hooks/__tests__/use-auth-session-clear.test.ts b/src/hooks/__tests__/use-auth-session-clear.test.ts index e98968f80..6c64d4a92 100644 --- a/src/hooks/__tests__/use-auth-session-clear.test.ts +++ b/src/hooks/__tests__/use-auth-session-clear.test.ts @@ -29,6 +29,12 @@ vi.mock("@/lib/day/last-day", () => ({ clearLastDay: () => clearLastDay(), })); +// Coach deletes the ending session could not confirm belong to its account. +const clearCoachDeleteJournal = vi.fn(); +vi.mock("@/lib/ai/coach/delete-journal", () => ({ + clearCoachDeleteJournal: () => clearCoachDeleteJournal(), +})); + // `use-auth` pulls in the typed fetch wrapper, the router, and the i18n // context at module load. None of them matter for the pure session-end // helper, so stub them so the import is side-effect free in the test env. @@ -48,6 +54,7 @@ import { clearCachesForSessionEnd } from "../use-auth"; afterEach(() => { clearOfflineCachesForSessionEnd.mockClear(); clearLastDay.mockClear(); + clearCoachDeleteJournal.mockClear(); }); describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => { @@ -80,4 +87,9 @@ describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => { clearCachesForSessionEnd(new QueryClient()); expect(clearLastDay).toHaveBeenCalledTimes(1); }); + + it("clears the Coach delete journal", () => { + clearCachesForSessionEnd(new QueryClient()); + expect(clearCoachDeleteJournal).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index a8830df66..038625e60 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -28,6 +28,7 @@ import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearLastDay } from "@/lib/day/last-day"; +import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { setRecordScope, @@ -624,6 +625,9 @@ export function clearCachesForSessionEnd(queryClient: QueryClient): void { clearWasAuthenticated(); // The day the layer last showed is a date of this account's record. clearLastDay(); + // Coach deletes this session could not confirm belong to its account; the + // next one must not re-send them against its own record. + clearCoachDeleteJournal(); void clearOfflineCachesForSessionEnd(); } diff --git a/src/lib/ai/coach/__tests__/delete-journal.test.ts b/src/lib/ai/coach/__tests__/delete-journal.test.ts new file mode 100644 index 000000000..a3be2f9cd --- /dev/null +++ b/src/lib/ai/coach/__tests__/delete-journal.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearCoachDeleteJournal, + coachDeleteJournalOwner, + readCoachDeleteJournal, + resendCoachDeleteJournal, + writeCoachDeleteJournal, +} from "../delete-journal"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: (index) => [...map.keys()][index] ?? null, + removeItem: (key) => void map.delete(key), + setItem: (key, value) => void map.set(key, String(value)), + }; +} + +const KEY = "healthlog:coach-conversation-deletes"; +let storage: Storage; + +beforeEach(() => { + storage = memoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const alice = coachDeleteJournalOwner("user-a", null); +const aliceInManaged = coachDeleteJournalOwner("user-a", "managed-1"); +const bob = coachDeleteJournalOwner("user-b", null); + +describe("Coach delete journal", () => { + it("binds each entry to the account and the record it was sent for", () => { + writeCoachDeleteJournal(alice, "c1", true); + writeCoachDeleteJournal(aliceInManaged, "c2", true); + expect(readCoachDeleteJournal(alice)).toEqual(["c1"]); + expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]); + expect(readCoachDeleteJournal(bob)).toEqual([]); + + writeCoachDeleteJournal(alice, "c1", false); + expect(readCoachDeleteJournal(alice)).toEqual([]); + expect(readCoachDeleteJournal(aliceInManaged)).toEqual(["c2"]); + }); + + it("re-sends nothing after a logout and another login in the same tab", () => { + writeCoachDeleteJournal(alice, "c1", true); + const commit = vi.fn(); + resendCoachDeleteJournal(bob, commit); + expect(commit).not.toHaveBeenCalled(); + // The entry waits for its own scope instead of being spent on the wrong + // one, where a 404 would have read as "already gone". + expect(readCoachDeleteJournal(alice)).toEqual(["c1"]); + }); + + it("re-sends nothing outside the record after a switch into a managed profile", () => { + writeCoachDeleteJournal(alice, "own-1", true); + writeCoachDeleteJournal(aliceInManaged, "managed-c", true); + + const inManaged = vi.fn(); + resendCoachDeleteJournal(aliceInManaged, inManaged); + expect(inManaged.mock.calls).toEqual([["managed-c"]]); + + const backHome = vi.fn(); + resendCoachDeleteJournal(alice, backHome); + expect(backHome.mock.calls).toEqual([["own-1"]]); + }); + + it("drops entries without an owner instead of re-sending them anywhere", () => { + storage.setItem(KEY, JSON.stringify(["legacy-id"])); + const commit = vi.fn(); + resendCoachDeleteJournal(alice, commit); + expect(commit).not.toHaveBeenCalled(); + }); + + it("is emptied by a session end, whoever the entries belonged to", () => { + writeCoachDeleteJournal(alice, "c1", true); + writeCoachDeleteJournal(bob, "c2", true); + clearCoachDeleteJournal(); + expect(storage.getItem(KEY)).toBeNull(); + }); + + it("stays quiet without storage", () => { + vi.stubGlobal("window", undefined); + expect(() => writeCoachDeleteJournal(alice, "c1", true)).not.toThrow(); + expect(readCoachDeleteJournal(alice)).toEqual([]); + expect(() => clearCoachDeleteJournal()).not.toThrow(); + }); +}); diff --git a/src/lib/ai/coach/delete-journal.ts b/src/lib/ai/coach/delete-journal.ts new file mode 100644 index 000000000..372092e4f --- /dev/null +++ b/src/lib/ai/coach/delete-journal.ts @@ -0,0 +1,111 @@ +/** + * Coach conversation deletes sent but not yet confirmed, kept in + * `sessionStorage` so they outlive a reload. A reload inside the undo window + * sends the DELETE with `keepalive` from the old page, and the new page's list + * read can reach the server before it: without the journal that read brings + * the row back. The new page hides every journaled id and sends its DELETE + * again; a 404 then means it is already gone. + * + * Every entry carries the owner it was sent for: the signed-in account and + * the record it was reading (`:`). A tab outlives + * a logout, another login and a switch into a managed profile, and a delete + * re-sent in any of those other scopes asks the wrong record: the server + * answers 404, which would read as "already gone" and drop the entry while the + * conversation still exists, or the record fence refuses it and a failure + * toast names a row the person cannot see. So only entries of the current + * owner are re-sent, which is also what keeps a 404 meaningful, and a session + * end clears the journal with the rest of the session's state. + */ +const DELETE_JOURNAL_KEY = "healthlog:coach-conversation-deletes"; + +export interface CoachDeleteJournalEntry { + id: string; + owner: string; +} + +/** The journal owner for an account reading `recordScope` (null = own). */ +export function coachDeleteJournalOwner( + userId: string, + recordScope: string | null, +): string { + return `${userId}:${recordScope ?? "own"}`; +} + +function safeSessionStorage(): Storage | null { + try { + return typeof window === "undefined" ? null : window.sessionStorage; + } catch { + return null; + } +} + +function readAll(storage: Storage): CoachDeleteJournalEntry[] { + try { + const raw = storage.getItem(DELETE_JOURNAL_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + // An entry without an owner (the earlier bare-id shape) cannot be bound + // to a scope and is dropped rather than re-sent anywhere. + return parsed.filter( + (entry): entry is CoachDeleteJournalEntry => + typeof entry === "object" && + entry !== null && + typeof (entry as CoachDeleteJournalEntry).id === "string" && + typeof (entry as CoachDeleteJournalEntry).owner === "string", + ); + } catch { + return []; + } +} + +/** The ids journaled for `owner`, and only those. */ +export function readCoachDeleteJournal(owner: string): string[] { + const storage = safeSessionStorage(); + if (!storage) return []; + return readAll(storage) + .filter((entry) => entry.owner === owner) + .map((entry) => entry.id); +} + +/** + * Re-send what a previous page of this tab could not confirm, for `owner` + * only: another account's or another record's entries stay where they are + * until their own scope comes back or the session ends. + */ +export function resendCoachDeleteJournal( + owner: string, + commit: (id: string) => void, +): void { + for (const id of readCoachDeleteJournal(owner)) commit(id); +} + +/** Add (`present`) or remove one delete of `owner`. Best effort. */ +export function writeCoachDeleteJournal( + owner: string, + id: string, + present: boolean, +): void { + const storage = safeSessionStorage(); + if (!storage) return; + try { + const entries = readAll(storage).filter( + (entry) => !(entry.owner === owner && entry.id === id), + ); + if (present) entries.push({ id, owner }); + if (entries.length === 0) storage.removeItem(DELETE_JOURNAL_KEY); + else storage.setItem(DELETE_JOURNAL_KEY, JSON.stringify(entries)); + } catch { + // Storage unavailable: the keepalive request still carries the delete. + } +} + +/** Forget every journaled delete, whoever it belonged to (a session end). */ +export function clearCoachDeleteJournal(): void { + const storage = safeSessionStorage(); + if (!storage) return; + try { + storage.removeItem(DELETE_JOURNAL_KEY); + } catch { + return; + } +} From 0ea8d929d2a3285acd7a1a4afc825185351119b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 10:44:17 +0200 Subject: [PATCH 2/3] fix(correlations): date the earlier-engine note from the instance's first new run The note for records whose findings predate the seasonally adjusted engine compared against a fixed calendar date, so findings an instance kept writing under the earlier engine until its deploy did not count. The first discovery sync of the new engine now stamps AppSettings.correlationSeasonalEngineSince before it writes; without a stamp every stored discovery pattern counts as an earlier finding. A restore from a portable export reset each pattern's createdAt to the restore day and dropped the note for good. createdAt now rides every export, and a file without it falls back to lastComputedAt. --- .../migration.sql | 4 + prisma/schema.prisma | 8 + src/lib/export/profile-backup.ts | 15 +- src/lib/insights/correlation-patterns.ts | 84 +++++++-- ...indings-before-seasonal-adjustment.test.ts | 170 ++++++++++++++++-- 5 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql diff --git a/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql new file mode 100644 index 000000000..4a9b66e3c --- /dev/null +++ b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql @@ -0,0 +1,4 @@ +-- When the instance first stored a discovery finding under the seasonally +-- adjusted correlation engine. Stamped once by the engine itself; null until +-- its first run, which counts every existing pattern as an earlier finding. +ALTER TABLE "app_settings" ADD COLUMN "correlation_seasonal_engine_since" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 911083311..49e96f28f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5171,6 +5171,14 @@ model AppSettings { /// step names who confirmed when the account still exists. encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id") + /// When this instance first stored a discovery finding under the seasonally + /// adjusted correlation engine (v1.42). Stamped once, by the first + /// `syncAcceptedPatterns` run of a discovery family, before it writes; null + /// until then. Consumer: `hadFindingsBeforeSeasonalAdjustment`, which counts + /// a discovery pattern created before it (or any, while it is null) as a + /// finding of the earlier engine and so earns the one-time note. + correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + @@map("app_settings") } diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 614a4d4a3..282d01a70 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -506,10 +506,15 @@ export async function buildProfileBackupSection( ...(disasterRecovery ? { id: pattern.id, - createdAt: pattern.createdAt.toISOString(), updatedAt: pattern.updatedAt.toISOString(), } : {}), + // Carried in every export, not only DR: when a finding was first made + // is part of what it means. The correlation surface reads it to tell a + // finding of the earlier engine from one of the seasonally adjusted + // engine, and a restore that reset it to the restore day would drop + // that note for good. + createdAt: pattern.createdAt.toISOString(), canonicalKey: pattern.canonicalKey, family: pattern.family, factorKey: pattern.factorKey, @@ -945,9 +950,11 @@ export async function restoreProfileData( dismissedEvidenceHash: pattern.dismissedEvidenceHash, dismissedEffectSize: pattern.dismissedEffectSize, dismissedSampleSize: pattern.dismissedSampleSize, - ...(pattern.createdAt - ? { createdAt: new Date(pattern.createdAt) } - : {}), + // A file from before portable exports carried `createdAt` falls back + // to the last computation, the latest instant the finding certainly + // existed: still earlier than this restore, which is what keeps a + // finding of the earlier engine counted as one. + createdAt: new Date(pattern.createdAt ?? pattern.lastComputedAt), ...(pattern.updatedAt ? { updatedAt: new Date(pattern.updatedAt) } : {}), diff --git a/src/lib/insights/correlation-patterns.ts b/src/lib/insights/correlation-patterns.ts index 66625b36f..7d72fe4bb 100644 --- a/src/lib/insights/correlation-patterns.ts +++ b/src/lib/insights/correlation-patterns.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export const PATTERN_FAMILIES = { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE", @@ -140,6 +141,12 @@ export async function syncAcceptedPatterns(args: { })); const keys = prepared.map((item) => item.canonicalKey); + // The earlier-engine cutoff has to exist before the new engine writes its + // first discovery pattern, or that pattern would count as an earlier one. + if (DISCOVERY_FAMILIES.includes(args.family)) { + await stampSeasonalEngineStart(); + } + return prisma.$transaction(async (tx) => { const existing = await tx.correlationPattern.findMany({ where: { userId: args.userId, canonicalKey: { in: keys } }, @@ -252,33 +259,78 @@ export function decisionForEvidence( } /** - * The day the discovery engine started correlating seasonally adjusted - * residuals with an effective-sample-size p-value (v1.42). Before it, two - * series that merely shared the season or a long trend surfaced as a - * finding, so an account with history usually sees fewer pairs afterwards. + * The discovery engine correlates seasonally adjusted residuals with an + * effective-sample-size p-value since v1.42. Before it, two series that + * merely shared the season or a long trend surfaced as a finding, so an + * account with history usually sees fewer pairs afterwards. + * + * Where that change took effect is an instance fact, not a calendar date: an + * instance keeps writing earlier-engine findings until it runs the new build. + * The first discovery sync of the new engine stamps + * `AppSettings.correlationSeasonalEngineSince`, before it writes anything, and + * the cutoff below reads that stamp. Stamped once per instance; the process + * remembers it so later syncs skip the write. */ -export const SEASONAL_ADJUSTMENT_SINCE = new Date("2026-10-08T00:00:00.000Z"); +const DISCOVERY_FAMILIES: readonly PatternFamily[] = [ + PATTERN_FAMILIES.discoveryRetrospective, + PATTERN_FAMILIES.discoveryRecent, +]; + +let seasonalEngineStamped = false; + +/** + * Record that the seasonally adjusted engine is running here, unless already + * recorded. Best effort: a failed stamp leaves the marker unset, which counts + * every pattern as an earlier finding (the note shows once more than it + * should), never the reverse. + */ +export async function stampSeasonalEngineStart( + now: Date = new Date(), +): Promise { + if (seasonalEngineStamped) return; + try { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: now }, + update: {}, + }); + await prisma.appSettings.updateMany({ + where: { id: "singleton", correlationSeasonalEngineSince: null }, + data: { correlationSeasonalEngineSince: now }, + }); + seasonalEngineStamped = true; + } catch { + getEvent()?.addWarning("correlation seasonal engine stamp failed"); + } +} + +/** Test seam: forget that this process already stamped the marker. */ +export function resetSeasonalEngineStampForTests(): void { + seasonalEngineStamped = false; +} /** * Whether the record held a discovery finding under the earlier engine: a - * stored discovery pattern first written before {@link SEASONAL_ADJUSTMENT_SINCE}. - * A dismissed pattern counts, it was a finding. An account created after the - * change can never qualify, so the one-time explanation on the correlation - * surface reaches only the people whose list actually changed. + * stored discovery pattern created before the instance's seasonal-engine + * stamp, or any stored discovery pattern while there is no stamp yet. A + * dismissed pattern counts, it was a finding. An account whose first finding + * came from the new engine never qualifies, so the one-time explanation on + * the correlation surface reaches only the people whose list actually + * changed. */ export async function hadFindingsBeforeSeasonalAdjustment( userId: string, ): Promise { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { correlationSeasonalEngineSince: true }, + }); + const since = settings?.correlationSeasonalEngineSince ?? null; const row = await prisma.correlationPattern.findFirst({ where: { userId, - family: { - in: [ - PATTERN_FAMILIES.discoveryRetrospective, - PATTERN_FAMILIES.discoveryRecent, - ], - }, - createdAt: { lt: SEASONAL_ADJUSTMENT_SINCE }, + family: { in: [...DISCOVERY_FAMILIES] }, + ...(since ? { createdAt: { lt: since } } : {}), }, select: { id: true }, }); diff --git a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts index 7534347f2..91af5b9ee 100644 --- a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts +++ b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts @@ -3,25 +3,33 @@ * * The flag decides who sees the one-time note on the correlation surface, so * it must hold only for a record that kept a discovery pattern from before the - * engine change: not for a pattern written since, not for another account's - * old pattern, and not for a non-discovery family. + * engine change. The change is located by the instance's own marker + * (`AppSettings.correlationSeasonalEngineSince`), which the first discovery + * sync of the new engine stamps before it writes, not by a calendar date. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { + canonicalPatternKey, hadFindingsBeforeSeasonalAdjustment, PATTERN_FAMILIES, - SEASONAL_ADJUSTMENT_SINCE, + resetSeasonalEngineStampForTests, + syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; +import { + buildProfileBackupSection, + restoreProfileData, +} from "@/lib/export/profile-backup"; vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); const DAY_MS = 24 * 60 * 60 * 1000; -const BEFORE = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() - 30 * DAY_MS); -const AFTER = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() + DAY_MS); +const MARKER = new Date("2026-10-08T00:00:00.000Z"); +const BEFORE = new Date(MARKER.getTime() - 30 * DAY_MS); +const AFTER = new Date(MARKER.getTime() + DAY_MS); let seq = 0; async function seedUser() { @@ -40,40 +48,60 @@ async function seedPattern(userId: string, family: string, createdAt: Date) { await getPrismaClient().correlationPattern.create({ data: { userId, - canonicalKey: `p1:test-${seq}`, + canonicalKey: canonicalPatternKey(`FACTOR_${seq}`, "MOOD", 1), family, - factorKey: "ACTIVITY_STEPS", + factorKey: `FACTOR_${seq}`, outcomeKey: "MOOD", lagDays: 1, sampleSize: 60, effectSize: 0.4, pValue: 0.001, qValue: 0.01, - evidenceHash: `hash-${seq}`, + evidenceHash: "a".repeat(64), lastComputedAt: createdAt, createdAt, }, }); } +async function setMarker(at: Date | null) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: at }, + update: { correlationSeasonalEngineSince: at }, + }); +} + beforeEach(async () => { await truncateAllTables(getPrismaClient()); + resetSeasonalEngineStampForTests(); }); describe("hadFindingsBeforeSeasonalAdjustment", () => { - it("holds for a record with a discovery pattern stored before the change", async () => { + it("counts every existing discovery pattern while the instance has no marker", async () => { + const user = await seedUser(); + // Written by the earlier engine right up to the deploy: no fixed date + // may exclude it. + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("holds for a record with a discovery pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("holds for an early-detection pattern stored before the change", async () => { + it("holds for an early-detection pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRecent, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("does not hold for a pattern first stored after the change", async () => { + it("does not hold for a pattern first stored after the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); @@ -96,3 +124,123 @@ describe("hadFindingsBeforeSeasonalAdjustment", () => { expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); }); }); + +describe("the first discovery sync of the new engine", () => { + const evidence = { + factorKey: "ACTIVITY_STEPS", + outcomeKey: "SLEEP_DURATION", + lagDays: 0, + sampleSize: 90, + effectSize: 0.35, + pValue: 0.002, + qValue: 0.02, + }; + + it("stamps the marker before it writes, once", async () => { + const earlier = await seedUser(); + await seedPattern( + earlier.id, + PATTERN_FAMILIES.discoveryRetrospective, + BEFORE, + ); + const fresh = await seedUser(); + + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRetrospective, + accepted: [evidence], + }); + + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + const stamp = settings?.correlationSeasonalEngineSince; + expect(stamp).toBeInstanceOf(Date); + // The new engine's own finding is not an earlier one; the old one is. + expect(await hadFindingsBeforeSeasonalAdjustment(fresh.id)).toBe(false); + expect(await hadFindingsBeforeSeasonalAdjustment(earlier.id)).toBe(true); + + // A later run (another process) leaves the first stamp standing. + resetSeasonalEngineStampForTests(); + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRecent, + accepted: [evidence], + }); + const again = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(again?.correlationSeasonalEngineSince?.toISOString()).toBe( + stamp?.toISOString(), + ); + }); + + it("is not triggered by a non-discovery family", async () => { + const user = await seedUser(); + await syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.fixed, + accepted: [evidence], + }); + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(settings?.correlationSeasonalEngineSince ?? null).toBeNull(); + }); +}); + +describe("a restore", () => { + it("keeps when a finding was first made, so the note survives it", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + + // The portable export is the one that used to drop `createdAt`. + const section = await buildProfileBackupSection(prisma, user.id, { + purpose: "portable-export", + }); + expect(section.correlationPatterns[0]?.createdAt).toBe( + BEFORE.toISOString(), + ); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: section.correlationPatterns, + }), + ); + + const restored = await prisma.correlationPattern.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(restored.createdAt.toISOString()).toBe(BEFORE.toISOString()); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("falls back to the last computation for a file without createdAt", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + const section = await buildProfileBackupSection(prisma, user.id); + const legacy = section.correlationPatterns.map((pattern) => { + const copy = { ...pattern }; + delete copy.createdAt; + return copy; + }); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: legacy, + }), + ); + + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); +}); From 470ddeedc4ab5cf73873be784cc2dd1ae5e6fc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 10:44:18 +0200 Subject: [PATCH 3/3] fix(version): move the finer geo state to the admin status geoLookup and geoProviderChosen on the public /api/version added one more way to tell instances apart. They now live on /api/admin/status (requireAdmin, cookie only) as geo { lookup, providerChosen, providerHost }, which both admin status views read. offlineGeoEnabled and geoProviderHost stay public as before. --- docs/api/openapi.yaml | 14 ----- .../api/admin/status/__tests__/route.test.ts | 24 +++++++++ src/app/api/admin/status/route.ts | 12 +++++ src/app/api/version/__tests__/route.test.ts | 21 +++++++- src/app/api/version/route.ts | 14 ++--- .../admin/__tests__/geo-lookup-state.test.ts | 35 ++++-------- .../system-status-worker-contrast.test.tsx | 5 ++ src/components/admin/_shared.tsx | 28 ++++++---- .../admin/system-status-section.tsx | 47 ++++++++-------- .../admin/system-status-summary.tsx | 53 ++++++++----------- src/lib/openapi/routes/meta.ts | 10 ---- 11 files changed, 135 insertions(+), 128 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b1e6eeb4b..77fa87cf8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36065,18 +36065,6 @@ components: type: string description: Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default. - geoLookup: - type: string - enum: - - offline - - online - - off - description: "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider - above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`)." - geoProviderChosen: - type: boolean - description: The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in - use. required: - version - buildSha @@ -36087,8 +36075,6 @@ components: - docs - offlineGeoEnabled - geoProviderHost - - geoLookup - - geoProviderChosen additionalProperties: false description: The running build's identity plus the offline-geolocation state. Public — no authentication, no account data. AssistantFlagsEnvelope: diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index bffaa5be3..deef1897b 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,6 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); +const geoMock = vi.hoisted(() => ({ + lookup: "online" as "offline" | "online" | "off", + chosen: false, + host: "ipwho.is", +})); +vi.mock("@/lib/geo", () => ({ + geoLookupMode: () => geoMock.lookup, + geoProviderChosen: () => geoMock.chosen, + resolveGeoProviderHost: () => geoMock.host, +})); + vi.mock("@/lib/jobs/job-failures", () => ({ JOB_FAILURE_WINDOW_HOURS: 72, readFailingQueues: vi.fn(), @@ -208,4 +219,17 @@ describe("GET /api/admin/status (integration health summary)", () => { ], }); }); + + it("carries the login-IP geo state the public version route no longer shows", async () => { + geoMock.lookup = "online"; + geoMock.chosen = true; + geoMock.host = "ip-api.com"; + const res = await GET(); + const body = (await res.json()) as { data: { geo: unknown } }; + expect(body.data.geo).toEqual({ + lookup: "online", + providerChosen: true, + providerHost: "ip-api.com", + }); + }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index d94de0dd4..5e7f4b9ab 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,6 +9,11 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; +import { + geoLookupMode, + geoProviderChosen, + resolveGeoProviderHost, +} from "@/lib/geo"; // Build timestamp (set at build time via next.config) const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString(); @@ -109,5 +114,12 @@ export const GET = apiHandler(async () => { : null, webPush: webPushConfigured ? { configured: true } : null, }, + // How login IPs are placed on this host. Admin-only: the public + // `/api/version` keeps just `offlineGeoEnabled` and `geoProviderHost`. + geo: { + lookup: geoLookupMode(), + providerChosen: geoProviderChosen(), + providerHost: resolveGeoProviderHost(), + }, }); }); diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index cba8c7961..4803e2ab5 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -9,8 +9,6 @@ const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is"); vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => offlineGeoReadyMock(), resolveGeoProviderHost: () => resolveGeoProviderHostMock(), - geoLookupMode: () => (offlineGeoReadyMock() ? "offline" : "online"), - geoProviderChosen: () => false, })); import { GET } from "../route"; @@ -108,4 +106,23 @@ describe("GET /api/version", () => { const body = (await response.json()) as VersionEnvelope; expect(body.data.geoProviderHost).toBe("ip-api.com"); }); + + it("keeps the finer geo state off the public answer", async () => { + // Lookup mode and whether the operator chose the provider are admin-only + // (`/api/admin/status`); the public route would only add a way to tell + // instances apart. + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "buildSha", + "builtAt", + "changelog", + "docs", + "geoProviderHost", + "license", + "offlineGeoEnabled", + "repository", + "version", + ]); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index f737480be..9c0435b8e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,11 +1,6 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { - geoLookupMode, - geoProviderChosen, - offlineGeoReady, - resolveGeoProviderHost, -} from "@/lib/geo"; +import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo"; import packageJson from "../../../../package.json"; // v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo @@ -41,7 +36,10 @@ export const dynamic = "force-dynamic"; * `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default * `ipwho.is`) and the resolver emits a one-shot admin notification on * first use. `geoProviderHost` lets the admin status surface name the - * real provider instead of assuming the default. + * real provider instead of assuming the default. Both were public before + * v1.42 and stay so for existing readers; the finer geo state (lookup mode, + * whether the operator chose the provider) is admin-only and lives on + * `/api/admin/status`, so it adds nothing to tell instances apart by. */ export const GET = apiHandler(async () => { // v1.4.43 B11 — prefer the build-arg-injected env var so the runtime @@ -69,7 +67,5 @@ export const GET = apiHandler(async () => { docs: "https://docs.healthlog.dev", offlineGeoEnabled, geoProviderHost, - geoLookup: geoLookupMode(), - geoProviderChosen: geoProviderChosen(), }); }); diff --git a/src/components/admin/__tests__/geo-lookup-state.test.ts b/src/components/admin/__tests__/geo-lookup-state.test.ts index 17da78f9c..f7b062cb1 100644 --- a/src/components/admin/__tests__/geo-lookup-state.test.ts +++ b/src/components/admin/__tests__/geo-lookup-state.test.ts @@ -1,41 +1,24 @@ import { describe, expect, it } from "vitest"; -import { geoLookupState, type PublicVersion } from "../_shared"; +import { geoLookupState, type AdminGeoStatus } from "../_shared"; -const base: PublicVersion = { - version: "1.42.0", - buildSha: null, - builtAt: null, +const base: AdminGeoStatus = { + lookup: "online", + providerChosen: false, + providerHost: "ipwho.is", }; describe("geoLookupState", () => { it("warns only about the unconfigured default provider", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - }), - ).toBe("default"); + expect(geoLookupState(base)).toBe("default"); }); it("treats a provider the operator named as a decision", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - geoProviderChosen: true, - }), - ).toBe("chosen"); + expect(geoLookupState({ ...base, providerChosen: true })).toBe("chosen"); }); it("reads a switched-off lookup and the offline databases as settled", () => { - expect( - geoLookupState({ ...base, offlineGeoEnabled: false, geoLookup: "off" }), - ).toBe("off"); - expect(geoLookupState({ ...base, offlineGeoEnabled: true })).toBe( - "offline", - ); + expect(geoLookupState({ ...base, lookup: "off" })).toBe("off"); + expect(geoLookupState({ ...base, lookup: "offline" })).toBe("offline"); }); }); diff --git a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx index a6c49734f..9ee36ebd4 100644 --- a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx +++ b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx @@ -50,6 +50,11 @@ vi.mock("@tanstack/react-query", () => ({ activeSessions: 1, }, integrations: { umami: null, glitchtip: null, webPush: null }, + geo: { + lookup: "offline", + providerChosen: false, + providerHost: "ipwho.is", + }, }, isLoading: false, isError: false, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 4c3855915..023211450 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -81,6 +81,8 @@ export interface SystemStatus { glitchtip: { configured: boolean; enabled: boolean } | null; webPush: { configured: boolean } | null; }; + /** Login-IP geolocation state (admin-only since v1.42). */ + geo: AdminGeoStatus; } export interface AdminSettings { @@ -590,10 +592,16 @@ export interface PublicVersion { // offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is). // Optional so legacy /api/version responses still satisfy the shape. geoProviderHost?: string; - /** v1.42 — `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ - geoLookup?: "offline" | "online" | "off"; - /** v1.42 — the operator named the provider in IP_GEO_LOOKUP_URL. */ - geoProviderChosen?: boolean; +} + +/** How login IPs are placed on this host, as `/api/admin/status` reports it. */ +export interface AdminGeoStatus { + /** `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ + lookup: "offline" | "online" | "off"; + /** The operator named the provider in IP_GEO_LOOKUP_URL. */ + providerChosen: boolean; + /** Host of the online provider (IP_GEO_LOOKUP_URL, default ipwho.is). */ + providerHost: string; } /** @@ -602,15 +610,15 @@ export interface PublicVersion { * warns, because it sends every login IP to a third party nobody chose. */ export function geoLookupState( - version: PublicVersion, + geo: AdminGeoStatus, ): "offline" | "off" | "chosen" | "default" { - if (version.offlineGeoEnabled) return "offline"; - if (version.geoLookup === "off") return "off"; - return version.geoProviderChosen ? "chosen" : "default"; + if (geo.lookup === "offline") return "offline"; + if (geo.lookup === "off") return "off"; + return geo.providerChosen ? "chosen" : "default"; } -/** Shared `/api/version` reader — the system-status section + overview - * summary both surface app version / build SHA / offline-geo state. */ +/** Shared `/api/version` reader for the version tile (version, build SHA, + * update check). The geo state comes from `/api/admin/status`. */ export function usePublicVersion() { return useQuery({ queryKey: queryKeys.publicVersion(), diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 334a83e2d..a3cf1f4c4 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -25,7 +25,6 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { geoLookupState, StatusItem, - usePublicVersion, useSystemStatus, type FailingQueue, } from "./_shared"; @@ -129,7 +128,6 @@ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); return (
@@ -238,30 +236,27 @@ export function SystemStatusSection() { tone="success" /> )} - {/* v1.4.27 R5 — offline GeoLite2 availability. Renders only - when /api/version returns the new field so legacy - responses do not produce a placeholder row. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + {/* v1.4.27 R5 — offline GeoLite2 availability, read from the + admin status rather than the public /api/version. */} +
) : isError ? ( // P19: surface load failures inline instead of leaving the diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 19ae088dd..c2088e662 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -26,18 +26,12 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; -import { - geoLookupState, - StatusItem, - usePublicVersion, - useSystemStatus, -} from "./_shared"; +import { geoLookupState, StatusItem, useSystemStatus } from "./_shared"; import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); const { data: keyBackup } = useKeyBackupStatus(); return ( @@ -92,30 +86,27 @@ export function SystemStatusSummary() { /> )} {/* v1.4.27 R5 — surface the offline-geo state so the maintainer - spots the missing MAXMIND_LICENSE_KEY without crawling logs. - The field is undefined on legacy responses; the row only - renders when /api/version answers the new shape. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + spots the missing GeoLite2 databases without crawling logs. + Read from the admin status, not the public /api/version. */} + ) : isError ? (