Merge the delete journal scope, seasonal marker and admin-only geo state into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:51:44 +02:00
commit fe87af7234
22 files changed
+667 -207

No files matched your search

-14
View File
@@ -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:
@@ -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);
+8
View File
@@ -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")
}
@@ -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",
});
});
});
+12
View File
@@ -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(),
},
});
});
+19 -2
View File
@@ -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<Response>)();
const body = (await response.json()) as { data: Record<string, unknown> };
expect(Object.keys(body.data).sort()).toEqual([
"buildSha",
"builtAt",
"changelog",
"docs",
"geoProviderHost",
"license",
"offlineGeoEnabled",
"repository",
"version",
]);
});
});
+5 -9
View File
@@ -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(),
});
});
@@ -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");
});
});
@@ -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,
+18 -10
View File
@@ -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(),
+21 -26
View File
@@ -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 (
<div className="space-y-6">
@@ -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 && (
<StatusItem
icon={Map}
label={t("admin.offlineGeoLabel")}
value={
{
offline: t("admin.offlineGeoEnabled"),
off: t("admin.offlineGeoOff"),
chosen: t("admin.offlineGeoChosen", {
host: version.geoProviderHost ?? "ipwho.is",
}),
default: t("admin.offlineGeoFallback", {
host: version.geoProviderHost ?? "ipwho.is",
}),
}[geoLookupState(version)]
}
tone={
geoLookupState(version) === "default" ? "warning" : "success"
}
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability, read from the
admin status rather than the public /api/version. */}
<StatusItem
icon={Map}
label={t("admin.offlineGeoLabel")}
value={
{
offline: t("admin.offlineGeoEnabled"),
off: t("admin.offlineGeoOff"),
chosen: t("admin.offlineGeoChosen", {
host: status.geo.providerHost,
}),
default: t("admin.offlineGeoFallback", {
host: status.geo.providerHost,
}),
}[geoLookupState(status.geo)]
}
tone={
geoLookupState(status.geo) === "default" ? "warning" : "success"
}
/>
</div>
) : isError ? (
// P19: surface load failures inline instead of leaving the
+22 -31
View File
@@ -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 && (
<StatusItem
icon={Globe}
label={t("admin.overview.snapshotOfflineGeo")}
value={
{
offline: t("admin.overview.snapshotOfflineGeoOn"),
off: t("admin.offlineGeoOff"),
chosen: t("admin.offlineGeoChosen", {
host: version.geoProviderHost ?? "ipwho.is",
}),
default: t("admin.overview.snapshotOfflineGeoOff", {
host: version.geoProviderHost ?? "ipwho.is",
}),
}[geoLookupState(version)]
}
tone={
geoLookupState(version) === "default" ? "warning" : "success"
}
/>
)}
spots the missing GeoLite2 databases without crawling logs.
Read from the admin status, not the public /api/version. */}
<StatusItem
icon={Globe}
label={t("admin.overview.snapshotOfflineGeo")}
value={
{
offline: t("admin.overview.snapshotOfflineGeoOn"),
off: t("admin.offlineGeoOff"),
chosen: t("admin.offlineGeoChosen", {
host: status.geo.providerHost,
}),
default: t("admin.overview.snapshotOfflineGeoOff", {
host: status.geo.providerHost,
}),
}[geoLookupState(status.geo)]
}
tone={
geoLookupState(status.geo) === "default" ? "warning" : "success"
}
/>
</div>
) : isError ? (
<QueryErrorRow
@@ -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<string, string>();
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"]);
@@ -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<string>();
/**
@@ -477,16 +444,20 @@ const deletesInFlight = new Set<string>();
* `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();
};
@@ -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);
});
});
+4
View File
@@ -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();
}
@@ -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<string, string>();
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();
});
});
+111
View File
@@ -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 (`<userId>:<recordScope | "own">`). 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;
}
}
+11 -4
View File
@@ -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) }
: {}),
+68 -16
View File
@@ -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<void> {
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<boolean> {
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 },
});
-10
View File
@@ -239,16 +239,6 @@ const versionResponse = z
.describe(
"Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default.",
),
geoLookup: z
.enum(["offline", "online", "off"])
.describe(
"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: z
.boolean()
.describe(
"The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in use.",
),
})
.meta({
id: "VersionResponse",
@@ -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);
});
});