feat(health-score): expose per-component source + asOf in provenance detail

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:15:18 +02:00
1 parent ed6fd46c5b
commit 86737fd6a5
3 files changed
+430 -49

No files matched your search

+145 -30
View File
@@ -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<MeasurementSource>,
): 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
@@ -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);
});
});
+153 -19
View File
@@ -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<keyof typeof BASE_WEIGHTS> = [];
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 <today>" 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"),
};
}