mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
ed6fd46c5b
commit
86737fd6a5
3 files changed
+430
-49
No files matched your search
+145
-30
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user