Merge the seasonal adjustment note into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:30:11 +02:00
commit 402c503781
17 files changed
+445 -19

No files matched your search

+6
View File
@@ -62559,11 +62559,17 @@ components:
description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled;
absent-degrading on sparse draws.
$ref: "#/components/schemas/LabCorrelationResult"
findingsBeforeSeasonalAdjustment:
type: boolean
description: v1.42 — true when the record held a discovery finding before the engine began removing trend and season
from both series. Drives a one-time explanation of why fewer pairs surface; false for every account created
since.
required:
- discovered
- pairsTested
- fdrQ
- minPairs
- findingsBeforeSeasonalAdjustment
additionalProperties: false
description: v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour
→ next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "Dieses Muster konnte nicht ausgeblendet werden.",
"restoreError": "Dieses Muster konnte nicht wiederhergestellt werden.",
"loadError": "Muster konnten nicht geladen werden.",
"empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht."
"empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht.",
"seasonalNote": "Seit Version 1.42 rechnet HealthLog Jahreszeit und Langzeittrend heraus, bevor es Werte vergleicht. Du siehst dadurch weniger Zusammenhänge, aber die verbliebenen halten besser. Viele der früheren Muster waren der Kalender.",
"seasonalNoteDismiss": "Hinweis schließen"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "Could not dismiss this pattern.",
"restoreError": "Could not restore this pattern.",
"loadError": "Patterns could not be loaded.",
"empty": "No current pattern cleared the evidence threshold."
"empty": "No current pattern cleared the evidence threshold.",
"seasonalNote": "Since version 1.42, HealthLog removes season and long-term trend before it compares values. You see fewer relationships, but the ones that remain hold up better. Many of the earlier patterns were just the calendar.",
"seasonalNoteDismiss": "Dismiss note"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "No se pudo descartar este patrón.",
"restoreError": "No se pudo restaurar este patrón.",
"loadError": "No se pudieron cargar los patrones.",
"empty": "Ningún patrón actual superó el umbral de evidencia."
"empty": "Ningún patrón actual superó el umbral de evidencia.",
"seasonalNote": "Desde la versión 1.42, HealthLog descuenta la estación del año y la tendencia a largo plazo antes de comparar valores. Verás menos relaciones, pero las que quedan son más sólidas. Muchos de los patrones anteriores eran solo el calendario.",
"seasonalNoteDismiss": "Cerrar aviso"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "Impossible d’ignorer ce schéma.",
"restoreError": "Impossible de restaurer ce schéma.",
"loadError": "Impossible de charger les schémas.",
"empty": "Aucun schéma actuel n’a franchi le seuil de preuve."
"empty": "Aucun schéma actuel n’a franchi le seuil de preuve.",
"seasonalNote": "Depuis la version 1.42, HealthLog retire la saison et la tendance de fond avant de comparer les valeurs. Vous verrez moins de liens, mais ceux qui restent sont plus solides. Beaucoup des schémas d’avant n’étaient que le calendrier.",
"seasonalNoteDismiss": "Fermer la remarque"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "Impossibile ignorare questo schema.",
"restoreError": "Impossibile ripristinare questo schema.",
"loadError": "Impossibile caricare gli schemi.",
"empty": "Nessuno schema attuale ha superato la soglia di evidenza."
"empty": "Nessuno schema attuale ha superato la soglia di evidenza.",
"seasonalNote": "Dalla versione 1.42 HealthLog toglie stagione e tendenza di lungo periodo prima di confrontare i valori. Vedrai meno relazioni, ma quelle rimaste reggono meglio. Molti degli schemi di prima erano solo il calendario.",
"seasonalNoteDismiss": "Chiudi l’avviso"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "이 패턴을 숨기지 못했어요.",
"restoreError": "이 패턴을 다시 표시하지 못했어요.",
"loadError": "패턴을 불러오지 못했어요.",
"empty": "근거 기준선을 넘은 패턴이 아직 없어요."
"empty": "근거 기준선을 넘은 패턴이 아직 없어요.",
"seasonalNote": "버전 1.42부터 HealthLog는 값을 비교하기 전에 계절과 장기 추세를 먼저 걸러 냅니다. 보이는 관계는 줄어들지만, 남은 관계는 더 믿을 만합니다. 예전에 보이던 패턴 중 상당수는 그저 달력이었습니다.",
"seasonalNoteDismiss": "안내 닫기"
},
"correlation": {
"direction": {
+3 -1
View File
@@ -2554,7 +2554,9 @@
"dismissError": "Nie udało się odrzucić tego wzorca.",
"restoreError": "Nie udało się przywrócić tego wzorca.",
"loadError": "Nie udało się wczytać wzorców.",
"empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów."
"empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów.",
"seasonalNote": "Od wersji 1.42 HealthLog usuwa porę roku i długoterminowy trend, zanim porówna wartości. Zobaczysz mniej zależności, ale te, które zostały, są pewniejsze. Wiele wcześniejszych wzorców wynikało po prostu z kalendarza.",
"seasonalNoteDismiss": "Zamknij uwagę"
},
"correlation": {
"direction": {
@@ -70,6 +70,7 @@ vi.mock("@/lib/insights/correlation-patterns", () => ({
},
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
decisionForEvidence: vi.fn().mockReturnValue(null),
hadFindingsBeforeSeasonalAdjustment: vi.fn().mockResolvedValue(false),
}));
vi.mock("next/headers", () => ({
@@ -88,7 +89,10 @@ import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns";
import {
hadFindingsBeforeSeasonalAdjustment,
syncAcceptedPatterns,
} from "@/lib/insights/correlation-patterns";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -155,6 +159,7 @@ beforeEach(() => {
).mockResolvedValue([]);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -181,6 +186,18 @@ describe("GET /api/insights/correlations", () => {
expect(body.data.fdrQ).toBeGreaterThan(0);
});
it("reports whether the record had findings before the seasonal adjustment", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(true);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { findingsBeforeSeasonalAdjustment: boolean };
};
expect(body.data.findingsBeforeSeasonalAdjustment).toBe(true);
expect(hadFindingsBeforeSeasonalAdjustment).toHaveBeenCalledWith("user-1");
});
it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => {
// The SWR cell caches the built body for 60 s; the second GET inside that
// window must neither re-read the measurement window nor re-run the two
+19 -11
View File
@@ -36,6 +36,7 @@ import {
} from "@/lib/insights/correlation-discovery";
import {
decisionForEvidence,
hadFindingsBeforeSeasonalAdjustment,
PATTERN_FAMILIES,
syncAcceptedPatterns,
} from "@/lib/insights/correlation-patterns";
@@ -131,17 +132,23 @@ async function buildCorrelationsResponse(
//
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
// a different pass over a different grain, so they are not matrix channels.
const [matrix, labDraws] = await Promise.all([
assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "tiered",
modules,
}),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
]);
//
// The earlier-findings probe is one indexed lookup on the pattern store; it
// tells the client whether this record had findings under the engine before
// the seasonal adjustment, which is what earns the one-time explanation.
const [matrix, labDraws, findingsBeforeSeasonalAdjustment] =
await Promise.all([
assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "tiered",
modules,
}),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
hadFindingsBeforeSeasonalAdjustment(userId),
]);
const { series, diagnostics } = matrix;
const result = discoverCorrelations(series, { locale });
@@ -246,5 +253,6 @@ async function buildCorrelationsResponse(
discovered,
emerging: emergingWithDecisions,
labCorrelations,
findingsBeforeSeasonalAdjustment,
};
}
@@ -0,0 +1,132 @@
/**
* The one-time note on the correlation surface after the v1.42 engine change.
*
* Three properties: it shows for a record that had findings under the earlier
* engine, it never shows for an account without them (every account created
* since, and a response from an older server that lacks the flag), and once
* closed it stays closed on the next mount.
*
* The project runs SSR-only component tests, so nothing here clicks. The
* close path is driven by calling the component as a plain function and
* invoking the dismiss button's own `onClick` from the returned tree, then
* mounting again against the same storage. `useState` is replaced by a
* stateless stand-in for that; persistence across mounts is the property, and
* it lives in storage, not in component state.
*/
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useState: <T,>(init: T | (() => T)) => [
typeof init === "function" ? (init as () => T)() : init,
() => undefined,
],
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "viewer-1" }, isAuthenticated: true }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
import {
CorrelationSeasonalNote,
correlationSeasonalNoteStorageKey,
} from "../correlation-seasonal-note";
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(flag: boolean | undefined): string {
return renderToStaticMarkup(
<CorrelationSeasonalNote findingsBeforeSeasonalAdjustment={flag} />,
);
}
function findBySlot(node: ReactNode, slot: string): ReactElement | null {
if (Array.isArray(node)) {
for (const child of node) {
const hit = findBySlot(child, slot);
if (hit) return hit;
}
return null;
}
if (!isValidElement(node)) return null;
const props = node.props as { "data-slot"?: string; children?: ReactNode };
if (props["data-slot"] === slot) return node;
return findBySlot(props.children, slot);
}
describe("CorrelationSeasonalNote", () => {
it("shows for a record that had findings before the seasonal adjustment", () => {
const html = render(true);
expect(html).toContain('data-slot="correlation-seasonal-note"');
expect(html).toContain("insights.pattern.seasonalNote");
expect(html).toContain('aria-label="insights.pattern.seasonalNoteDismiss"');
});
it("stays away for an account without earlier findings", () => {
expect(render(false)).toBe("");
});
it("stays away when the server does not send the flag", () => {
expect(render(undefined)).toBe("");
});
it("does not come back once closed", () => {
const tree = CorrelationSeasonalNote({
findingsBeforeSeasonalAdjustment: true,
});
const button = findBySlot(tree, "correlation-seasonal-note-dismiss");
expect(button).not.toBeNull();
(button!.props as { onClick: () => void }).onClick();
expect(store.get(correlationSeasonalNoteStorageKey("viewer-1"))).toBe("1");
expect(render(true)).toBe("");
});
it("is remembered per viewer, not per browser", () => {
store.set(correlationSeasonalNoteStorageKey("someone-else"), "1");
expect(render(true)).toContain('data-slot="correlation-seasonal-note"');
});
it("still shows and still closes when storage is unavailable", () => {
vi.stubGlobal("window", {
localStorage: {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
},
});
const tree = CorrelationSeasonalNote({
findingsBeforeSeasonalAdjustment: true,
});
const button = findBySlot(tree, "correlation-seasonal-note-dismiss");
expect(button).not.toBeNull();
expect(() =>
(button!.props as { onClick: () => void }).onClick(),
).not.toThrow();
});
});
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Info, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
/**
* One-time note on the correlation surface after the v1.42 engine change.
*
* Since v1.42 every daily pair is correlated on seasonally adjusted residuals
* with an effective-sample-size p-value, so an account with history usually
* sees far fewer relationships than before. Without a word about it that
* reads as a defect. The note explains it once.
*
* Who sees it is decided on the server: `findingsBeforeSeasonalAdjustment`
* on the correlations response is true only when the record kept a discovery
* pattern from before the change, so an account created since never sees it.
* Dismissing follows the app's one-off hint convention (`AiSetupHint`): it is
* remembered per viewer in this browser, and a storage failure hides the note
* for the current visit only.
*/
const STORAGE_PREFIX = "healthlog.correlation-seasonal-note.dismissed";
export function correlationSeasonalNoteStorageKey(viewerId: string): string {
return `${STORAGE_PREFIX}.${viewerId}`;
}
function readDismissed(viewerId: string): boolean {
try {
return (
window.localStorage.getItem(
correlationSeasonalNoteStorageKey(viewerId),
) === "1"
);
} catch {
return false;
}
}
export function CorrelationSeasonalNote({
findingsBeforeSeasonalAdjustment,
}: {
/** From the correlations response; absent on an older server. */
findingsBeforeSeasonalAdjustment: boolean | undefined;
}) {
const { t } = useTranslations();
const { user } = useAuth();
// Storage is browser-only: nothing renders before hydration, so the server
// HTML and the first client render agree.
const mounted = useMounted();
const [dismissedNow, setDismissedNow] = useState(false);
const viewerId = user?.id ?? null;
if (!mounted || viewerId === null) return null;
if (findingsBeforeSeasonalAdjustment !== true) return null;
if (dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
setDismissedNow(true);
try {
window.localStorage.setItem(
correlationSeasonalNoteStorageKey(viewerId),
"1",
);
} catch {
// Storage unavailable (private mode): hidden for this visit only.
}
};
return (
<div
data-slot="correlation-seasonal-note"
role="note"
className="flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p className="text-foreground text-sm">
{t("insights.pattern.seasonalNote")}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={dismiss}
aria-label={t("insights.pattern.seasonalNoteDismiss")}
title={t("insights.pattern.seasonalNoteDismiss")}
data-slot="correlation-seasonal-note-dismiss"
// 44 px on a phone like every other mobile action; the negative
// margin keeps the row as tall as the text needs.
className="-my-2 size-11 shrink-0 sm:size-9"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
);
}
@@ -50,6 +50,8 @@ export interface CorrelationDiscoveryResponse {
pairsTested: number;
fdrQ: number;
minPairs: number;
/** v1.42 — the record had findings before the seasonal adjustment. */
findingsBeforeSeasonalAdjustment?: boolean;
}
/** Map a discovery channel key to its localized measurement-type label. */
@@ -7,6 +7,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { CorrelationSeasonalNote } from "@/components/insights/correlation-seasonal-note";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -85,6 +86,13 @@ export function MoodWhatStandsOut({
onRetry={() => void refetch()}
/>
) : null}
{data ? (
<CorrelationSeasonalNote
findingsBeforeSeasonalAdjustment={
data.findingsBeforeSeasonalAdjustment
}
/>
) : null}
{!isLoading && !isError && data && !hasDiscovered ? (
<p
data-slot="mood-discovery-empty"
+34
View File
@@ -250,3 +250,37 @@ export function decisionForEvidence(
) ?? null
);
}
/**
* 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.
*/
export const SEASONAL_ADJUSTMENT_SINCE = new Date("2026-10-08T00:00:00.000Z");
/**
* 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.
*/
export async function hadFindingsBeforeSeasonalAdjustment(
userId: string,
): Promise<boolean> {
const row = await prisma.correlationPattern.findFirst({
where: {
userId,
family: {
in: [
PATTERN_FAMILIES.discoveryRetrospective,
PATTERN_FAMILIES.discoveryRecent,
],
},
createdAt: { lt: SEASONAL_ADJUSTMENT_SINCE },
},
select: { id: true },
});
return row !== null;
}
@@ -691,6 +691,11 @@ export const correlationDiscoveryResponse = z
.describe(
"v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled; absent-degrading on sparse draws.",
),
findingsBeforeSeasonalAdjustment: z
.boolean()
.describe(
"v1.42 — true when the record held a discovery finding before the engine began removing trend and season from both series. Drives a one-time explanation of why fewer pairs surface; false for every account created since.",
),
})
.meta({
id: "CorrelationDiscoveryResponse",
@@ -0,0 +1,98 @@
/**
* `hadFindingsBeforeSeasonalAdjustment` against a real pattern store.
*
* 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.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
hadFindingsBeforeSeasonalAdjustment,
PATTERN_FAMILIES,
SEASONAL_ADJUSTMENT_SINCE,
} from "@/lib/insights/correlation-patterns";
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);
let seq = 0;
async function seedUser() {
seq += 1;
return getPrismaClient().user.create({
data: {
username: `corr-before-adjust-${seq}`,
email: `corr-before-adjust-${seq}@example.test`,
role: "USER",
},
});
}
async function seedPattern(userId: string, family: string, createdAt: Date) {
seq += 1;
await getPrismaClient().correlationPattern.create({
data: {
userId,
canonicalKey: `p1:test-${seq}`,
family,
factorKey: "ACTIVITY_STEPS",
outcomeKey: "MOOD",
lagDays: 1,
sampleSize: 60,
effectSize: 0.4,
pValue: 0.001,
qValue: 0.01,
evidenceHash: `hash-${seq}`,
lastComputedAt: createdAt,
createdAt,
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("hadFindingsBeforeSeasonalAdjustment", () => {
it("holds for a record with a discovery pattern stored before the change", async () => {
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 () => {
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 () => {
const user = await seedUser();
await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER);
expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false);
});
it("does not hold for a non-discovery family or another account's pattern", async () => {
const user = await seedUser();
const other = await seedUser();
await seedPattern(user.id, PATTERN_FAMILIES.moodTagCrosstab, BEFORE);
await seedPattern(
other.id,
PATTERN_FAMILIES.discoveryRetrospective,
BEFORE,
);
expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false);
});
it("does not hold for an account with no patterns", async () => {
const user = await seedUser();
expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false);
});
});