From 666a85d311cd5575637c8d9d270c8e43ae02e46b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 14:01:11 +0200 Subject: [PATCH] feat(insights): persist correlation patterns --- .../migration.sql | 42 ++++ .../migration.sql | 39 ++++ prisma/schema.prisma | 40 +++- .../[id]/restore/__tests__/round-trip.test.ts | 47 ++++ .../api/admin/backups/[id]/restore/route.ts | 3 + src/app/api/analytics/__tests__/route.test.ts | 6 + src/app/api/analytics/route.ts | 58 ++++- .../[id]/__tests__/route.test.ts | 21 ++ .../[id]/entries/[entryId]/route.ts | 3 + .../api/custom-metrics/[id]/entries/route.ts | 2 + src/app/api/custom-metrics/[id]/route.ts | 6 + .../custom-metrics/__tests__/route.test.ts | 10 +- src/app/api/custom-metrics/route.ts | 15 +- .../correlations/__tests__/route.test.ts | 10 + src/app/api/insights/correlations/route.ts | 87 +++++++- .../patterns/[id]/__tests__/route.test.ts | 105 +++++++++ src/app/api/insights/patterns/[id]/route.ts | 92 ++++++++ src/app/api/insights/patterns/route.ts | 42 ++++ .../__tests__/custom-metric-detail.test.tsx | 1 + .../custom-metrics/custom-metric-form.tsx | 20 ++ src/components/custom-metrics/types.ts | 1 + src/components/insights/correlation-card.tsx | 22 +- .../mood/mood-discovered-relations.tsx | 63 +++++- .../mood/mood-factor-metric-crosstab.tsx | 55 +++++ .../mood/mood-tag-metric-crosstab.tsx | 55 +++++ .../insights/mood/mood-what-stands-out.tsx | 30 ++- .../insights/pattern-dismiss-action.tsx | 113 ++++++++++ src/lib/cache/invalidate.ts | 7 + src/lib/custom-metrics/custom-metric-store.ts | 2 + .../__tests__/full-backup-payload.test.ts | 40 ++++ src/lib/export/backup-plan.ts | 2 + src/lib/export/full-backup-payload.ts | 7 +- src/lib/export/profile-backup.ts | 130 +++++++++-- .../correlation-channel-series.test.ts | 74 +++++++ .../__tests__/correlation-patterns.test.ts | 203 +++++++++++++++++ .../metric-correlation-context.test.ts | 20 ++ .../__tests__/mood-aggregates.test.ts | 33 ++- .../insights/__tests__/status-cache.test.ts | 40 ++++ .../insights/correlation-channel-series.ts | 50 +++++ src/lib/insights/correlation-discovery.ts | 31 ++- src/lib/insights/correlation-patterns.ts | 208 ++++++++++++++++++ src/lib/insights/correlations.ts | 3 + .../insights/metric-correlation-context.ts | 62 +++++- src/lib/insights/mood-aggregates.ts | 52 ++++- src/lib/insights/mood-crosstab.ts | 6 + src/lib/insights/status-cache.ts | 33 ++- src/lib/openapi/routes/custom-metrics.ts | 11 +- src/lib/openapi/routes/insights/paths.ts | 58 +++++ src/lib/openapi/routes/insights/schemas.ts | 56 +++++ src/lib/validations/backup.ts | 33 +++ src/lib/validations/correlation-patterns.ts | 9 + src/lib/validations/custom-metrics.ts | 2 + tests/integration/mood-tag-groups.test.ts | 24 ++ 53 files changed, 2129 insertions(+), 55 deletions(-) create mode 100644 prisma/migrations/0285_correlation_pattern_identity/migration.sql create mode 100644 prisma/migrations/0286_caffeine_nicotine_mood_tags/migration.sql create mode 100644 src/app/api/insights/patterns/[id]/__tests__/route.test.ts create mode 100644 src/app/api/insights/patterns/[id]/route.ts create mode 100644 src/app/api/insights/patterns/route.ts create mode 100644 src/components/insights/pattern-dismiss-action.tsx create mode 100644 src/lib/insights/__tests__/correlation-patterns.test.ts create mode 100644 src/lib/insights/correlation-patterns.ts create mode 100644 src/lib/validations/correlation-patterns.ts diff --git a/prisma/migrations/0285_correlation_pattern_identity/migration.sql b/prisma/migrations/0285_correlation_pattern_identity/migration.sql new file mode 100644 index 000000000..45a9d0591 --- /dev/null +++ b/prisma/migrations/0285_correlation_pattern_identity/migration.sql @@ -0,0 +1,42 @@ +-- Custom metrics remain isolated unless the owner explicitly opts into +-- correlation discovery. Existing definitions retain the isolated default. +ALTER TABLE "custom_metrics" + ADD COLUMN "correlation_enabled" BOOLEAN NOT NULL DEFAULT false; + +-- Stable, user-scoped identity plus the latest statistically accepted evidence. +-- Dismissal baselines are retained independently of subsequent recomputation so +-- immaterial movement remains suppressed while a material change can resurface. +CREATE TABLE "correlation_patterns" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "canonical_key" TEXT NOT NULL, + "family" TEXT NOT NULL, + "factor_key" TEXT NOT NULL, + "outcome_key" TEXT NOT NULL, + "lag_days" INTEGER NOT NULL, + "sample_size" INTEGER NOT NULL, + "effect_size" DOUBLE PRECISION NOT NULL, + "p_value" DOUBLE PRECISION NOT NULL, + "q_value" DOUBLE PRECISION, + "evidence_hash" TEXT NOT NULL, + "is_current" BOOLEAN NOT NULL DEFAULT true, + "last_computed_at" TIMESTAMP(3) NOT NULL, + "dismissed_at" TIMESTAMP(3), + "dismissed_evidence_hash" TEXT, + "dismissed_effect_size" DOUBLE PRECISION, + "dismissed_sample_size" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "correlation_patterns_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "correlation_patterns_user_id_canonical_key_key" + ON "correlation_patterns"("user_id", "canonical_key"); +CREATE INDEX "correlation_patterns_user_id_family_is_current_idx" + ON "correlation_patterns"("user_id", "family", "is_current"); + +ALTER TABLE "correlation_patterns" + ADD CONSTRAINT "correlation_patterns_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0286_caffeine_nicotine_mood_tags/migration.sql b/prisma/migrations/0286_caffeine_nicotine_mood_tags/migration.sql new file mode 100644 index 000000000..701b6c768 --- /dev/null +++ b/prisma/migrations/0286_caffeine_nicotine_mood_tags/migration.sql @@ -0,0 +1,39 @@ +-- Data-only, idempotent extension of the built-in binary mood-tag catalogue. +-- Mirrors migration 0101's deterministic-id and key-conflict taxonomy pattern. +INSERT INTO "mood_tags" ( + "id", "category_id", "key", "label_key", "icon", "sort_order", + "is_active", "kind", "scale_min", "scale_max", "inverse", "user_id", + "label_encrypted" +) +VALUES ( + 'mt_caffeine', 'mtc_health', 'caffeine', 'mood.tag.caffeine', 'Coffee', 5, + true, 'BINARY', 1, 5, false, NULL, NULL +) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", + "label_key" = EXCLUDED."label_key", + "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", + "kind" = EXCLUDED."kind", + "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", + "inverse" = EXCLUDED."inverse"; + +INSERT INTO "mood_tags" ( + "id", "category_id", "key", "label_key", "icon", "sort_order", + "is_active", "kind", "scale_min", "scale_max", "inverse", "user_id", + "label_encrypted" +) +VALUES ( + 'mt_nicotine', 'mtc_health', 'nicotine', 'mood.tag.nicotine', 'Cigarette', 6, + true, 'BINARY', 1, 5, false, NULL, NULL +) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", + "label_key" = EXCLUDED."label_key", + "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", + "kind" = EXCLUDED."kind", + "scale_min" = EXCLUDED."scale_min", + "scale_max" = EXCLUDED."scale_max", + "inverse" = EXCLUDED."inverse"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abdf1e1fa..2d81888cb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -841,10 +841,11 @@ model User { // marker); LabResult rows link to a Biomarker for the resolved range/unit. biomarkers Biomarker[] // v1.25.5 — user-defined custom metrics. A separate generic store - // (catalog + values), fully isolated from the closed MeasurementType - // system: not synced, not in FHIR, not in AI insights — log + chart only. + // (catalog + values), not synced and not in FHIR. Correlation discovery + // reads a metric only after the account explicitly opts it in. customMetrics CustomMetric[] customMetricEntries CustomMetricEntry[] + correlationPatterns CorrelationPattern[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. @@ -4961,6 +4962,9 @@ model CustomMetric { decimals Int? /// Optional free-text description ("what this measures"). Plaintext. description String? + /// Explicit opt-in to expose this metric as a bounded behaviour channel in + /// correlation discovery. False keeps the metric isolated. + correlationEnabled Boolean @default(false) @map("correlation_enabled") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. @@ -4995,6 +4999,38 @@ model CustomMetricEntry { @@map("custom_metric_entries") } +/// Persisted identity and latest accepted evidence for a user-scoped +/// correlation finding. `canonicalKey` is derived from factor + outcome + lag; +/// the named fields remain queryable and make the identity portable. +model CorrelationPattern { + id String @id @default(cuid()) + userId String @map("user_id") + canonicalKey String @map("canonical_key") + family String + factorKey String @map("factor_key") + outcomeKey String @map("outcome_key") + lagDays Int @map("lag_days") + sampleSize Int @map("sample_size") + effectSize Float @map("effect_size") + pValue Float @map("p_value") + qValue Float? @map("q_value") + evidenceHash String @map("evidence_hash") + isCurrent Boolean @default(true) @map("is_current") + lastComputedAt DateTime @map("last_computed_at") + dismissedAt DateTime? @map("dismissed_at") + dismissedEvidenceHash String? @map("dismissed_evidence_hash") + dismissedEffectSize Float? @map("dismissed_effect_size") + dismissedSampleSize Int? @map("dismissed_sample_size") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, canonicalKey]) + @@index([userId, family, isCurrent]) + @@map("correlation_patterns") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement { diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 0c09b5df7..c5d9fdb4f 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -212,6 +212,7 @@ function sourceClient() { targetHigh: null, decimals: 1, description: "Right hand, before breakfast", + correlationEnabled: true, createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-19T00:00:00.000Z"), deletedAt: null, @@ -228,6 +229,32 @@ function sourceClient() { }, ]), }, + correlationPattern: { + findMany: vi.fn().mockResolvedValue([ + { + id: "pattern-1", + userId: OWNER, + canonicalKey: `p1:${"a".repeat(64)}`, + family: "DISCOVERY_RETROSPECTIVE", + factorKey: "CUSTOM_METRIC:metric-1", + outcomeKey: "RESTING_HEART_RATE", + lagDays: 1, + sampleSize: 42, + effectSize: 0.34, + pValue: 0.01, + qValue: 0.04, + evidenceHash: "b".repeat(64), + isCurrent: true, + lastComputedAt: new Date("2026-07-19T08:00:00.000Z"), + dismissedAt: new Date("2026-07-19T09:00:00.000Z"), + dismissedEvidenceHash: "b".repeat(64), + dismissedEffectSize: 0.34, + dismissedSampleSize: 42, + createdAt: new Date("2026-07-19T08:00:00.000Z"), + updatedAt: new Date("2026-07-19T09:00:00.000Z"), + }, + ]), + }, }; } @@ -362,6 +389,7 @@ describe("backup round trip — export, wire schema, restore", () => { targetLow: 40, decimals: 1, description: "Right hand, before breakfast", + correlationEnabled: true, }); // The readings are the half that would go missing quietly: a metric with @@ -377,6 +405,25 @@ describe("backup round trip — export, wire schema, restore", () => { }); }); + it("writes persisted pattern identity and dismissal evidence back", async () => { + const { written } = await roundTrip(); + const pattern = written.find((w) => w.model === "correlationPattern"); + expect(pattern).toBeDefined(); + expect(pattern!.data).toMatchObject({ + userId: OWNER, + canonicalKey: `p1:${"a".repeat(64)}`, + family: "DISCOVERY_RETROSPECTIVE", + factorKey: "CUSTOM_METRIC:metric-1", + outcomeKey: "RESTING_HEART_RATE", + dismissedEvidenceHash: "b".repeat(64), + dismissedEffectSize: 0.34, + dismissedSampleSize: 42, + }); + expect(pattern!.data.dismissedAt).toEqual( + new Date("2026-07-19T09:00:00.000Z"), + ); + }); + it("writes the day's cumulative shape back, every hour of it", async () => { const { written } = await roundTrip(); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 0093aeca8..0bff07309 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -68,6 +68,7 @@ interface RestoreResponse { documents: number; healthProfile: number; customMetrics: number; + correlationPatterns: number; intradayProfiles: number; }; } @@ -1121,6 +1122,7 @@ const handler = apiHandler( documents: documents.count, healthProfile: profileCleared.healthProfile, customMetrics: profileCleared.customMetrics, + correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, }; }, @@ -1249,6 +1251,7 @@ const handler = apiHandler( healthProfile: summary.healthProfile, customMetrics: summary.customMetrics, customMetricEntries: summary.customMetricEntries, + correlationPatterns: summary.correlationPatterns, }, }, }); diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index ffc4fa0fb..37a72a67a 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -69,6 +69,12 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/insights/correlation-patterns", () => ({ + PATTERN_FAMILIES: { fixed: "FIXED" }, + syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()), + decisionForEvidence: vi.fn().mockReturnValue(null), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index c06e8cf65..001908f92 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -33,6 +33,11 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; +import { + decisionForEvidence, + PATTERN_FAMILIES, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; export const dynamic = "force-dynamic"; @@ -419,6 +424,57 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { coverage, locale, }); + const fixedDefinitions = [ + { + key: "bpCompliance" as const, + factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE", + outcomeKey: "BLOOD_PRESSURE_SYS", + }, + { + key: "moodPulse" as const, + factorKey: "HYPOTHESIS:MOOD", + outcomeKey: "RESTING_PULSE", + }, + { + key: "weightWeekday" as const, + factorKey: "HYPOTHESIS:WEEKDAY", + outcomeKey: "WEIGHT", + }, + ]; + const acceptedFixed = fixedDefinitions.flatMap((definition) => { + const result = correlations[definition.key]; + return result.status === "ok" + ? [ + { + factorKey: definition.factorKey, + outcomeKey: definition.outcomeKey, + lagDays: 0, + sampleSize: result.n, + effectSize: result.statistic, + pValue: result.pValue, + qValue: null, + }, + ] + : []; + }); + const fixedDecisions = await syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.fixed, + accepted: acceptedFixed, + }); + const correlationsWithPatterns = { ...correlations }; + for (const definition of fixedDefinitions) { + const result = correlations[definition.key]; + if (result.status !== "ok") continue; + const decision = decisionForEvidence(fixedDecisions, { + factorKey: definition.factorKey, + outcomeKey: definition.outcomeKey, + lagDays: 0, + }); + correlationsWithPatterns[definition.key] = decision + ? { ...result, ...decision } + : result; + } // v1.4.20 phase B5 — Personal Health Score. Server-deterministic // composite of BP-in-target % + weight-trend alignment + mood @@ -509,7 +565,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { bpInTargetSpanDays90, glucoseByContext: glucoseByContextOut, glucoseClinical: glucoseClinicalOut, - correlations, + correlations: correlationsWithPatterns, healthScore, sleepStages, // v1.28.x — the latest night's source-discrepancy annotation, computed diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 26ac5a335..17c2aae11 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -47,6 +47,7 @@ const EXISTING = { targetHigh: 60, decimals: 1, description: null, + correlationEnabled: false, createdAt: new Date("2026-06-01T00:00:00.000Z"), updatedAt: new Date("2026-06-01T00:00:00.000Z"), deletedAt: null, @@ -127,6 +128,26 @@ describe("PATCH /api/custom-metrics/[id]", () => { expect(updateCall.data.targetHigh).toBe(50); }); + it("persists only the explicit correlation opt-in field", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( + EXISTING as never, + ); + vi.mocked(prisma.customMetric.update).mockResolvedValue({ + ...EXISTING, + correlationEnabled: true, + } as never); + + const res = await PATCH( + patchReq({ correlationEnabled: true }), + params("cm-1"), + ); + expect(res.status).toBe(200); + const updateCall = vi.mocked(prisma.customMetric.update).mock + .calls[0][0] as { data: Record }; + expect(updateCall.data).toEqual({ correlationEnabled: true }); + }); + it("422s an inverted partial bound update without updating", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // existing.targetHigh is 60; moving the low bound above it inverts. diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 4f12e9af2..84c60839d 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -91,6 +92,7 @@ export const PATCH = apiHandler( action: { name: "custom-metric.entry.update" }, meta: { customMetricId: id, entryId }, }); + invalidateUserCorrelationPatterns(user.id); return apiSuccess(serialiseCustomMetricEntry(updated)); }, @@ -120,6 +122,7 @@ export const DELETE = apiHandler( action: { name: "custom-metric.entry.delete" }, meta: { customMetricId: id, entryId }, }); + invalidateUserCorrelationPatterns(user.id); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index c91a5e695..5c96553b2 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -152,6 +153,7 @@ async function postCustomMetricEntry( action: { name: "custom-metric.entry.create" }, meta: { customMetricId: id, entryId: created.id }, }); + invalidateUserCorrelationPatterns(user.id); return apiSuccess(serialiseCustomMetricEntry(created), 201); } diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 4f3c709a7..b50b2726c 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -121,6 +122,9 @@ export const PATCH = apiHandler( if (d.targetHigh !== undefined) data.targetHigh = d.targetHigh; if (d.decimals !== undefined) data.decimals = d.decimals; if (d.description !== undefined) data.description = d.description; + if (d.correlationEnabled !== undefined) { + data.correlationEnabled = d.correlationEnabled; + } // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the @@ -143,6 +147,7 @@ export const PATCH = apiHandler( action: { name: "custom-metric.metric.update" }, meta: { customMetricId: id }, }); + invalidateUserCorrelationPatterns(user.id); return apiSuccess(serialiseCustomMetric(updated)); }, @@ -179,6 +184,7 @@ export const DELETE = apiHandler( action: { name: "custom-metric.metric.delete" }, meta: { customMetricId: id }, }); + invalidateUserCorrelationPatterns(user.id); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/custom-metrics/__tests__/route.test.ts b/src/app/api/custom-metrics/__tests__/route.test.ts index 7e885592e..57a10098b 100644 --- a/src/app/api/custom-metrics/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/__tests__/route.test.ts @@ -65,6 +65,7 @@ const ROW = { targetHigh: null, decimals: null, description: null, + correlationEnabled: false, createdAt: new Date("2026-06-01T00:00:00.000Z"), updatedAt: new Date("2026-06-01T00:00:00.000Z"), deletedAt: null, @@ -108,7 +109,13 @@ describe("POST /api/custom-metrics", () => { vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.customMetric.create).mockResolvedValue(ROW as never); - const res = await POST(postReq({ name: "Grip strength", unit: "kg" })); + const res = await POST( + postReq({ + name: "Grip strength", + unit: "kg", + correlationEnabled: true, + }), + ); expect(res.status).toBe(201); const createCall = vi.mocked(prisma.customMetric.create).mock .calls[0][0] as { @@ -117,6 +124,7 @@ describe("POST /api/custom-metrics", () => { expect(createCall.data.userId).toBe("user-1"); expect(createCall.data.name).toBe("Grip strength"); expect("userId" in createCall.data).toBe(true); + expect(createCall.data.correlationEnabled).toBe(true); }); it("409s a duplicate live name without creating", async () => { diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 2dc7e8a59..06b8e7d5e 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -94,8 +95,15 @@ async function postCustomMetric(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { name, unit, targetLow, targetHigh, decimals, description } = - parsed.data; + const { + name, + unit, + targetLow, + targetHigh, + decimals, + description, + correlationEnabled, + } = parsed.data; // The `@@unique([userId, name])` index is the structural backstop and spans // soft-deleted rows too. Resolve the name up front: a LIVE collision is a @@ -120,6 +128,7 @@ async function postCustomMetric(request: NextRequest) { targetHigh: targetHigh ?? null, decimals: decimals ?? null, description: description ?? null, + correlationEnabled, deletedAt: null, }, }) @@ -132,6 +141,7 @@ async function postCustomMetric(request: NextRequest) { targetHigh: targetHigh ?? null, decimals: decimals ?? null, description: description ?? null, + correlationEnabled, }, }); @@ -145,6 +155,7 @@ async function postCustomMetric(request: NextRequest) { action: { name: "custom-metric.metric.create" }, meta: { customMetricId: created.id, revived: existing !== null }, }); + invalidateUserCorrelationPatterns(user.id); // A freshly created metric has no values; a revived one may retain entries // logged before it was soft-deleted, so resolve the latest + count for it. diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 3f4b1ed4e..e999238e4 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ // channels in (read from EnvironmentContext); default empty so no env // channel produces a point. environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, + customMetric: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -55,6 +56,15 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/insights/correlation-patterns", () => ({ + PATTERN_FAMILIES: { + discoveryRetrospective: "DISCOVERY_RETROSPECTIVE", + discoveryRecent: "DISCOVERY_RECENT", + }, + syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()), + decisionForEvidence: vi.fn().mockReturnValue(null), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 763ea82c8..30f18ca3f 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -38,9 +38,15 @@ import { type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { + decisionForEvidence, + PATTERN_FAMILIES, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; import { buildMeasurementDailySeries, fetchComplianceSeries, + fetchCustomMetricBehaviourSeries, fetchEnvironmentSeries, fetchLabDraws, fetchMeasurementWindowSeries, @@ -126,16 +132,22 @@ export const GET = apiHandler(async () => { // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. - const [complianceSeries, symptomSeries, labDraws, environmentSeries] = - await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), - // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / - // daylight). Empty when the module is off / no home set, so the channels - // degrade to absent. The module gate is implicit: no rows ⇒ no channels. - fetchEnvironmentSeries(user.id, since), - ]); + const [ + complianceSeries, + symptomSeries, + labDraws, + environmentSeries, + customMetricSeries, + ] = await Promise.all([ + fetchComplianceSeries(user.id, tz, since), + fetchSymptomSeries(user.id, tz, since), + fetchLabDraws(user.id, tz, since), + // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / + // daylight). Empty when the module is off / no home set, so the channels + // degrade to absent. The module gate is implicit: no rows ⇒ no channels. + fetchEnvironmentSeries(user.id, since), + fetchCustomMetricBehaviourSeries(user.id, tz, since), + ]); const points = (key: string): DailySeriesPoint[] => key === "MOOD" @@ -170,6 +182,9 @@ export const GET = apiHandler(async () => { for (const envSeries of environmentSeries) { series.push(envSeries); } + for (const customSeries of customMetricSeries) { + series.push(customSeries); + } const result = discoverCorrelations(series, { locale }); @@ -191,6 +206,46 @@ export const GET = apiHandler(async () => { locale, }); + const toEvidence = (pattern: (typeof result.discovered)[number]) => ({ + factorKey: pattern.behaviour, + outcomeKey: pattern.outcome, + lagDays: pattern.lagDays, + sampleSize: pattern.n, + effectSize: pattern.r, + pValue: pattern.pValue, + qValue: pattern.qValue, + }); + const [retrospectiveDecisions, recentDecisions] = await Promise.all([ + syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.discoveryRetrospective, + accepted: result.discovered.map(toEvidence), + computedAt: now, + }), + syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.discoveryRecent, + accepted: emerging.emerging.map(toEvidence), + computedAt: now, + }), + ]); + const attachPatternDecision = ( + pattern: (typeof result.discovered)[number], + decisions: typeof retrospectiveDecisions, + ) => { + const decision = decisionForEvidence(decisions, toEvidence(pattern)); + return decision ? { ...pattern, ...decision } : pattern; + }; + const discovered = result.discovered.map((pattern) => + attachPatternDecision(pattern, retrospectiveDecisions), + ); + const emergingWithDecisions = { + ...emerging, + emerging: emerging.emerging.map((pattern) => + attachPatternDecision(pattern, recentDecisions), + ), + }; + annotate({ action: { name: "insights.correlations.discover" }, meta: { @@ -220,8 +275,18 @@ export const GET = apiHandler(async () => { (sum, s) => sum + s.points.length, 0, ), + custom_metric_channels: customMetricSeries.length, + custom_metric_days: customMetricSeries.reduce( + (sum, channel) => sum + channel.points.length, + 0, + ), }, }); - return apiSuccess({ ...result, emerging, labCorrelations }); + return apiSuccess({ + ...result, + discovered, + emerging: emergingWithDecisions, + labCorrelations, + }); }); diff --git a/src/app/api/insights/patterns/[id]/__tests__/route.test.ts b/src/app/api/insights/patterns/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..5a01a63b6 --- /dev/null +++ b/src/app/api/insights/patterns/[id]/__tests__/route.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + correlationPattern: { findFirst: vi.fn(), update: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "owner-1", username: "owner", role: "USER", locale: "en" }, +}; +const PATTERN = { + id: "pattern-1", + userId: "owner-1", + canonicalKey: `p1:${"a".repeat(64)}`, + evidenceHash: "b".repeat(64), + effectSize: 0.34, + sampleSize: 42, + dismissedAt: null, +}; +const params = { params: Promise.resolve({ id: PATTERN.id }) }; + +function request(body: unknown) { + return new NextRequest( + `http://localhost/api/insights/patterns/${PATTERN.id}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("PATCH /api/insights/patterns/[id]", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + }); + + it("stores the current evidence as the account-scoped dismissal baseline", async () => { + vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue( + PATTERN as never, + ); + vi.mocked(prisma.correlationPattern.update).mockResolvedValue({ + ...PATTERN, + dismissedAt: new Date("2026-07-28T10:00:00.000Z"), + } as never); + + const response = await PATCH(request({ dismissed: true }), params); + expect(response.status).toBe(200); + expect(prisma.correlationPattern.findFirst).toHaveBeenCalledWith({ + where: { id: PATTERN.id, userId: "owner-1", isCurrent: true }, + }); + expect(prisma.correlationPattern.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: PATTERN.id }, + data: expect.objectContaining({ + dismissedEvidenceHash: PATTERN.evidenceHash, + dismissedEffectSize: PATTERN.effectSize, + dismissedSampleSize: PATTERN.sampleSize, + }), + }), + ); + }); + + it("does not reveal a pattern outside the authenticated account", async () => { + vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue(null); + const response = await PATCH(request({ dismissed: true }), params); + expect(response.status).toBe(404); + expect(prisma.correlationPattern.update).not.toHaveBeenCalled(); + }); + + it("rejects fields other than the dismissal decision", async () => { + const response = await PATCH( + request({ dismissed: true, factorKey: "OTHER" }), + params, + ); + expect(response.status).toBe(422); + expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/patterns/[id]/route.ts b/src/app/api/insights/patterns/[id]/route.ts new file mode 100644 index 000000000..1e8d41570 --- /dev/null +++ b/src/app/api/insights/patterns/[id]/route.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns"; + +type RouteParams = { params: Promise<{ id: string }> }; +const patternIdSchema = z.string().min(1).max(128); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const parsedId = patternIdSchema.safeParse((await params).id); + if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422); + const id = parsedId.data; + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateCorrelationPatternSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const pattern = await prisma.correlationPattern.findFirst({ + where: { id, userId: user.id, isCurrent: true }, + }); + if (!pattern) return apiError("Correlation pattern not found", 404); + + const dismissedAt = parsed.data.dismissed ? new Date() : null; + const updated = await prisma.correlationPattern.update({ + where: { id: pattern.id }, + data: parsed.data.dismissed + ? { + dismissedAt, + dismissedEvidenceHash: pattern.evidenceHash, + dismissedEffectSize: pattern.effectSize, + dismissedSampleSize: pattern.sampleSize, + } + : { + dismissedAt: null, + dismissedEvidenceHash: null, + dismissedEffectSize: null, + dismissedSampleSize: null, + }, + select: { + id: true, + canonicalKey: true, + dismissedAt: true, + evidenceHash: true, + }, + }); + + await auditLog( + parsed.data.dismissed + ? "correlationPattern.dismiss" + : "correlationPattern.restore", + { + userId: user.id, + ipAddress: getClientIp(request), + details: { correlationPatternId: pattern.id }, + }, + ); + annotate({ + action: { + name: parsed.data.dismissed + ? "insights.pattern.dismiss" + : "insights.pattern.restore", + }, + meta: { correlationPatternId: pattern.id }, + }); + invalidateUserCorrelationPatterns(user.id); + + return apiSuccess({ + id: updated.id, + canonicalKey: updated.canonicalKey, + dismissed: updated.dismissedAt != null, + dismissedAt: updated.dismissedAt?.toISOString() ?? null, + evidenceHash: updated.evidenceHash, + }); + }, +); diff --git a/src/app/api/insights/patterns/route.ts b/src/app/api/insights/patterns/route.ts new file mode 100644 index 000000000..e85b45ca8 --- /dev/null +++ b/src/app/api/insights/patterns/route.ts @@ -0,0 +1,42 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const patterns = await prisma.correlationPattern.findMany({ + where: { userId: user.id, isCurrent: true }, + orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }], + select: { + id: true, + canonicalKey: true, + family: true, + factorKey: true, + outcomeKey: true, + lagDays: true, + sampleSize: true, + effectSize: true, + pValue: true, + qValue: true, + evidenceHash: true, + lastComputedAt: true, + dismissedAt: true, + }, + }); + + annotate({ + action: { name: "insights.pattern.list" }, + meta: { total: patterns.length }, + }); + + return apiSuccess({ + patterns: patterns.map((pattern) => ({ + ...pattern, + lastComputedAt: pattern.lastComputedAt.toISOString(), + dismissedAt: pattern.dismissedAt?.toISOString() ?? null, + })), + }); +}); diff --git a/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx b/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx index 3619d9fae..a10367ac5 100644 --- a/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx +++ b/src/components/custom-metrics/__tests__/custom-metric-detail.test.tsx @@ -44,6 +44,7 @@ const METRIC: CustomMetricDto = { targetHigh: 60, decimals: 1, description: "Right-hand grip measured first thing.", + correlationEnabled: false, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", }; diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index d490fd65c..9dade7a22 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; @@ -73,6 +74,9 @@ export function CustomMetricForm({ existing?.decimals != null ? String(existing.decimals) : "", ); const [description, setDescription] = useState(existing?.description ?? ""); + const [correlationEnabled, setCorrelationEnabled] = useState( + existing?.correlationEnabled ?? false, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -100,6 +104,7 @@ export function CustomMetricForm({ ...(highVal !== null ? { targetHigh: highVal } : {}), ...(decimalsVal !== null ? { decimals: decimalsVal } : {}), ...(description.trim() ? { description: description.trim() } : {}), + correlationEnabled, }; setSubmitting(true); @@ -227,6 +232,21 @@ export function CustomMetricForm({ rows={2} /> +
+
+ +

+ {t("customMetrics.form.correlationEnabledHint")} +

+
+ +

diff --git a/src/components/custom-metrics/types.ts b/src/components/custom-metrics/types.ts index 7893c97d3..2d970a9a7 100644 --- a/src/components/custom-metrics/types.ts +++ b/src/components/custom-metrics/types.ts @@ -14,6 +14,7 @@ export interface CustomMetricDto { targetHigh: number | null; decimals: number | null; description: string | null; + correlationEnabled: boolean; /** Present on the list + create reads; absent on the bare detail read. */ latest?: CustomMetricLatest | null; entryCount?: number; diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index f42e00b87..246050d1e 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { useEffect, useState } from "react"; import { Activity, Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,10 @@ import type { CorrelationResult } from "@/lib/insights/correlations"; import type { CoachScopeSource } from "@/lib/ai/coach/types"; import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; import { AskCoachAction } from "./ask-coach-action"; +import { + PatternDismissButton, + PatternDismissedNotice, +} from "./pattern-dismiss-action"; /** * v1.4.20 phase B3 — single Correlation card. @@ -95,6 +100,10 @@ const COACH_QUESTION_BY_KIND: Record = { export function CorrelationCard({ result }: CorrelationCardProps) { const { t } = useTranslations(); + const initialDismissed = + result.status === "ok" && result.dismissed === true && !!result.patternId; + const [dismissed, setDismissed] = useState(initialDismissed); + useEffect(() => setDismissed(initialDismissed), [initialDismissed]); const title = t(TITLE_KEY[result.kind]); const subtitle = t(SUBTITLE_KEY[result.kind]); const coachSources = COACH_SOURCES_BY_KIND[result.kind]; @@ -140,7 +149,12 @@ export function CorrelationCard({ result }: CorrelationCardProps) { - {result.status === "ok" ? ( + {dismissed && result.status === "ok" && result.patternId ? ( + setDismissed(false)} + /> + ) : result.status === "ok" ? ( <> + {result.patternId ? ( + setDismissed(true)} + /> + ) : null} ) : ( diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 7440d8c36..2a149599c 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -1,7 +1,12 @@ "use client"; +import { useEffect, useState } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { MoodExplainerIcon } from "./mood-explainer-icon"; +import { + PatternDismissButton, + PatternDismissedNotice, +} from "@/components/insights/pattern-dismiss-action"; /** * v1.11.5 (F3) — FDR-controlled discovered mood relations. @@ -33,6 +38,11 @@ export interface DiscoveredCorrelation { qValue: number; interpretation: string; lagDays: number; + behaviourLabel?: string; + outcomeLabel?: string; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } export interface CorrelationDiscoveryResponse { @@ -80,6 +90,23 @@ export function MoodDiscoveredRelations({ pairsTested: number; }) { const { t } = useTranslations(); + const [dismissedIds, setDismissedIds] = useState( + () => + new Set( + pairs + .filter((pair) => pair.dismissed && pair.patternId) + .map((pair) => pair.patternId as string), + ), + ); + useEffect(() => { + setDismissedIds( + new Set( + pairs + .filter((pair) => pair.dismissed && pair.patternId) + .map((pair) => pair.patternId as string), + ), + ); + }, [pairs]); if (pairs.length === 0) return null; @@ -106,7 +133,28 @@ export function MoodDiscoveredRelations({ {pairs.map((pair) => { const moodIsOutcome = pair.outcome === "MOOD"; const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome; - const factorLabel = t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const dynamicLabel = moodIsOutcome + ? pair.behaviourLabel + : pair.outcomeLabel; + const factorLabel = + dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + if (pair.patternId && dismissedIds.has(pair.patternId)) { + return ( +

  • + + setDismissedIds((current) => { + const next = new Set(current); + next.delete(pair.patternId as string); + return next; + }) + } + /> +
  • + ); + } const up = pair.r >= 0; const sentenceKey = moodIsOutcome ? up @@ -139,6 +187,19 @@ export function MoodDiscoveredRelations({ })} /> + {pair.patternId ? ( + + setDismissedIds((current) => { + const next = new Set(current); + next.add(pair.patternId as string); + return next; + }) + } + className="self-start" + /> + ) : null} ); })} diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index 8393d0bbf..30717596a 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -1,10 +1,15 @@ "use client"; +import { useEffect, useState } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; +import { + PatternDismissButton, + PatternDismissedNotice, +} from "@/components/insights/pattern-dismiss-action"; /** * v1.14.0 — RATED-factor × vital crosstab card. @@ -42,6 +47,9 @@ export interface MoodFactorMetricCrosstabRow { pValue: number; qValue: number; confidence: MoodInfluenceConfidence; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } const METRIC_LABEL_KEY: Record = { @@ -87,6 +95,23 @@ export function MoodFactorMetricCrosstab({ }) { const { t } = useTranslations(); const unitDisplay = useUnitDisplay(); + const [dismissedIds, setDismissedIds] = useState( + () => + new Set( + rows + .filter((row) => row.dismissed && row.patternId) + .map((row) => row.patternId as string), + ), + ); + useEffect(() => { + setDismissedIds( + new Set( + rows + .filter((row) => row.dismissed && row.patternId) + .map((row) => row.patternId as string), + ), + ); + }, [rows]); if (rows.length === 0) return null; return ( @@ -96,6 +121,23 @@ export function MoodFactorMetricCrosstab({

      {rows.map((row) => { + if (row.patternId && dismissedIds.has(row.patternId)) { + return ( +
    • + + setDismissedIds((current) => { + const next = new Set(current); + next.delete(row.patternId as string); + return next; + }) + } + /> +
    • + ); + } const Icon = moodTagIcon(row.icon); const factorLabel = t(row.labelKey); const metricLabel = t( @@ -155,6 +197,19 @@ export function MoodFactorMetricCrosstab({ > {t(CONFIDENCE_KEY[row.confidence])} + {row.patternId ? ( + + setDismissedIds((current) => { + const next = new Set(current); + next.add(row.patternId as string); + return next; + }) + } + className="shrink-0" + /> + ) : null}

      {t( diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index ef37dbf06..37792afaf 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -1,9 +1,14 @@ "use client"; +import { useEffect, useState } from "react"; import { useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; +import { + PatternDismissButton, + PatternDismissedNotice, +} from "@/components/insights/pattern-dismiss-action"; /** * v1.12.0 — tag × health-metric crosstab card. @@ -49,6 +54,9 @@ export interface MoodTagMetricCrosstabRow { pValue: number; qValue: number; confidence: MoodInfluenceConfidence; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } const METRIC_LABEL_KEY: Record = { @@ -94,6 +102,23 @@ export function MoodTagMetricCrosstab({ rows: MoodTagMetricCrosstabRow[]; }) { const { t } = useTranslations(); + const [dismissedIds, setDismissedIds] = useState( + () => + new Set( + rows + .filter((row) => row.dismissed && row.patternId) + .map((row) => row.patternId as string), + ), + ); + useEffect(() => { + setDismissedIds( + new Set( + rows + .filter((row) => row.dismissed && row.patternId) + .map((row) => row.patternId as string), + ), + ); + }, [rows]); if (rows.length === 0) return null; return ( @@ -103,6 +128,23 @@ export function MoodTagMetricCrosstab({

        {rows.map((row) => { + if (row.patternId && dismissedIds.has(row.patternId)) { + return ( +
      • + + setDismissedIds((current) => { + const next = new Set(current); + next.delete(row.patternId as string); + return next; + }) + } + /> +
      • + ); + } const Icon = moodTagIcon(row.icon); const tagLabel = row.label ?? t(row.labelKey); const metricLabel = t( @@ -147,6 +189,19 @@ export function MoodTagMetricCrosstab({ > {t(CONFIDENCE_KEY[row.confidence])} + {row.patternId ? ( + + setDismissedIds((current) => { + const next = new Set(current); + next.add(row.patternId as string); + return next; + }) + } + className="shrink-0" + /> + ) : null}

        {t( diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx index cd9adf3e3..53e319a5d 100644 --- a/src/components/insights/mood/mood-what-stands-out.tsx +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { TrendingUp } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; @@ -55,13 +56,11 @@ export function MoodWhatStandsOut({ retry: false, }); - const moodPairs = - isLoading || isError || !data ? [] : moodPairsOf(data.discovered); + const moodPairs = !data ? [] : moodPairsOf(data.discovered); const hasNarratives = narratives.length > 0; const hasDiscovered = moodPairs.length > 0; - // Both halves empty → render nothing rather than an empty card. - if (!hasNarratives && !hasDiscovered) return null; + if (!hasNarratives && !isLoading && !isError && !data) return null; return ( @@ -73,6 +72,29 @@ export function MoodWhatStandsOut({ {hasNarratives && } + {isLoading ? ( + + ) : null} + {isError ? ( +

        + {t("insights.pattern.loadError")} +

        + ) : null} + {!isLoading && !isError && data && !hasDiscovered ? ( +

        + {t("insights.pattern.empty")} +

        + ) : null} {hasDiscovered && ( void; + className?: string; +}) { + const { t } = useTranslations(); + const [pending, setPending] = useState(false); + + async function dismiss() { + setPending(true); + try { + await apiPatch( + `/api/insights/patterns/${patternId}`, + { dismissed: true }, + ); + onDismissed(); + } catch { + toast.error(t("insights.pattern.dismissError")); + } finally { + setPending(false); + } + } + + return ( + + ); +} + +export function PatternDismissedNotice({ + patternId, + onRestored, + compact = false, +}: { + patternId: string; + onRestored: () => void; + compact?: boolean; +}) { + const { t } = useTranslations(); + const [pending, setPending] = useState(false); + + async function restore() { + setPending(true); + try { + await apiPatch( + `/api/insights/patterns/${patternId}`, + { dismissed: false }, + ); + onRestored(); + } catch { + toast.error(t("insights.pattern.restoreError")); + } finally { + setPending(false); + } + } + + return ( +
        + + + +
        + ); +} diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 150c48b18..b154bf598 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -261,6 +261,13 @@ export function invalidateUserMedicationListLayout(userId: string): void { caches.medicationListLayout.deleteByPrefix(userId); } +/** Invalidate surfaces whose server decision includes pattern dismissal. */ +export function invalidateUserCorrelationPatterns(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.moodInsights.deleteByPrefix(userId); + caches.insightsDerived.deleteByPrefix(`${userId}|`); +} + /** * v1.16.9 — full per-user cache eviction for the wipe / restore paths. * A data wipe or a backup restore replaces (or removes) every row the diff --git a/src/lib/custom-metrics/custom-metric-store.ts b/src/lib/custom-metrics/custom-metric-store.ts index fc5a4e4c5..1b6e4f79a 100644 --- a/src/lib/custom-metrics/custom-metric-store.ts +++ b/src/lib/custom-metrics/custom-metric-store.ts @@ -19,6 +19,7 @@ export interface CustomMetricRow { targetHigh: number | null; decimals: number | null; description: string | null; + correlationEnabled: boolean; createdAt: Date; updatedAt: Date; } @@ -49,6 +50,7 @@ export function serialiseCustomMetric(row: CustomMetricRow) { targetHigh: row.targetHigh, decimals: row.decimals, description: row.description, + correlationEnabled: row.correlationEnabled, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 4d37b30c0..7ccc1506a 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -230,6 +230,7 @@ function makePrisma() { targetHigh: null, decimals: 1, description: "Right hand, before breakfast", + correlationEnabled: true, createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-19T00:00:00.000Z"), deletedAt: null, @@ -246,6 +247,32 @@ function makePrisma() { }, ]), }, + correlationPattern: { + findMany: vi.fn().mockResolvedValue([ + { + id: "pattern-1", + userId: "user-1", + canonicalKey: `p1:${"a".repeat(64)}`, + family: "DISCOVERY_RETROSPECTIVE", + factorKey: "CUSTOM_METRIC:metric-1", + outcomeKey: "RESTING_HEART_RATE", + lagDays: 1, + sampleSize: 42, + effectSize: 0.34, + pValue: 0.01, + qValue: 0.04, + evidenceHash: "b".repeat(64), + isCurrent: true, + lastComputedAt: new Date("2026-07-19T08:00:00.000Z"), + dismissedAt: new Date("2026-07-19T09:00:00.000Z"), + dismissedEvidenceHash: "b".repeat(64), + dismissedEffectSize: 0.34, + dismissedSampleSize: 42, + createdAt: new Date("2026-07-19T08:00:00.000Z"), + updatedAt: new Date("2026-07-19T09:00:00.000Z"), + }, + ]), + }, }; } @@ -551,6 +578,7 @@ describe("buildFullBackupPayload — section keys reach the payload", () => { // from the builders. "healthProfile", "customMetrics", + "correlationPatterns", "intradayProfiles", ]; @@ -662,6 +690,7 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { targetHigh: null, decimals: 1, description: "Right hand, before breakfast", + correlationEnabled: true, deletedAt: null, entries: [ expect.objectContaining({ @@ -676,6 +705,17 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { ]); expect(counts.customMetrics).toBe(1); expect(counts.customMetricEntries).toBe(1); + expect(payload.correlationPatterns).toEqual([ + expect.objectContaining({ + id: "pattern-1", + canonicalKey: `p1:${"a".repeat(64)}`, + factorKey: "CUSTOM_METRIC:metric-1", + outcomeKey: "RESTING_HEART_RATE", + dismissedAt: "2026-07-19T09:00:00.000Z", + dismissedEvidenceHash: "b".repeat(64), + }), + ]); + expect(counts.correlationPatterns).toBe(1); }); it("excludes deleted metrics from a portable export", async () => { diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 309c2946e..ad7430ba0 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -101,6 +101,7 @@ export const BACKED_UP_MODELS = [ // ── Custom metrics ──────────────────────────────────────────────────────── "CustomMetric", "CustomMetricEntry", + "CorrelationPattern", // ── Activity and nutrition ──────────────────────────────────────────────── "Workout", @@ -202,6 +203,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [ "CycleSymptomLink", "CustomMetric", "CustomMetricEntry", + "CorrelationPattern", "Workout", "NutrientIntakeDay", "InboundDocument", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index ffa1d745c..4a8d970ea 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -213,10 +213,9 @@ export async function buildFullBackupPayload( buildRecordsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), - // Durable self-context + user-defined metrics. Both were classified as - // carried when the backup plan was written and carried by nothing; the - // metrics are the live loss, since a series the person defined themselves - // is the one kind no integration can re-sync. + // Durable self-context, user-defined metrics, and persisted pattern + // decisions. These account-owned rows cannot be reconstructed from an + // integration after restore. buildProfileBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index b0281b7f9..560ce409b 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -1,12 +1,11 @@ /** - * Durable self-context and user-defined metrics — both ends in one file. + * Durable self-context, user-defined metrics, and persisted pattern decisions, + * with both backup ends in one file. * - * Two models classified `BACKED_UP` since `backup-plan.ts` was written and - * carried by nothing: `UserHealthProfile` (what a person told the app is - * durably true of them) and `CustomMetric` / `CustomMetricEntry` (a series the - * person defined themselves, which by definition no integration can re-sync). - * The custom-metric gap is live data loss today: a restore rebuilt the account - * without the metric or a single one of its readings and reported success. + * These account-owned models cannot be reconstructed from an integration: + * `UserHealthProfile`, `CustomMetric` / `CustomMetricEntry`, and + * `CorrelationPattern`. The last model carries stable finding identity and the + * evidence baseline that makes a dismissal survive recomputation and restore. * * The builder and the restore live together deliberately, the way * `src/lib/cycle/backup.ts` does. A reader asking "is this carried at both @@ -98,6 +97,7 @@ export interface CustomMetricBackupEntry { targetHigh: number | null; decimals: number | null; description: string | null; + correlationEnabled: boolean; createdAt?: string; updatedAt?: string; /** Tombstone, DR payloads only — a portable export never carries deleted rows. */ @@ -105,15 +105,40 @@ export interface CustomMetricBackupEntry { entries: CustomMetricEntryBackupEntry[]; } +/** Persisted accepted correlation evidence and its dismissal decision. */ +export interface CorrelationPatternBackupEntry { + id?: string; + canonicalKey: string; + family: string; + factorKey: string; + outcomeKey: string; + lagDays: number; + sampleSize: number; + effectSize: number; + pValue: number; + qValue: number | null; + evidenceHash: string; + isCurrent: boolean; + lastComputedAt: string; + dismissedAt: string | null; + dismissedEvidenceHash: string | null; + dismissedEffectSize: number | null; + dismissedSampleSize: number | null; + createdAt?: string; + updatedAt?: string; +} + export interface ProfileBackupSection { healthProfile: HealthProfileBackupEntry | null; customMetrics: CustomMetricBackupEntry[]; + correlationPatterns: CorrelationPatternBackupEntry[]; } export interface ProfileBackupCounts { healthProfile: number; customMetrics: number; customMetricEntries: number; + correlationPatterns: number; } /** @@ -155,13 +180,16 @@ function toBase64(buf: Uint8Array | null): string | null { * `buildCycleBackupSection` and `buildRecordsBackupSection`. */ export async function buildProfileBackupSection( - prisma: Pick, + prisma: Pick< + PrismaClient, + "userHealthProfile" | "customMetric" | "correlationPattern" + >, userId: string, options: ProfileBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [profileRow, metricRows] = await Promise.all([ + const [profileRow, metricRows, patternRows] = await Promise.all([ prisma.userHealthProfile.findUnique({ where: { userId } }), prisma.customMetric.findMany({ // A portable export never resurrects a tombstone; a DR payload restores @@ -169,12 +197,13 @@ export async function buildProfileBackupSection( where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { name: "asc" }, include: { - // `CustomMetricEntry` carries no tombstone of its own — a deleted - // metric cascades its readings away — so both purposes read the same - // set and only the parent filter differs. entries: { orderBy: { measuredAt: "asc" } }, }, }), + prisma.correlationPattern.findMany({ + where: { userId }, + orderBy: { canonicalKey: "asc" }, + }), ]); const healthProfile: HealthProfileBackupEntry | null = profileRow @@ -230,6 +259,7 @@ export async function buildProfileBackupSection( targetHigh: metric.targetHigh, decimals: metric.decimals, description: metric.description, + correlationEnabled: metric.correlationEnabled, entries: metric.entries.map((entry) => ({ ...(disasterRecovery ? { @@ -244,7 +274,35 @@ export async function buildProfileBackupSection( })), })); - return { healthProfile, customMetrics }; + const correlationPatterns: CorrelationPatternBackupEntry[] = patternRows.map( + (pattern) => ({ + ...(disasterRecovery + ? { + id: pattern.id, + createdAt: pattern.createdAt.toISOString(), + updatedAt: pattern.updatedAt.toISOString(), + } + : {}), + canonicalKey: pattern.canonicalKey, + family: pattern.family, + factorKey: pattern.factorKey, + outcomeKey: pattern.outcomeKey, + lagDays: pattern.lagDays, + sampleSize: pattern.sampleSize, + effectSize: pattern.effectSize, + pValue: pattern.pValue, + qValue: pattern.qValue, + evidenceHash: pattern.evidenceHash, + isCurrent: pattern.isCurrent, + lastComputedAt: pattern.lastComputedAt.toISOString(), + dismissedAt: pattern.dismissedAt?.toISOString() ?? null, + dismissedEvidenceHash: pattern.dismissedEvidenceHash, + dismissedEffectSize: pattern.dismissedEffectSize, + dismissedSampleSize: pattern.dismissedSampleSize, + }), + ); + + return { healthProfile, customMetrics, correlationPatterns }; } /** Row counts for the audit trail, mirroring the other section counters. */ @@ -258,6 +316,7 @@ export function countProfileBackupSection( (sum, metric) => sum + metric.entries.length, 0, ), + correlationPatterns: section.correlationPatterns.length, }; } @@ -265,6 +324,7 @@ export function countProfileBackupSection( export interface ProfileRestoreCleared { healthProfile: number; customMetrics: number; + correlationPatterns: number; } /** @@ -278,6 +338,7 @@ export interface ProfileRestoreCleared { export interface ProfileRestoreInput { healthProfile: HealthProfileBackupEntry | null; customMetrics: CustomMetricBackupEntry[]; + correlationPatterns: CorrelationPatternBackupEntry[]; } /** @@ -337,6 +398,9 @@ export async function restoreProfileData( const metricsCleared = await tx.customMetric.deleteMany({ where: { userId: ownerId }, }); + const patternsCleared = await tx.correlationPattern.deleteMany({ + where: { userId: ownerId }, + }); if (payload.healthProfile) { const p = payload.healthProfile; @@ -406,6 +470,7 @@ export async function restoreProfileData( targetHigh: metric.targetHigh, decimals: metric.decimals, description: metric.description, + correlationEnabled: metric.correlationEnabled, ...(metric.createdAt ? { createdAt: new Date(metric.createdAt) } : {}), ...(metric.updatedAt ? { updatedAt: new Date(metric.updatedAt) } : {}), deletedAt: metric.deletedAt ? new Date(metric.deletedAt) : null, @@ -426,8 +491,47 @@ export async function restoreProfileData( }); } + const seenPatternKeys = new Set(); + for (const pattern of payload.correlationPatterns) { + if (seenPatternKeys.has(pattern.canonicalKey)) { + throw new Error( + `Duplicate correlation pattern key: ${pattern.canonicalKey}`, + ); + } + seenPatternKeys.add(pattern.canonicalKey); + await tx.correlationPattern.create({ + data: { + ...(pattern.id ? { id: pattern.id } : {}), + userId: ownerId, + canonicalKey: pattern.canonicalKey, + family: pattern.family, + factorKey: pattern.factorKey, + outcomeKey: pattern.outcomeKey, + lagDays: pattern.lagDays, + sampleSize: pattern.sampleSize, + effectSize: pattern.effectSize, + pValue: pattern.pValue, + qValue: pattern.qValue, + evidenceHash: pattern.evidenceHash, + isCurrent: pattern.isCurrent, + lastComputedAt: new Date(pattern.lastComputedAt), + dismissedAt: pattern.dismissedAt ? new Date(pattern.dismissedAt) : null, + dismissedEvidenceHash: pattern.dismissedEvidenceHash, + dismissedEffectSize: pattern.dismissedEffectSize, + dismissedSampleSize: pattern.dismissedSampleSize, + ...(pattern.createdAt + ? { createdAt: new Date(pattern.createdAt) } + : {}), + ...(pattern.updatedAt + ? { updatedAt: new Date(pattern.updatedAt) } + : {}), + }, + }); + } + return { healthProfile: profileCleared.count, customMetrics: metricsCleared.count, + correlationPatterns: patternsCleared.count, }; } diff --git a/src/lib/insights/__tests__/correlation-channel-series.test.ts b/src/lib/insights/__tests__/correlation-channel-series.test.ts index 2c226534b..14a1dce74 100644 --- a/src/lib/insights/__tests__/correlation-channel-series.test.ts +++ b/src/lib/insights/__tests__/correlation-channel-series.test.ts @@ -3,16 +3,20 @@ import type { SleepStage } from "@/generated/prisma/client"; const measurementFindMany = vi.fn(); const moodFindMany = vi.fn(); +const customMetricFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, }, })); import { buildMeasurementDailySeries, fetchMeasurementWindowSeries, + fetchCustomMetricBehaviourSeries, + MAX_CUSTOM_CORRELATION_CHANNELS, fetchMoodWindowSeries, toDailyMeans, type MeasurementSeriesRow, @@ -238,3 +242,73 @@ describe("fetchMoodWindowSeries — desc+cap+resort", () => { ); }); }); + +describe("fetchCustomMetricBehaviourSeries", () => { + beforeEach(() => { + customMetricFindMany.mockReset(); + }); + + it("reads only active opted-in owner metrics with a deterministic cap", async () => { + customMetricFindMany.mockResolvedValue([]); + + await fetchCustomMetricBehaviourSeries( + "owner-1", + "Europe/Berlin", + new Date("2026-01-01T00:00:00.000Z"), + ); + + expect(customMetricFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "owner-1", + deletedAt: null, + correlationEnabled: true, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: MAX_CUSTOM_CORRELATION_CHANNELS, + }), + ); + }); + + it("keeps metric identity, drops historical units, and averages same-local-day values", async () => { + customMetricFindMany.mockResolvedValue([ + { + id: "grip-id", + name: "Grip strength", + unit: "kg", + entries: [ + { + value: 40, + unit: "kg", + measuredAt: new Date("2026-07-01T08:00:00.000Z"), + }, + { + value: 44, + unit: "kg", + measuredAt: new Date("2026-07-01T18:00:00.000Z"), + }, + { + value: 100, + unit: "lb", + measuredAt: new Date("2026-07-01T20:00:00.000Z"), + }, + ], + }, + ]); + + const result = await fetchCustomMetricBehaviourSeries( + "owner-1", + "UTC", + new Date("2026-01-01T00:00:00.000Z"), + ); + + expect(result).toEqual([ + { + key: "CUSTOM_METRIC:grip-id", + label: "Grip strength", + role: "behaviour", + points: [{ day: "2026-07-01", value: 42 }], + }, + ]); + }); +}); diff --git a/src/lib/insights/__tests__/correlation-patterns.test.ts b/src/lib/insights/__tests__/correlation-patterns.test.ts new file mode 100644 index 000000000..8308a9607 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-patterns.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface StoredPattern { + id: string; + userId: string; + canonicalKey: string; + family: string; + factorKey: string; + outcomeKey: string; + lagDays: number; + sampleSize: number; + effectSize: number; + pValue: number; + qValue: number | null; + evidenceHash: string; + isCurrent: boolean; + lastComputedAt: Date; + dismissedAt: Date | null; + dismissedEvidenceHash: string | null; + dismissedEffectSize: number | null; + dismissedSampleSize: number | null; +} + +const rows = new Map(); +const correlationPattern = { + findMany: vi.fn( + async ({ + where, + }: { + where: { userId: string; canonicalKey: { in: string[] } }; + }) => + [...rows.values()].filter( + (row) => + row.userId === where.userId && + where.canonicalKey.in.includes(row.canonicalKey), + ), + ), + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: Record; + data: { isCurrent: boolean }; + }) => { + let count = 0; + for (const row of rows.values()) { + const notIn = (where.canonicalKey as { notIn?: string[] } | undefined) + ?.notIn; + if ( + row.userId === where.userId && + row.family === where.family && + row.isCurrent === where.isCurrent && + (!notIn || !notIn.includes(row.canonicalKey)) + ) { + row.isCurrent = data.isCurrent; + count += 1; + } + } + return { count }; + }, + ), + upsert: vi.fn( + async ({ + where, + create, + update, + }: { + where: { userId_canonicalKey: { userId: string; canonicalKey: string } }; + create: Omit< + StoredPattern, + | "id" + | "dismissedAt" + | "dismissedEvidenceHash" + | "dismissedEffectSize" + | "dismissedSampleSize" + >; + update: Partial; + }) => { + const key = `${where.userId_canonicalKey.userId}:${where.userId_canonicalKey.canonicalKey}`; + const prior = rows.get(key); + if (prior) { + Object.assign(prior, update); + return prior; + } + const created: StoredPattern = { + id: `pattern-${rows.size + 1}`, + ...create, + dismissedAt: null, + dismissedEvidenceHash: null, + dismissedEffectSize: null, + dismissedSampleSize: null, + }; + rows.set(key, created); + return created; + }, + ), +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: ( + fn: (tx: { correlationPattern: typeof correlationPattern }) => unknown, + ) => fn({ correlationPattern }), + }, +})); + +import { + canonicalPatternKey, + isMaterialEvidenceChange, + PATTERN_FAMILIES, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; + +const baseEvidence = { + factorKey: "TAG:alcohol", + outcomeKey: "CROSSTAB:RESTING_HEART_RATE", + lagDays: 1, + sampleSize: 40, + effectSize: 0.3, + pValue: 0.01, + qValue: 0.04, +}; + +describe("correlation pattern identity and dismissal", () => { + beforeEach(() => { + rows.clear(); + vi.clearAllMocks(); + }); + + it("uses the same canonical identity across recomputation", () => { + expect(canonicalPatternKey("factor", "outcome", 1)).toBe( + canonicalPatternKey("factor", "outcome", 1), + ); + expect(canonicalPatternKey("factor", "outcome", 0)).not.toBe( + canonicalPatternKey("factor", "outcome", 1), + ); + }); + + it("retains an unchanged dismissal, resurfaces material evidence, and retires stale findings", async () => { + const first = await syncAcceptedPatterns({ + userId: "owner-1", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [baseEvidence], + computedAt: new Date("2026-07-01T00:00:00.000Z"), + }); + const decision = [...first.values()][0]; + const row = [...rows.values()][0]; + row.dismissedAt = new Date("2026-07-02T00:00:00.000Z"); + row.dismissedEvidenceHash = row.evidenceHash; + row.dismissedEffectSize = row.effectSize; + row.dismissedSampleSize = row.sampleSize; + + const unchanged = await syncAcceptedPatterns({ + userId: "owner-1", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [{ ...baseEvidence, sampleSize: 45, effectSize: 0.35 }], + }); + expect(unchanged.get(decision.canonicalKey)?.dismissed).toBe(true); + + const changed = await syncAcceptedPatterns({ + userId: "owner-1", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [{ ...baseEvidence, sampleSize: 50, effectSize: -0.2 }], + }); + expect(changed.get(decision.canonicalKey)?.dismissed).toBe(false); + expect(row.dismissedAt).toBeNull(); + + await syncAcceptedPatterns({ + userId: "owner-1", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [], + }); + expect(row.isCurrent).toBe(false); + }); + + it("defines material movement deterministically", () => { + expect( + isMaterialEvidenceChange({ + currentEffectSize: 0.39, + currentSampleSize: 49, + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + }), + ).toBe(false); + expect( + isMaterialEvidenceChange({ + currentEffectSize: 0.4, + currentSampleSize: 40, + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + }), + ).toBe(true); + expect( + isMaterialEvidenceChange({ + currentEffectSize: 0.3, + currentSampleSize: 50, + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + }), + ).toBe(true); + }); +}); diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 465d7dd4e..cf8cd9c8f 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -5,9 +5,21 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + illnessEpisode: { findMany: vi.fn() }, + illnessDayLog: { findMany: vi.fn() }, + environmentContext: { findMany: vi.fn() }, + customMetric: { findMany: vi.fn() }, }, })); +vi.mock("@/lib/insights/correlation-patterns", () => ({ + PATTERN_FAMILIES: { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE" }, + syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()), + decisionForEvidence: vi.fn().mockReturnValue(null), +})); + import { prisma } from "@/lib/db"; import { getRelevantCorrelationsForMetric } from "../metric-correlation-context"; @@ -17,6 +29,14 @@ beforeEach(() => { timezone: "Europe/Berlin", } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.illnessDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.environmentContext.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); }); describe("getRelevantCorrelationsForMetric", () => { diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index f99a9e04a..50d04848f 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -1090,11 +1090,13 @@ describe("computeTagMetricCrosstab", () => { type: string, presentValue: number, absentValue: number, + tag: StructuredTagRef = ALCOHOL_TAG, ) { const entries: MoodAggregateEntry[] = []; const measurements: CrossMetricMeasurement[] = []; for (let i = 1; i <= 12; i++) { - entries.push(structuredEntry(i, 3, [ALCOHOL_TAG])); + entries.push(structuredEntry(i, 3, [tag])); + // All built-in binary tags use this same branch and FDR family. measurements.push( meas(i - 1, type, presentValue + (i % 2 === 0 ? 2 : -2)), ); @@ -1149,6 +1151,35 @@ describe("computeTagMetricCrosstab", () => { expect(row!.delta).toBeCloseTo(-17, 0); }); + it.each([ + ["alcohol", "mood.tag.alcohol", "Wine"], + ["caffeine", "mood.tag.caffeine", "Coffee"], + ["nicotine", "mood.tag.nicotine", "Cigarette"], + ])( + "runs the built-in %s tag through the same next-day crosstab and FDR family", + (key, labelKey, icon) => { + const { entries, measurements } = nextDayFixture( + "RESTING_HEART_RATE", + 62, + 54, + { key, categoryKey: "health", labelKey, icon }, + ); + const rows = computeTagMetricCrosstab({ + entries, + measurements, + now: NOW, + }); + const row = rows.find( + (candidate) => + candidate.tag === key && + candidate.metricKey === "nextDayRestingHeartRate", + ); + expect(row).toBeDefined(); + expect(row!.mode).toBe("nextDay"); + expect(row!.qValue).toBeLessThanOrEqual(CROSSTAB_FDR_Q); + }, + ); + it("reads both overnight vitals from the fetched channel set", () => { // The fetch filter is derived from the map, so a metric added to // CROSSTAB_METRICS without a channel to read it from is impossible. diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 4bdb32588..0a4334b86 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ // v1.18.11 (P6) — the input gate / fingerprint probe salient inputs. measurement: { groupBy: vi.fn() }, moodEntry: { aggregate: vi.fn() }, + customMetric: { findMany: vi.fn() }, }, })); @@ -40,6 +41,7 @@ function cacheRow(details: Record, createdAt = new Date()) { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); }); describe("isTimeoutStub", () => { @@ -493,6 +495,44 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { expect(after).not.toBe(before); }); + it("folds opted-in custom metric configuration and entries into the fingerprint", async () => { + const t0 = new Date("2026-05-30T08:00:00.000Z"); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.customMetric.findMany) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([ + { + id: "custom-1", + unit: "kg", + updatedAt: t0, + _count: { entries: 1 }, + entries: [{ measuredAt: t0, unit: "kg" }], + }, + ] as never); + + const before = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT"], + includeCorrelationChannels: true, + }); + const after = await computeStatusInputFingerprint({ + userId: "u1", + types: ["WEIGHT"], + includeCorrelationChannels: true, + }); + + expect(after).not.toBe(before); + expect(prisma.customMetric.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { + userId: "u1", + deletedAt: null, + correlationEnabled: true, + }, + }), + ); + }); + it("widens the grouped query by the discovery channels only when asked", async () => { vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); await computeStatusInputFingerprint({ diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 68e4bf432..97f5dd940 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -250,6 +250,56 @@ export async function fetchEnvironmentSeries( }); } +/** Explicit deterministic ceiling on opt-in custom behaviour channels. */ +export const MAX_CUSTOM_CORRELATION_CHANNELS = 8; +export const CUSTOM_METRIC_CHANNEL_PREFIX = "CUSTOM_METRIC:"; + +/** + * Read only active, owner-scoped custom metrics that explicitly opted into + * discovery. Metrics are selected oldest-first with an id tie-break and capped + * before entries are read, bounding both channel count and pair-space growth. + * Historical rows whose unit snapshot differs from the definition's current + * unit are excluded so a rename/conversion can never mix units. + */ +export async function fetchCustomMetricBehaviourSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const metrics = await prisma.customMetric.findMany({ + where: { + userId, + deletedAt: null, + correlationEnabled: true, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: MAX_CUSTOM_CORRELATION_CHANNELS, + select: { + id: true, + name: true, + unit: true, + entries: { + where: { measuredAt: { gte: since } }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + take: 2000, + select: { value: true, unit: true, measuredAt: true }, + }, + }, + }); + + return metrics.map((metric) => ({ + key: `${CUSTOM_METRIC_CHANNEL_PREFIX}${metric.id}`, + label: metric.name, + role: "behaviour" as const, + points: toDailyMeans( + metric.entries + .filter((entry) => entry.unit === metric.unit) + .map((entry) => ({ value: entry.value, at: entry.measuredAt })), + tz, + ), + })); +} + /** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ function tzDayKey(at: Date, tz: string): string { const { year, month, day } = wallClockInTz(at, tz); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 77db6f0d3..05ddbbd5d 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -121,6 +121,7 @@ export function metricFamily(key: string): string { // near-tautology). They stay free to pair cross-domain against mood / sleep / // vitals — the whole point of the module. if (key.startsWith("ENV_")) return "ENVIRONMENT"; + if (key.startsWith("CUSTOM_METRIC:")) return key; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -168,6 +169,8 @@ export interface NamedSeries { key: string; /** Whether the channel is a behaviour (lag source) or an outcome (lag target). */ role: "behaviour" | "outcome"; + /** User-facing label for dynamic channels such as custom metrics. */ + label?: string; points: DailySeriesPoint[]; } @@ -175,7 +178,9 @@ export interface NamedSeries { export interface DiscoveredCorrelation { behaviour: string; outcome: string; + behaviourLabel?: string; /** Paired-day count after the day+1 lag join. */ + outcomeLabel?: string; n: number; /** Pearson r (lag-joined). */ r: number; @@ -214,6 +219,10 @@ export interface DiscoveredCorrelation { * provisional rather than established. */ provisional?: boolean; + /** Persisted server decision for dismissal across recomputation. */ + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } export interface CorrelationDiscoveryResult { @@ -263,6 +272,8 @@ interface RawPair { n: number; r: number; pValue: number; + behaviourLabel?: string; + outcomeLabel?: string; } /** @@ -309,10 +320,12 @@ function interpret( r: number, tier: ConfidenceTier, t: Translate, + behaviourLabel?: string, + outcomeLabel?: string, ): string { const params = { - behaviour: humanise(behaviour, t), - outcome: humanise(outcome, t), + behaviour: behaviourLabel ?? humanise(behaviour, t), + outcome: outcomeLabel ?? humanise(outcome, t), direction: t( `insights.correlation.direction.${r < 0 ? "lower" : "higher"}`, ), @@ -471,6 +484,8 @@ export function discoverCorrelations( n: result.n, r: result.r, pValue: result.pValue, + behaviourLabel: b.label, + outcomeLabel: o.label, }); } } @@ -505,13 +520,23 @@ export function discoverCorrelations( .map((t) => ({ behaviour: t.behaviour, outcome: t.outcome, + behaviourLabel: t.behaviourLabel, + outcomeLabel: t.outcomeLabel, n: t.n, r: t.r, pValue: t.pValue, qValue: Math.round(t.qValue * 1000) / 1000, shrunkR: Math.round(t.shrunkR * 1000) / 1000, tier: t.tier, - interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier, translate), + interpretation: interpret( + t.behaviour, + t.outcome, + t.r, + t.tier, + translate, + t.behaviourLabel, + t.outcomeLabel, + ), lagDays, })) // RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong diff --git a/src/lib/insights/correlation-patterns.ts b/src/lib/insights/correlation-patterns.ts new file mode 100644 index 000000000..98d22bf65 --- /dev/null +++ b/src/lib/insights/correlation-patterns.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; + +import { prisma } from "@/lib/db"; + +export const PATTERN_FAMILIES = { + discoveryRetrospective: "DISCOVERY_RETROSPECTIVE", + discoveryRecent: "DISCOVERY_RECENT", + fixed: "FIXED", + moodTagCrosstab: "MOOD_TAG_CROSSTAB", + moodFactorCrosstab: "MOOD_FACTOR_CROSSTAB", +} as const; + +export type PatternFamily = + (typeof PATTERN_FAMILIES)[keyof typeof PATTERN_FAMILIES]; + +export interface AcceptedPatternEvidence { + factorKey: string; + outcomeKey: string; + lagDays: number; + sampleSize: number; + effectSize: number; + pValue: number; + qValue?: number | null; +} + +export interface PatternDecision { + patternId: string; + canonicalKey: string; + dismissed: boolean; +} + +/** Stable identity derived only from the user-independent pair definition. */ +export function canonicalPatternKey( + factorKey: string, + outcomeKey: string, + lagDays: number, +): string { + const source = JSON.stringify([factorKey, outcomeKey, lagDays]); + return `p1:${createHash("sha256").update(source).digest("hex")}`; +} + +function evidenceHash(evidence: AcceptedPatternEvidence): string { + const source = JSON.stringify({ + factorKey: evidence.factorKey, + outcomeKey: evidence.outcomeKey, + lagDays: evidence.lagDays, + sampleSize: evidence.sampleSize, + effectSize: evidence.effectSize, + pValue: evidence.pValue, + qValue: evidence.qValue ?? null, + }); + return createHash("sha256").update(source).digest("hex"); +} + +/** + * A dismissal survives normal sampling noise. It is released only when the + * effect reverses direction, moves by at least 0.10, or the accepted sample + * grows by at least 25% (with a ten-observation floor). + */ +export function isMaterialEvidenceChange(args: { + currentEffectSize: number; + currentSampleSize: number; + dismissedEffectSize: number; + dismissedSampleSize: number; +}): boolean { + const signChanged = + Math.sign(args.currentEffectSize) !== Math.sign(args.dismissedEffectSize); + const effectMoved = + Math.abs(args.currentEffectSize - args.dismissedEffectSize) >= 0.1; + const sampleGrowth = Math.max(10, Math.ceil(args.dismissedSampleSize * 0.25)); + const sampleDeepened = + args.currentSampleSize >= args.dismissedSampleSize + sampleGrowth; + return signChanged || effectMoved || sampleDeepened; +} + +/** + * Persist one accepted family run, retire findings no longer accepted, and + * return the server's presentation decision for every accepted finding. + */ +export async function syncAcceptedPatterns(args: { + userId: string; + family: PatternFamily; + accepted: AcceptedPatternEvidence[]; + computedAt?: Date; +}): Promise> { + const computedAt = args.computedAt ?? new Date(); + const prepared = args.accepted.map((evidence) => ({ + evidence, + canonicalKey: canonicalPatternKey( + evidence.factorKey, + evidence.outcomeKey, + evidence.lagDays, + ), + evidenceHash: evidenceHash(evidence), + })); + const keys = prepared.map((item) => item.canonicalKey); + + return prisma.$transaction(async (tx) => { + const existing = await tx.correlationPattern.findMany({ + where: { userId: args.userId, canonicalKey: { in: keys } }, + }); + const existingByKey = new Map( + existing.map((row) => [row.canonicalKey, row]), + ); + + if (keys.length === 0) { + await tx.correlationPattern.updateMany({ + where: { userId: args.userId, family: args.family, isCurrent: true }, + data: { isCurrent: false }, + }); + } else { + await tx.correlationPattern.updateMany({ + where: { + userId: args.userId, + family: args.family, + isCurrent: true, + canonicalKey: { notIn: keys }, + }, + data: { isCurrent: false }, + }); + } + + const decisions = new Map(); + for (const item of prepared) { + const prior = existingByKey.get(item.canonicalKey); + const shouldResurface = + prior?.dismissedAt != null && + prior.dismissedEffectSize != null && + prior.dismissedSampleSize != null && + isMaterialEvidenceChange({ + currentEffectSize: item.evidence.effectSize, + currentSampleSize: item.evidence.sampleSize, + dismissedEffectSize: prior.dismissedEffectSize, + dismissedSampleSize: prior.dismissedSampleSize, + }); + + const row = await tx.correlationPattern.upsert({ + where: { + userId_canonicalKey: { + userId: args.userId, + canonicalKey: item.canonicalKey, + }, + }, + create: { + userId: args.userId, + canonicalKey: item.canonicalKey, + family: args.family, + factorKey: item.evidence.factorKey, + outcomeKey: item.evidence.outcomeKey, + lagDays: item.evidence.lagDays, + sampleSize: item.evidence.sampleSize, + effectSize: item.evidence.effectSize, + pValue: item.evidence.pValue, + qValue: item.evidence.qValue ?? null, + evidenceHash: item.evidenceHash, + isCurrent: true, + lastComputedAt: computedAt, + }, + update: { + family: args.family, + factorKey: item.evidence.factorKey, + outcomeKey: item.evidence.outcomeKey, + lagDays: item.evidence.lagDays, + sampleSize: item.evidence.sampleSize, + effectSize: item.evidence.effectSize, + pValue: item.evidence.pValue, + qValue: item.evidence.qValue ?? null, + evidenceHash: item.evidenceHash, + isCurrent: true, + lastComputedAt: computedAt, + ...(shouldResurface + ? { + dismissedAt: null, + dismissedEvidenceHash: null, + dismissedEffectSize: null, + dismissedSampleSize: null, + } + : {}), + }, + }); + + decisions.set(item.canonicalKey, { + patternId: row.id, + canonicalKey: row.canonicalKey, + dismissed: row.dismissedAt != null, + }); + } + return decisions; + }); +} + +export function decisionForEvidence( + decisions: Map, + evidence: Pick< + AcceptedPatternEvidence, + "factorKey" | "outcomeKey" | "lagDays" + >, +): PatternDecision | null { + return ( + decisions.get( + canonicalPatternKey( + evidence.factorKey, + evidence.outcomeKey, + evidence.lagDays, + ), + ) ?? null + ); +} diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index dffe8766b..9ceffa0c6 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -106,6 +106,9 @@ export interface CorrelationOk { xLabel: string; /** Free-form key the UI can use to label the y-axis. */ yLabel: string; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } export interface CorrelationInsufficient { diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index 101dcbb7f..b5b269a27 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -28,13 +28,24 @@ import { DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, type NamedSeries, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, } from "@/lib/insights/correlation-discovery"; import { buildMeasurementDailySeries, + fetchComplianceSeries, + fetchCustomMetricBehaviourSeries, + fetchEnvironmentSeries, fetchMeasurementWindowSeries, fetchMoodWindowSeries, + fetchSymptomSeries, } from "@/lib/insights/correlation-channel-series"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + decisionForEvidence, + PATTERN_FAMILIES, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; import type { RelevantCorrelation } from "@/lib/insights/assessment-context"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -101,13 +112,25 @@ export async function getRelevantCorrelationsForMetric( // lives in `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries` // (`correlation-channel-series.ts`), shared with the route, the Coach // tool, and the period narrative. - const [{ byType }, { moodDaily }, priorityJson] = await Promise.all([ + const [ + { byType }, + { moodDaily }, + priorityJson, + complianceSeries, + symptomSeries, + environmentSeries, + customMetricSeries, + ] = await Promise.all([ fetchMeasurementWindowSeries(userId, since, [ ...behaviourTypes, ...outcomeTypes, ]), fetchMoodWindowSeries(userId, tz, since), loadUserSourcePriority(userId), + fetchComplianceSeries(userId, tz, since), + fetchSymptomSeries(userId, tz, since), + fetchEnvironmentSeries(userId, since), + fetchCustomMetricBehaviourSeries(userId, tz, since), ]); const points = (key: string) => @@ -122,15 +145,46 @@ export async function getRelevantCorrelationsForMetric( const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { - series.push({ key, role: "behaviour", points: points(key) }); + if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { + series.push(complianceSeries); + } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "behaviour" }); + } else { + series.push({ key, role: "behaviour", points: points(key) }); + } } for (const key of DISCOVERY_OUTCOMES) { - series.push({ key, role: "outcome", points: points(key) }); + if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "outcome" }); + } else { + series.push({ key, role: "outcome", points: points(key) }); + } } + series.push(...environmentSeries, ...customMetricSeries); const result = discoverCorrelations(series); + const decisions = await syncAcceptedPatterns({ + userId, + family: PATTERN_FAMILIES.discoveryRetrospective, + accepted: result.discovered.map((pattern) => ({ + factorKey: pattern.behaviour, + outcomeKey: pattern.outcome, + lagDays: pattern.lagDays, + sampleSize: pattern.n, + effectSize: pattern.r, + pValue: pattern.pValue, + qValue: pattern.qValue, + })), + }); return result.discovered - .filter((d) => d.behaviour === channel || d.outcome === channel) + .filter((d) => { + const decision = decisionForEvidence(decisions, { + factorKey: d.behaviour, + outcomeKey: d.outcome, + lagDays: d.lagDays, + }); + return decision?.dismissed !== true; + }) .map((d) => ({ interpretation: d.interpretation, n: d.n, r: d.r })); } catch { return []; diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 263f4a575..f150a3149 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -70,6 +70,11 @@ import { type TagInfluence, type TagSummaryRow, } from "@/lib/insights/mood-tag-influence"; +import { + decisionForEvidence, + PATTERN_FAMILIES, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; import type { MeasurementType } from "@/generated/prisma/enums"; import type { MeasurementSource } from "@/generated/prisma/client"; @@ -760,11 +765,56 @@ export async function fetchMoodAggregates( resolveUserTimezone(userId), ]); - return computeMoodAggregates({ + const aggregate = computeMoodAggregates({ entries, measurements, now, userPriorityJson, tz, }); + + const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({ + factorKey: `TAG:${row.tag}`, + outcomeKey: `CROSSTAB:${row.metricKey}`, + lagDays: row.mode === "nextDay" ? 1 : 0, + sampleSize: row.withDays + row.withoutDays, + effectSize: row.delta, + pValue: row.pValue, + qValue: row.qValue, + })); + const factorEvidence = aggregate.factorCrosstab.map((row) => ({ + factorKey: `RATED:${row.factor}`, + outcomeKey: `CROSSTAB:${row.metricKey}`, + lagDays: row.mode === "nextDay" ? 1 : 0, + sampleSize: row.lowDays + row.highDays, + effectSize: row.delta, + pValue: row.pValue, + qValue: row.qValue, + })); + const [tagDecisions, factorDecisions] = await Promise.all([ + syncAcceptedPatterns({ + userId, + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: tagEvidence, + computedAt: now, + }), + syncAcceptedPatterns({ + userId, + family: PATTERN_FAMILIES.moodFactorCrosstab, + accepted: factorEvidence, + computedAt: now, + }), + ]); + + return { + ...aggregate, + tagMetricCrosstab: aggregate.tagMetricCrosstab.map((row, index) => ({ + ...row, + ...(decisionForEvidence(tagDecisions, tagEvidence[index]) ?? {}), + })), + factorCrosstab: aggregate.factorCrosstab.map((row, index) => ({ + ...row, + ...(decisionForEvidence(factorDecisions, factorEvidence[index]) ?? {}), + })), + }; } diff --git a/src/lib/insights/mood-crosstab.ts b/src/lib/insights/mood-crosstab.ts index c49003a06..d349febb6 100644 --- a/src/lib/insights/mood-crosstab.ts +++ b/src/lib/insights/mood-crosstab.ts @@ -170,6 +170,9 @@ export interface TagMetricCrosstabRow { qValue: number; /** Discrete confidence band (p + min per-group day count). */ confidence: InfluenceConfidence; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } const CROSSTAB_SUM_TYPES = new Set([ @@ -497,6 +500,9 @@ export interface FactorMetricCrosstabRow { qValue: number; /** Discrete confidence band (p + min per-group day count). */ confidence: InfluenceConfidence; + patternId?: string; + canonicalKey?: string; + dismissed?: boolean; } type FactorMetricDisplay = diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 03679f230..e41af01ac 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -14,6 +14,7 @@ import { DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, } from "@/lib/insights/correlation-discovery"; +import { MAX_CUSTOM_CORRELATION_CHANNELS } from "@/lib/insights/correlation-channel-series"; import { annotate } from "@/lib/logging/context"; import type { SupportedLocale } from "@/lib/insights/status-shared"; @@ -289,7 +290,7 @@ export async function computeStatusInputFingerprint(args: { ? Array.from(new Set([...args.types, ...CORRELATION_CHANNEL_TYPES])) : [...args.types]; - const [grouped, mood] = await Promise.all([ + const [grouped, mood, customMetrics] = await Promise.all([ prisma.measurement.groupBy({ by: ["type"], where: { @@ -307,6 +308,28 @@ export async function computeStatusInputFingerprint(args: { _max: { moodLoggedAt: true }, }) : Promise.resolve(null), + args.includeCorrelationChannels + ? prisma.customMetric.findMany({ + where: { + userId: args.userId, + deletedAt: null, + correlationEnabled: true, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: MAX_CUSTOM_CORRELATION_CHANNELS, + select: { + id: true, + unit: true, + updatedAt: true, + _count: { select: { entries: true } }, + entries: { + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + take: 1, + select: { measuredAt: true, unit: true }, + }, + }, + }) + : Promise.resolve([]), ]); // Deterministic shape regardless of group order: sort by type, project a // stable `{ type, count, newest }` triple. `hashInsightSnapshot` sorts keys @@ -330,6 +353,14 @@ export async function computeStatusInputFingerprint(args: { }, } : {}), + customMetrics: customMetrics.map((metric) => ({ + id: metric.id, + unit: metric.unit, + updatedAt: metric.updatedAt.toISOString(), + count: metric._count.entries, + newest: metric.entries[0]?.measuredAt.toISOString() ?? null, + newestUnit: metric.entries[0]?.unit ?? null, + })), ...(args.extra ? { extra: args.extra } : {}), }); } diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index 2e5d48e95..8a1081841 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -7,9 +7,9 @@ * so the wire contract stays single-source. The response shapes are declared * here. * - * Custom metrics are a SEPARATE generic store from the closed measurement - * system: not synced, not in FHIR, not in AI insights — log + chart only. All - * fields are plaintext. + * Custom metrics are a separate generic store from the closed measurement + * system. A definition can explicitly opt into the bounded daily correlation + * behaviour-channel scan. All fields are plaintext. */ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; @@ -26,13 +26,13 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; createCustomMetricSchema.meta({ id: "CreateCustomMetricRequest", description: - "Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, and an optional `description`. The name is unique per user. Logging a value later just picks this metric — its unit is snapshotted onto the value at write time. Isolated from the closed measurement system: not synced, not in FHIR, not in insights.", + "Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, an optional `description`, and explicit `correlationEnabled` opt-in. The name is unique per user. Logging a value later snapshots its unit.", }); updateCustomMetricSchema.meta({ id: "UpdateCustomMetricRequest", description: - "Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. A rename that collides with another of the caller's live metrics is rejected 409.", + "Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. `correlationEnabled` explicitly controls use as a bounded behaviour channel. A conflicting rename is rejected 409.", }); createCustomMetricEntrySchema.meta({ @@ -64,6 +64,7 @@ const customMetricRow = z targetHigh: z.number().nullable(), decimals: z.number().nullable(), description: z.string().nullable(), + correlationEnabled: z.boolean(), latest: latestValue, entryCount: z.number(), createdAt: z.string(), diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 097c14092..d55ee89c6 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -24,6 +24,9 @@ import { derivedBatchQuery, derivedBatchResponse, correlationDiscoveryResponse, + correlationPatternListResponse, + updateCorrelationPatternRequest, + updateCorrelationPatternResponse, glp1PlateauResponse, insightStatusQuery, insightStatusResponse, @@ -511,4 +514,59 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/patterns": { + get: { + tags: ["Insights"], + summary: "List current correlation patterns", + description: + "Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps.", + responses: { + "200": { + description: "Current persisted correlation patterns.", + content: { + "application/json": { + schema: dataEnvelope( + correlationPatternListResponse, + "CorrelationPatternListResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/patterns/{id}": { + patch: { + tags: ["Insights"], + summary: "Update correlation pattern dismissal", + description: + "Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateCorrelationPatternRequest }, + }, + }, + responses: { + "200": { + description: "Updated dismissal decision.", + content: { + "application/json": { + schema: dataEnvelope( + updateCorrelationPatternResponse, + "UpdateCorrelationPatternResponseEnvelope", + ), + }, + }, + }, + "404": { + description: "No current pattern with that id for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 08cf58d45..803ac0084 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -281,6 +281,14 @@ export const discoveredCorrelation = z .describe( "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.", ), + behaviourLabel: z + .string() + .optional() + .describe("Display label for a dynamic behaviour channel."), + outcomeLabel: z + .string() + .optional() + .describe("Display label for a dynamic outcome channel."), n: z .number() .int() @@ -308,9 +316,57 @@ export const discoveredCorrelation = z .describe( "v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established.", ), + patternId: z + .string() + .optional() + .describe("Account-scoped persisted identity for dismissal."), + canonicalKey: z + .string() + .optional() + .describe("Stable factor + outcome + lag identity."), + dismissed: z + .boolean() + .optional() + .describe("Server decision to suppress unchanged evidence."), }) .meta({ id: "DiscoveredCorrelation" }); +export const correlationPattern = z + .object({ + id: z.string(), + canonicalKey: z.string(), + family: z.string(), + factorKey: z.string(), + outcomeKey: z.string(), + lagDays: z.number().int(), + sampleSize: z.number().int(), + effectSize: z.number(), + pValue: z.number(), + qValue: z.number().nullable(), + evidenceHash: z.string(), + lastComputedAt: z.string(), + dismissedAt: z.string().nullable(), + }) + .meta({ id: "CorrelationPattern" }); + +export const correlationPatternListResponse = z + .object({ patterns: z.array(correlationPattern) }) + .meta({ id: "CorrelationPatternListResponse" }); + +export const updateCorrelationPatternRequest = z + .strictObject({ dismissed: z.boolean() }) + .meta({ id: "UpdateCorrelationPatternRequest" }); + +export const updateCorrelationPatternResponse = z + .object({ + id: z.string(), + canonicalKey: z.string(), + dismissed: z.boolean(), + dismissedAt: z.string().nullable(), + evidenceHash: z.string(), + }) + .meta({ id: "UpdateCorrelationPatternResponse" }); + // v1.22 — one labs ↔ outcome association (point-vs-window over sparse draws). export const discoveredLabCorrelation = z .object({ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1fe0f8507..364845d68 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -447,6 +447,7 @@ const customMetricBackupSchema = z targetHigh: z.number().nullable().default(null), decimals: z.number().int().nullable().default(null), description: z.string().nullable().default(null), + correlationEnabled: z.boolean().default(false), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), deletedAt: isoDateTime.nullable().optional(), @@ -454,6 +455,34 @@ const customMetricBackupSchema = z }) .passthrough(); +const correlationPatternBackupSchema = z + .object({ + id: z.string().min(1).optional(), + canonicalKey: z.string().regex(/^p1:[a-f0-9]{64}$/), + family: z.string().min(1), + factorKey: z.string().min(1), + outcomeKey: z.string().min(1), + lagDays: z.number().int().min(0), + sampleSize: z.number().int().positive(), + effectSize: z.number().finite(), + pValue: z.number().min(0).max(1), + qValue: z.number().min(0).max(1).nullable().default(null), + evidenceHash: z.string().regex(/^[a-f0-9]{64}$/), + isCurrent: z.boolean(), + lastComputedAt: isoDateTime, + dismissedAt: isoDateTime.nullable().default(null), + dismissedEvidenceHash: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .default(null), + dismissedEffectSize: z.number().finite().nullable().default(null), + dismissedSampleSize: z.number().int().positive().nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + /** * One local day's cumulative curve for one metric. * @@ -749,6 +778,7 @@ export const backupPayloadSchema = z // `null` / `[]`. healthProfile: healthProfileBackupSchema.nullable().default(null), customMetrics: z.array(customMetricBackupSchema).default([]), + correlationPatterns: z.array(correlationPatternBackupSchema).default([]), // The hourly shape of a cumulative day. Defaulted for the same reason as // the pair above: a file written before the table existed carries no key. intradayProfiles: z.array(intradayProfileBackupSchema).default([]), @@ -810,6 +840,8 @@ export interface BackupSummary { customMetrics: number; /** Readings across every user-defined metric. */ customMetricEntries: number; + /** Persisted accepted correlation identities and dismissal decisions. */ + correlationPatterns: number; /** Stored day curves for the cumulative metrics, across every metric. */ intradayProfiles: number; } @@ -843,6 +875,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, metric) => sum + metric.entries.length, 0, ), + correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, }; } diff --git a/src/lib/validations/correlation-patterns.ts b/src/lib/validations/correlation-patterns.ts new file mode 100644 index 000000000..86ee2a157 --- /dev/null +++ b/src/lib/validations/correlation-patterns.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const updateCorrelationPatternSchema = z.strictObject({ + dismissed: z.boolean(), +}); + +export type UpdateCorrelationPatternInput = z.infer< + typeof updateCorrelationPatternSchema +>; diff --git a/src/lib/validations/custom-metrics.ts b/src/lib/validations/custom-metrics.ts index fe60d08d5..6b4367c24 100644 --- a/src/lib/validations/custom-metrics.ts +++ b/src/lib/validations/custom-metrics.ts @@ -44,6 +44,7 @@ export const createCustomMetricSchema = z targetHigh, decimals, description: optionalDescription, + correlationEnabled: z.boolean().default(false), }) .refine( (d) => @@ -71,6 +72,7 @@ export const updateCustomMetricSchema = z targetHigh: targetHigh.or(z.null()), decimals: decimals.or(z.null()), description: optionalDescription.or(z.null()), + correlationEnabled: z.boolean().optional(), }) .refine( (d) => diff --git a/tests/integration/mood-tag-groups.test.ts b/tests/integration/mood-tag-groups.test.ts index 24a0f09e0..58b1081dc 100644 --- a/tests/integration/mood-tag-groups.test.ts +++ b/tests/integration/mood-tag-groups.test.ts @@ -130,6 +130,30 @@ beforeEach(async () => { }); describe("custom mood-tag groups (real Postgres)", () => { + it("exposes alcohol, caffeine, and nicotine from the built-in binary catalogue", async () => { + await actAs(USER_A, "mtg-a"); + const tree = await readTree(); + const health = tree.find((category) => category.key === "health"); + expect(health).toBeDefined(); + expect( + health!.tags + .filter((tag) => ["alcohol", "caffeine", "nicotine"].includes(tag.key)) + .map((tag) => tag.key) + .sort(), + ).toEqual(["alcohol", "caffeine", "nicotine"]); + + const rows = await getPrismaClient().moodTag.findMany({ + where: { key: { in: ["alcohol", "caffeine", "nicotine"] } }, + select: { key: true, userId: true, kind: true, isActive: true }, + orderBy: { key: "asc" }, + }); + expect(rows).toEqual([ + { key: "alcohol", userId: null, kind: "BINARY", isActive: true }, + { key: "caffeine", userId: null, kind: "BINARY", isActive: true }, + { key: "nicotine", userId: null, kind: "BINARY", isActive: true }, + ]); + }); + it("creates a group, stores the label encrypted, and resolves it on the management read", async () => { await actAs(USER_A, "mtg-a"); const groupKey = await createGroup("Therapie", "Stethoscope");