feat(insights): persist correlation patterns

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 14:01:11 +02:00
1 parent 8ece6f39cc
commit 666a85d311
53 files changed
+2129 -55

No files matched your search

@@ -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;
@@ -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";
+38 -2
View File
@@ -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 {
@@ -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();
@@ -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,
},
},
});
@@ -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 () => ({
+57 -1
View File
@@ -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
@@ -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<string, unknown> };
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.
@@ -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 });
},
@@ -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);
}
+6
View File
@@ -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 });
},
@@ -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 () => {
+13 -2
View File
@@ -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.
@@ -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 () => ({
+76 -11
View File
@@ -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,
});
});
@@ -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();
});
});
@@ -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,
});
},
);
+42
View File
@@ -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,
})),
});
});
@@ -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",
};
@@ -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<string | null>(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}
/>
</div>
<div className="flex items-start justify-between gap-4 sm:col-span-2">
<div className="space-y-1">
<Label htmlFor="custom-metric-correlation-enabled">
{t("customMetrics.form.correlationEnabled")}
</Label>
<p className="text-muted-foreground text-xs">
{t("customMetrics.form.correlationEnabledHint")}
</p>
</div>
<Switch
id="custom-metric-correlation-enabled"
checked={correlationEnabled}
onCheckedChange={setCorrelationEnabled}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
+1
View File
@@ -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;
+21 -1
View File
@@ -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<CorrelationResult["kind"], string> = {
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) {
</div>
</CardHeader>
<CardContent className="space-y-3">
{result.status === "ok" ? (
{dismissed && result.status === "ok" && result.patternId ? (
<PatternDismissedNotice
patternId={result.patternId}
onRestored={() => setDismissed(false)}
/>
) : result.status === "ok" ? (
<>
<ScatterCorrelationChart
data={result.points}
@@ -188,6 +202,12 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
question={t(COACH_QUESTION_BY_KIND[result.kind])}
scope={coachScope}
/>
{result.patternId ? (
<PatternDismissButton
patternId={result.patternId}
onDismissed={() => setDismissed(true)}
/>
) : null}
</div>
</>
) : (
@@ -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 (
<li key={pair.canonicalKey ?? pair.patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={pair.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(pair.patternId as string);
return next;
})
}
/>
</li>
);
}
const up = pair.r >= 0;
const sentenceKey = moodIsOutcome
? up
@@ -139,6 +187,19 @@ export function MoodDiscoveredRelations({
})}
/>
</span>
{pair.patternId ? (
<PatternDismissButton
patternId={pair.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(pair.patternId as string);
return next;
})
}
className="self-start"
/>
) : null}
</li>
);
})}
@@ -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<string, string> = {
@@ -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({
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
if (row.patternId && dismissedIds.has(row.patternId)) {
return (
<li key={row.canonicalKey ?? row.patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={row.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(row.patternId as string);
return next;
})
}
/>
</li>
);
}
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])}
</span>
{row.patternId ? (
<PatternDismissButton
patternId={row.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(row.patternId as string);
return next;
})
}
className="shrink-0"
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t(
@@ -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<string, string> = {
@@ -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({
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
if (row.patternId && dismissedIds.has(row.patternId)) {
return (
<li key={row.canonicalKey ?? row.patternId} className="py-2">
<PatternDismissedNotice
compact
patternId={row.patternId}
onRestored={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.delete(row.patternId as string);
return next;
})
}
/>
</li>
);
}
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])}
</span>
{row.patternId ? (
<PatternDismissButton
patternId={row.patternId}
onDismissed={() =>
setDismissedIds((current) => {
const next = new Set(current);
next.add(row.patternId as string);
return next;
})
}
className="shrink-0"
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t(
@@ -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 (
<Card data-slot="mood-what-stands-out">
@@ -73,6 +72,29 @@ export function MoodWhatStandsOut({
</CardHeader>
<CardContent className="space-y-4">
{hasNarratives && <MoodNarrativeFeed items={narratives} />}
{isLoading ? (
<Skeleton
data-slot="mood-discovery-loading"
className="h-16 w-full"
/>
) : null}
{isError ? (
<p
data-slot="mood-discovery-error"
role="alert"
className="text-destructive text-sm"
>
{t("insights.pattern.loadError")}
</p>
) : null}
{!isLoading && !isError && data && !hasDiscovered ? (
<p
data-slot="mood-discovery-empty"
className="text-muted-foreground text-sm"
>
{t("insights.pattern.empty")}
</p>
) : null}
{hasDiscovered && (
<MoodDiscoveredRelations
pairs={moodPairs}
@@ -0,0 +1,113 @@
"use client";
import { EyeOff, RotateCcw, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
interface PatternDecisionResponse {
id: string;
dismissed: boolean;
}
export function PatternDismissButton({
patternId,
onDismissed,
className,
}: {
patternId: string;
onDismissed: () => void;
className?: string;
}) {
const { t } = useTranslations();
const [pending, setPending] = useState(false);
async function dismiss() {
setPending(true);
try {
await apiPatch<PatternDecisionResponse>(
`/api/insights/patterns/${patternId}`,
{ dismissed: true },
);
onDismissed();
} catch {
toast.error(t("insights.pattern.dismissError"));
} finally {
setPending(false);
}
}
return (
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={dismiss}
className={cn("gap-1.5", className)}
aria-label={t("insights.pattern.dismiss")}
>
<X className="size-3.5" aria-hidden="true" />
{t("insights.pattern.dismiss")}
</Button>
);
}
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<PatternDecisionResponse>(
`/api/insights/patterns/${patternId}`,
{ dismissed: false },
);
onRestored();
} catch {
toast.error(t("insights.pattern.restoreError"));
} finally {
setPending(false);
}
}
return (
<div
data-slot="pattern-dismissed"
role="status"
className={cn(
"text-muted-foreground flex items-center justify-between gap-3 text-sm",
!compact && "rounded-md border px-3 py-2",
)}
>
<span className="flex items-center gap-2">
<EyeOff className="size-4 shrink-0" aria-hidden="true" />
{t("insights.pattern.dismissed")}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={restore}
className="gap-1.5"
>
<RotateCcw className="size-3.5" aria-hidden="true" />
{t("insights.pattern.restore")}
</Button>
</div>
);
}
+7
View File
@@ -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
@@ -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(),
};
@@ -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 () => {
+2
View File
@@ -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",
+3 -4
View File
@@ -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",
}),
+117 -13
View File
@@ -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<PrismaClient, "userHealthProfile" | "customMetric">,
prisma: Pick<
PrismaClient,
"userHealthProfile" | "customMetric" | "correlationPattern"
>,
userId: string,
options: ProfileBackupOptions = {},
): Promise<ProfileBackupSection> {
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<string>();
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,
};
}
@@ -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 }],
},
]);
});
});
@@ -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<string, StoredPattern>();
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<string, unknown>;
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<StoredPattern>;
}) => {
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);
});
});
@@ -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", () => {
@@ -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.
@@ -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<string, unknown>, 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({
@@ -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<NamedSeries[]> {
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);
+28 -3
View File
@@ -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
+208
View File
@@ -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<Map<string, PatternDecision>> {
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<string, PatternDecision>();
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<string, PatternDecision>,
evidence: Pick<
AcceptedPatternEvidence,
"factorKey" | "outcomeKey" | "lagDays"
>,
): PatternDecision | null {
return (
decisions.get(
canonicalPatternKey(
evidence.factorKey,
evidence.outcomeKey,
evidence.lagDays,
),
) ?? null
);
}
+3
View File
@@ -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 {
+58 -4
View File
@@ -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 [];
+51 -1
View File
@@ -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]) ?? {}),
})),
};
}
+6
View File
@@ -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<string>([
@@ -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 =
+32 -1
View File
@@ -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<string>([...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 } : {}),
});
}
+6 -5
View File
@@ -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(),
+58
View File
@@ -24,6 +24,9 @@ import {
derivedBatchQuery,
derivedBatchResponse,
correlationDiscoveryResponse,
correlationPatternListResponse,
updateCorrelationPatternRequest,
updateCorrelationPatternResponse,
glp1PlateauResponse,
insightStatusQuery,
insightStatusResponse,
@@ -511,4 +514,59 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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,
},
},
},
};
@@ -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({
+33
View File
@@ -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,
};
}
@@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const updateCorrelationPatternSchema = z.strictObject({
dismissed: z.boolean(),
});
export type UpdateCorrelationPatternInput = z.infer<
typeof updateCorrelationPatternSchema
>;
+2
View File
@@ -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) =>
+24
View File
@@ -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");