From 86737fd6a5b1e6fad389ff9a4383dd83e9c93907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:15:18 +0200 Subject: [PATCH] feat(health-score): expose per-component source + asOf in provenance detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends HealthScoreComponentDetail with a source pill (manual / withings / appleHealth / mixed / none) and an asOf ISO timestamp. The analytics route folds the persisted MeasurementSource enum from weight + BP rows into the new attribution slot; mood + compliance stay manual until later ingest paths land. Backward compat is preserved — callers that don't pass attribution still get a valid result with manual defaults for present components. --- src/app/api/analytics/route.ts | 175 +++++++++++++++--- .../analytics/__tests__/health-score.test.ts | 132 +++++++++++++ src/lib/analytics/health-score.ts | 172 +++++++++++++++-- 3 files changed, 430 insertions(+), 49 deletions(-) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 176d45c90..c0c5d7070 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -623,6 +623,51 @@ function berlinIsoWeekday(d: Date): number { return ISO_WEEKDAY[weekday] ?? 1; } +/** + * v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto + * the camelCase token set the health-score analytics layer consumes. + * + * - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both + * surface as `"manual"`. + * - `WITHINGS` and `APPLE_HEALTH` ride one-to-one. + * + * Returns `null` for source values that don't fall into the three + * exposed buckets — defence-in-depth in case the enum grows before the + * client is taught about it. + */ +function mapMeasurementSourceToLabel( + source: MeasurementSource, +): "manual" | "withings" | "appleHealth" | null { + switch (source) { + case "MANUAL": + case "IMPORT": + return "manual"; + case "WITHINGS": + return "withings"; + case "APPLE_HEALTH": + return "appleHealth"; + default: + return null; + } +} + +/** + * v1.4.25 W8e — deduplicate the contributing-source list for a single + * component. Returns the empty array when nothing in the input maps + * onto a known label so downstream `resolveSourceLabel` falls through + * to `none` (matches the empty-state branch). + */ +function uniqueComponentSources( + rows: ReadonlyArray, +): ReadonlyArray<"manual" | "withings" | "appleHealth"> { + const seen = new Set<"manual" | "withings" | "appleHealth">(); + for (const src of rows) { + const label = mapMeasurementSourceToLabel(src); + if (label) seen.add(label); + } + return Array.from(seen); +} + /** * Build the Health Score input from the user's last-30-day weight, * mood, and medication-compliance data, plus the already-computed @@ -645,38 +690,58 @@ async function computeUserHealthScore( const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); const prevUntil = new Date(now.getTime() - 7 * DAY_MS); - const [weightRows, moodRows, medications] = await Promise.all([ - prisma.measurement.findMany({ - where: { - userId, - type: "WEIGHT", - measuredAt: { gte: prevSince30d, lte: now }, - }, - select: { value: true, measuredAt: true }, - orderBy: { measuredAt: "asc" }, - }), - prisma.moodEntry.findMany({ - where: { - userId, - moodLoggedAt: { gte: prevSince30d, lte: now }, - }, - select: { score: true, moodLoggedAt: true }, - orderBy: { moodLoggedAt: "asc" }, - }), - prisma.medication.findMany({ - where: { userId, active: true }, - select: { - id: true, - createdAt: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, + // v1.4.25 W8e — read the `source` column alongside the value so the + // health-score provenance accordion knows which ingest path drove + // each component. The weight + BP reads already paid the row cost; + // adding the column to the SELECT is free in Postgres terms (no extra + // round-trip, no extra plan node) and the alternative — a second + // aggregate just for the source pill — would burn another findMany. + const [weightRows, bpSysRowsForSource, moodRows, medications] = + await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "WEIGHT", + measuredAt: { gte: prevSince30d, lte: now }, + }, + select: { value: true, measuredAt: true, source: true }, + orderBy: { measuredAt: "asc" }, + }), + // BP source attribution rides on the systolic-readings row set — + // diastolic rows always carry the same `source` because both + // halves of a pair are persisted in the same write. Pull only the + // trailing 30 days to keep the call bounded for power users. + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_PRESSURE_SYS", + measuredAt: { gte: since30d, lte: now }, + }, + select: { measuredAt: true, source: true }, + orderBy: { measuredAt: "asc" }, + }), + prisma.moodEntry.findMany({ + where: { + userId, + moodLoggedAt: { gte: prevSince30d, lte: now }, + }, + select: { score: true, moodLoggedAt: true }, + orderBy: { moodLoggedAt: "asc" }, + }), + prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + createdAt: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + }, }, }, - }, - }), - ]); + }), + ]); // Compliance30 per active medication, then again for the prior-week // snapshot. The compliance helper anchors on `Date.now()` internally; @@ -769,12 +834,62 @@ async function computeUserHealthScore( return null; } + // v1.4.25 W8e — build per-component source attribution from the rows + // we already hold in memory. `mapMeasurementSourceToLabel` collapses + // the persisted `MeasurementSource` enum onto the camelCase token list + // the analytics helper consumes; `IMPORT` rides under `manual` + // because the v1.4.20 CSV importer ingests user-supplied data — it's + // not a wearable stream. + const windowEndAt = now.toISOString(); + + const weightSourcesIn30d = uniqueComponentSources( + weightRows + .filter((r) => r.measuredAt >= since30d) + .map((r) => r.source), + ); + const latestWeightInWindow = weightRows + .filter((r) => r.measuredAt >= since30d) + .at(-1); + + const bpSourceTokens = uniqueComponentSources( + bpSysRowsForSource.map((r) => r.source), + ); + const latestBpInWindow = bpSysRowsForSource.at(-1); + + // Mood doesn't yet have a non-manual ingest (v1.5 will introduce + // Apple Health mood) so the source list is always `["manual"]` when + // there are entries in window. Keep the lookup explicit so the + // v1.5 ingest path slot just drops in. + const moodSourceTokens = moodSeriesLast30d.length > 0 + ? (["manual"] as const) + : []; + const latestMoodInWindow = moodRows + .filter((r) => r.moodLoggedAt >= since30d) + .at(-1); + + // Medication compliance always derives from logged intake events — + // user-driven manual logging today. + const complianceSourceTokens = medicationCompliance30.length > 0 + ? (["manual"] as const) + : []; + const current: HealthScoreInput = { bpInTargetRate: input.bpInTargetPct, weightSeriesLast30d, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesLast30d, medicationCompliance30, + attribution: { + bpSources: bpSourceTokens, + asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null, + weightSources: weightSourcesIn30d, + asOfWeight: latestWeightInWindow?.measuredAt.toISOString() ?? null, + moodSources: moodSourceTokens, + asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null, + complianceSources: complianceSourceTokens, + asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null, + windowEndAt, + }, }; // The all-time `bpInTargetPct` is a slow-moving aggregate and would // need a full historical re-pair to "rewind" by a week. We pass the diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts index dd335907a..c4da7181e 100644 --- a/src/lib/analytics/__tests__/health-score.test.ts +++ b/src/lib/analytics/__tests__/health-score.test.ts @@ -313,3 +313,135 @@ describe("computeHealthScore — delta vs previous week", () => { expect(result.delta!).toBeLessThan(0); }); }); + +// ── v1.4.25 W8e — per-component source attribution ────────────────── + +describe("computeHealthScore — source attribution", () => { + const WINDOW_END = "2026-05-14T12:00:00.000Z"; + const BP_LATEST = "2026-05-14T08:00:00.000Z"; + const WEIGHT_LATEST = "2026-05-13T18:00:00.000Z"; + const MOOD_LATEST = "2026-05-14T07:30:00.000Z"; + + function baseInputWithAttribution( + attribution: HealthScoreInput["attribution"], + ): HealthScoreInput { + return { + bpInTargetRate: 90, + weightSeriesLast30d: weightSeries([80, 80, 80]), + weightTargetKg: 80, + moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]), + medicationCompliance30: [95, 100], + attribution, + }; + } + + it("collapses a single-source component to that source label", () => { + const result = computeHealthScore( + baseInputWithAttribution({ + bpSources: ["withings"], + asOfBp: BP_LATEST, + weightSources: ["withings"], + asOfWeight: WEIGHT_LATEST, + moodSources: ["manual"], + asOfMood: MOOD_LATEST, + complianceSources: ["manual"], + asOfCompliance: WINDOW_END, + windowEndAt: WINDOW_END, + }), + ); + + expect(result.components.bp.source).toBe("withings"); + expect(result.components.bp.asOf).toBe(BP_LATEST); + expect(result.components.weight.source).toBe("withings"); + expect(result.components.weight.asOf).toBe(WEIGHT_LATEST); + expect(result.components.mood.source).toBe("manual"); + expect(result.components.mood.asOf).toBe(MOOD_LATEST); + expect(result.components.compliance.source).toBe("manual"); + }); + + it("marks a component `mixed` when multiple sources contribute", () => { + const result = computeHealthScore( + baseInputWithAttribution({ + bpSources: ["withings", "manual"], + asOfBp: BP_LATEST, + weightSources: ["withings", "appleHealth"], + asOfWeight: WEIGHT_LATEST, + moodSources: ["manual"], + asOfMood: MOOD_LATEST, + complianceSources: ["manual"], + asOfCompliance: WINDOW_END, + windowEndAt: WINDOW_END, + }), + ); + + expect(result.components.bp.source).toBe("mixed"); + expect(result.components.weight.source).toBe("mixed"); + // Single-source components still resolve to their token. + expect(result.components.mood.source).toBe("manual"); + }); + + it("falls through to `none` + window-end asOf when a component has no value", () => { + const input: HealthScoreInput = { + bpInTargetRate: null, + weightSeriesLast30d: [], + weightTargetKg: null, + // <5 mood entries → moodStability returns null. + moodEntriesLast30d: moodEntries([4, 4]), + medicationCompliance30: [], + attribution: { + bpSources: [], + asOfBp: null, + weightSources: [], + asOfWeight: null, + moodSources: [], + asOfMood: null, + complianceSources: [], + asOfCompliance: null, + windowEndAt: WINDOW_END, + }, + }; + const result = computeHealthScore(input); + expect(result.components.bp.source).toBe("none"); + expect(result.components.bp.asOf).toBe(WINDOW_END); + expect(result.components.weight.source).toBe("none"); + expect(result.components.mood.source).toBe("none"); + expect(result.components.compliance.source).toBe("none"); + }); + + it("backward-compat: omitted attribution defaults present components to `manual`", () => { + const result = computeHealthScore({ + bpInTargetRate: 80, + weightSeriesLast30d: weightSeries([80, 80, 80]), + weightTargetKg: 80, + moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), + medicationCompliance30: [100], + }); + // No `attribution` supplied — the legacy contract had no source + // field, so the helper defaults present components to "manual". + expect(result.components.bp.source).toBe("manual"); + expect(result.components.weight.source).toBe("manual"); + expect(result.components.mood.source).toBe("manual"); + expect(result.components.compliance.source).toBe("manual"); + // The asOf falls back to a wall-clock ISO string — just verify the + // type/shape, not the exact value. + expect(typeof result.components.bp.asOf).toBe("string"); + }); + + it("falls back to window-end asOf when a present component has no asOf supplied", () => { + const result = computeHealthScore({ + bpInTargetRate: 90, + weightSeriesLast30d: weightSeries([80, 80, 80]), + weightTargetKg: 80, + moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), + medicationCompliance30: [100], + attribution: { + bpSources: ["withings"], + asOfBp: null, + windowEndAt: WINDOW_END, + }, + }); + expect(result.components.bp.source).toBe("withings"); + // No asOf supplied → fall back to window end. + expect(result.components.bp.asOf).toBe(WINDOW_END); + }); +}); diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index 731fa2c44..c55ba06ba 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -30,6 +30,50 @@ export type HealthScoreBand = "green" | "yellow" | "red"; +/** + * v1.4.25 W8e — per-component source attribution. + * + * `manual` / `withings` / `appleHealth` map onto the `MeasurementSource` + * enum already persisted on the underlying rows. `mixed` is the + * aggregate label when the component blends entries from more than one + * source. `none` is the explicit empty-state — no measurement contributed. + * + * The token list is a UI-facing camelCase view of the persisted + * `MeasurementSource` enum so the i18n keys (`provenance.sources.*`) + * stay locale-stable even if the storage enum grows. + */ +export type HealthScoreComponentSource = + | "manual" + | "withings" + | "appleHealth" + | "mixed" + | "none"; + +/** + * v1.4.25 W8e — per-component source attribution as it arrives from the + * Prisma layer (the route maps Withings/Apple-Health/Manual rows for + * each pillar and forwards the contributing source set plus the + * freshest timestamp). `complianceSources` is `null` when no medications + * are active so the redistribution helper keeps treating compliance as + * "no signal" rather than "manual with zero entries". + */ +export interface HealthScoreSourceAttribution { + /** Sources that contributed to the BP-in-target rate. */ + bpSources?: ReadonlyArray<"manual" | "withings" | "appleHealth">; + asOfBp?: string | null; + /** Sources that contributed to the weight series. */ + weightSources?: ReadonlyArray<"manual" | "withings" | "appleHealth">; + asOfWeight?: string | null; + /** Sources that contributed to the mood entries. */ + moodSources?: ReadonlyArray<"manual" | "withings" | "appleHealth">; + asOfMood?: string | null; + /** Sources that contributed to medication compliance. */ + complianceSources?: ReadonlyArray<"manual" | "withings" | "appleHealth">; + asOfCompliance?: string | null; + /** Wall-clock anchor for the as-of-window-end fallback. */ + windowEndAt?: string; +} + export interface HealthScoreInput { /** All-time BP-in-target rate, already 0..100 (or null). */ bpInTargetRate: number | null; @@ -41,6 +85,13 @@ export interface HealthScoreInput { moodEntriesLast30d: Array<{ date: string; score: number }>; /** Per-active-medication 30-day compliance %. Empty array → null. */ medicationCompliance30: number[]; + /** + * v1.4.25 W8e — optional source attribution. When omitted the + * resulting `HealthScoreComponentDetail.source` falls back to `none` + * for null components and `manual` for present ones (the + * pre-v1.4.25 implicit default). + */ + attribution?: HealthScoreSourceAttribution; } export interface HealthScoreComponentDetail { @@ -48,6 +99,17 @@ export interface HealthScoreComponentDetail { value: number | null; /** Effective weight after null-redistribution, 0..1. */ weight: number; + /** + * v1.4.25 W8e — which ingest path produced the underlying + * measurement(s) for this component. `mixed` when two or more + * sources contributed; `none` when the value is null. + */ + source: HealthScoreComponentSource; + /** + * ISO timestamp of the most recent contributing measurement, or + * the as-of-window-end when no measurement contributed. + */ + asOf: string; } export interface HealthScoreResult { @@ -220,12 +282,35 @@ export function computeHealthScore( const complianceValue = complianceRate(input.medicationCompliance30); - const components = redistribute({ - bp: bpValue, - weight: weightValue, - mood: moodValue, - compliance: complianceValue, - }); + const attribution = input.attribution ?? {}; + const windowEndAt = attribution.windowEndAt ?? new Date().toISOString(); + const components = redistribute( + { + bp: bpValue, + weight: weightValue, + mood: moodValue, + compliance: complianceValue, + }, + { + bp: { + sources: attribution.bpSources ?? null, + asOf: attribution.asOfBp ?? null, + }, + weight: { + sources: attribution.weightSources ?? null, + asOf: attribution.asOfWeight ?? null, + }, + mood: { + sources: attribution.moodSources ?? null, + asOf: attribution.asOfMood ?? null, + }, + compliance: { + sources: attribution.complianceSources ?? null, + asOf: attribution.asOfCompliance ?? null, + }, + }, + windowEndAt, + ); let raw = 0; for (const key of ["bp", "weight", "mood", "compliance"] as const) { @@ -244,17 +329,52 @@ export function computeHealthScore( return { score, band, components, delta }; } +/** + * Collapse a list of contributing source tokens into the single label + * the UI renders. `null`/empty when the underlying value is missing + * → `none`. Two or more distinct tokens → `mixed`. Exactly one token + * → that token. Pre-v1.4.25 inputs (no attribution supplied) default + * to `manual` for present values so the contract stays backward-compat + * — the manual-entry path has been HealthLog's only ingest since + * v1.0. + */ +function resolveSourceLabel( + hasValue: boolean, + sources: ReadonlyArray<"manual" | "withings" | "appleHealth"> | null, +): HealthScoreComponentSource { + if (!hasValue) return "none"; + if (!sources || sources.length === 0) return "manual"; + const unique = Array.from(new Set(sources)); + if (unique.length === 1) return unique[0]; + return "mixed"; +} + /** * Re-scale the four base weights to skip any component whose value is * null. The remaining components keep the same proportional ratio * (e.g. BP and Compliance both at 30 % of original 80 % → 37.5 %). + * + * v1.4.25 W8e — the helper also folds the per-component + * `HealthScoreSourceAttribution` slice into each output detail so the + * client can render the provenance accordion without a second pass over + * the analytics payload. */ -function redistribute(values: { - bp: number | null; - weight: number | null; - mood: number | null; - compliance: number | null; -}): HealthScoreResult["components"] { +function redistribute( + values: { + bp: number | null; + weight: number | null; + mood: number | null; + compliance: number | null; + }, + attribution: Record< + keyof typeof BASE_WEIGHTS, + { + sources: ReadonlyArray<"manual" | "withings" | "appleHealth"> | null; + asOf: string | null; + } + >, + windowEndAt: string, +): HealthScoreResult["components"] { const present: Array = []; for (const key of ["bp", "weight", "mood", "compliance"] as const) { if (values[key] !== null) present.push(key); @@ -265,14 +385,28 @@ function redistribute(values: { if (totalBaseWeight === 0) return 0; return BASE_WEIGHTS[key] / totalBaseWeight; }; + const detailFor = ( + key: keyof typeof BASE_WEIGHTS, + ): HealthScoreComponentDetail => { + const value = values[key]; + const attr = attribution[key]; + const source = resolveSourceLabel(value !== null, attr.sources); + // When a component is `none`, fall back to the window-end anchor so + // the UI can still render "as of " without inventing a date. + const asOf = + source === "none" ? windowEndAt : (attr.asOf ?? windowEndAt); + return { + value, + weight: weightFor(key), + source, + asOf, + }; + }; return { - bp: { value: values.bp, weight: weightFor("bp") }, - weight: { value: values.weight, weight: weightFor("weight") }, - mood: { value: values.mood, weight: weightFor("mood") }, - compliance: { - value: values.compliance, - weight: weightFor("compliance"), - }, + bp: detailFor("bp"), + weight: detailFor("weight"), + mood: detailFor("mood"), + compliance: detailFor("compliance"), }; }