diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7a44b2306..d17709fd3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index a941d5562..85f28b659 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 2e9223eb7..ef3b5feba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 0db6ab52c..b920dd20c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index a96476ca4..37ab610cd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 94ce5ce2c..a763be6bf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index f94f054a4..42938c942 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2554,7 +2554,9 @@ "dismissError": "이 패턴을 숨기지 못했어요.", "restoreError": "이 패턴을 다시 표시하지 못했어요.", "loadError": "패턴을 불러오지 못했어요.", - "empty": "근거 기준선을 넘은 패턴이 아직 없어요." + "empty": "근거 기준선을 넘은 패턴이 아직 없어요.", + "seasonalNote": "버전 1.42부터 HealthLog는 값을 비교하기 전에 계절과 장기 추세를 먼저 걸러 냅니다. 보이는 관계는 줄어들지만, 남은 관계는 더 믿을 만합니다. 예전에 보이던 패턴 중 상당수는 그저 달력이었습니다.", + "seasonalNoteDismiss": "안내 닫기" }, "correlation": { "direction": { diff --git a/messages/pl.json b/messages/pl.json index 46fb88d4f..56627bedc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 42413f24f..ee6729d96 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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; @@ -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 diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index c94021409..ef9594848 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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, }; } diff --git a/src/components/insights/__tests__/correlation-seasonal-note.test.tsx b/src/components/insights/__tests__/correlation-seasonal-note.test.tsx new file mode 100644 index 000000000..ed6e68c39 --- /dev/null +++ b/src/components/insights/__tests__/correlation-seasonal-note.test.tsx @@ -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(); + return { + ...actual, + useState: (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; + +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( + , + ); +} + +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(); + }); +}); diff --git a/src/components/insights/correlation-seasonal-note.tsx b/src/components/insights/correlation-seasonal-note.tsx new file mode 100644 index 000000000..8987b0524 --- /dev/null +++ b/src/components/insights/correlation-seasonal-note.tsx @@ -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 ( +
+
+
+ +
+ ); +} diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 4a9adf235..7805ae35c 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -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. */ diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx index 65b12eae4..cc56a9765 100644 --- a/src/components/insights/mood/mood-what-stands-out.tsx +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -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 ? ( + + ) : null} {!isLoading && !isError && data && !hasDiscovered ? (

{ + 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; +} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index b35e7febb..4b2bd9faf 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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", diff --git a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts new file mode 100644 index 000000000..7534347f2 --- /dev/null +++ b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts @@ -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); + }); +});