mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): persist correlation patterns
This commit is contained in:
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
@@ -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 () => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+7
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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]) ?? {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user