mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Make WHOOP-native recovery the canonical read
RECOVERY_SCORE is written by two sources for the same day: the WHOOP-native percentage from the device sync and the COMPUTED proxy from the nightly readiness job. The wellness reader hard-filtered to source COMPUTED, silently dropping every ingested native row, and the doctor PDF blended both sources into one min/avg/max. The COMPUTED proxy also equals the readiness blend verbatim, so the Coach prompt carried the same number under both READINESS and RECOVERY_SCORE. Add resolveCanonicalRecovery: per day, keep the WHOOP row when present, else the COMPUTED proxy. Drop the COMPUTED-only filter for RECOVERY in the wellness reader and resolve to the canonical row; STRESS and STRAIN stay COMPUTED-only. Resolve the doctor PDF wellness summary the same way so the report reads the native value, not a proxy/native blend. Dedup the Coach snapshot: omit the recovery line when it mirrors readiness (the no-native case), keep it when a WHOOP-native value differs. Document the WHOOP-over-COMPUTED resolution on the sync delta feed, which already mirrors each row with its source so the client resolves one canonical series rather than charting two. A WHOOP-connected user's recovery tile, chart, and PDF flip from the proxy to the native value.
This commit is contained in:
1 parent
b4b9d8d9d7
commit
eba01b37ce
9 files changed
+360
-11
No files matched your search
@@ -319,6 +319,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
|
||||
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
|
||||
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
|
||||
// canonical recovery series — WHOOP wins when present, COMPUTED is the
|
||||
// fallback — and never chart the proxy and the native value as two
|
||||
// competing series. The feed faithfully mirrors both rows (a delta-sync
|
||||
// mirror cannot drop a row mid-keyset without a tombstone); the
|
||||
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
|
||||
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
|
||||
measurementUpserts.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summariseCanonicalRecovery } from "@/lib/doctor-report-data";
|
||||
|
||||
function m(type: string, value: number, iso: string, source: string) {
|
||||
return { type, value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
describe("summariseCanonicalRecovery", () => {
|
||||
it("returns null when no recovery rows exist", () => {
|
||||
expect(
|
||||
summariseCanonicalRecovery([
|
||||
m("STRESS_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy per day", () => {
|
||||
// The same day has a low proxy AND a high native row. The PDF must read the
|
||||
// native value, not blend both into one average.
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 50, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
]);
|
||||
expect(summary).not.toBeNull();
|
||||
// Only the canonical (WHOOP) row counts — avg/min/max/latest all 80, not 65.
|
||||
expect(summary!.latest).toBe(80);
|
||||
expect(summary!.avg).toBe(80);
|
||||
expect(summary!.min).toBe(80);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.count).toBe(1);
|
||||
});
|
||||
|
||||
it("summarises across days using one canonical row each", () => {
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 60, "2026-06-02T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
m("RECOVERY_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
]);
|
||||
expect(summary!.count).toBe(2);
|
||||
// Canonical set: 06-02 COMPUTED 60, 06-01 WHOOP 80 (proxy 40 dropped).
|
||||
expect(summary!.latest).toBe(60);
|
||||
expect(summary!.min).toBe(60);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.avg).toBe(70);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,32 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops RECOVERY_SCORE when it mirrors READINESS (COMPUTED proxy, no native)", async () => {
|
||||
// With no WHOOP-native recovery, the COMPUTED proxy IS the readiness blend
|
||||
// verbatim — feeding both labels hands the model the same number twice.
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.READINESS).toBeDefined();
|
||||
// The redundant proxy is removed — readiness once, not twice.
|
||||
expect(block!.RECOVERY_SCORE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps RECOVERY_SCORE when the WHOOP-native value differs from READINESS", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS?.value).toBe(64);
|
||||
expect(block!.RECOVERY_SCORE?.value).toBe(80);
|
||||
});
|
||||
|
||||
it("isolates a per-metric compute failure", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") throw new Error("boom");
|
||||
|
||||
@@ -124,5 +124,18 @@ export async function buildDerivedSnapshotBlock(
|
||||
};
|
||||
}
|
||||
|
||||
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
|
||||
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
|
||||
// recovery equals readiness — feeding both labels would hand the model the
|
||||
// identical score under two names. Drop the redundant recovery line in that
|
||||
// case; a WHOOP-native recovery resolves to a DIFFERENT number and both stay.
|
||||
if (
|
||||
block.READINESS !== undefined &&
|
||||
block.RECOVERY_SCORE !== undefined &&
|
||||
block.RECOVERY_SCORE.value === block.READINESS.value
|
||||
) {
|
||||
delete block.RECOVERY_SCORE;
|
||||
}
|
||||
|
||||
return Object.keys(block).length > 0 ? block : null;
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
thresholdMetricForContext,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
} from "@/generated/prisma/client";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
type DoctorReportPrefs,
|
||||
@@ -221,6 +225,59 @@ export const WELLNESS_SCORE_REPORT_TYPES = [
|
||||
"STRAIN_SCORE",
|
||||
] as const;
|
||||
|
||||
/** One wellness-summary row (latest + range over the report window). */
|
||||
interface WellnessScoreSummary {
|
||||
type: string;
|
||||
latest: number;
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
}
|
||||
|
||||
/** A measurement row as the doctor-report aggregator reads it (subset). */
|
||||
interface RecoveryMeasurementRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
|
||||
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
|
||||
* doctor PDF never blends the proxy and the native value into one min/avg/max.
|
||||
* Returns null when no recovery row exists. Exported for unit tests.
|
||||
*/
|
||||
export function summariseCanonicalRecovery(
|
||||
measurements: readonly RecoveryMeasurementRow[],
|
||||
): WellnessScoreSummary | null {
|
||||
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
|
||||
if (recovery.length === 0) return null;
|
||||
const canonical = resolveCanonicalRecovery(
|
||||
recovery.map((m) => ({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
source: m.source,
|
||||
})),
|
||||
);
|
||||
if (canonical.length === 0) return null;
|
||||
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
|
||||
// latest value + the window range over the canonical set.
|
||||
const values = canonical.map((r) => r.value);
|
||||
const latestRow = canonical[0];
|
||||
return {
|
||||
type: "RECOVERY_SCORE",
|
||||
latest: Math.round(latestRow.value),
|
||||
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
|
||||
min: Math.round(Math.min(...values)),
|
||||
max: Math.round(Math.max(...values)),
|
||||
count: values.length,
|
||||
latestAt: latestRow.measuredAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
@@ -927,12 +984,21 @@ export async function collectDoctorReportData(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE`
|
||||
// rows are already in `byType`/`stats` (they're only filtered out of the
|
||||
// clinical vitals table). Summarise each present score type for the
|
||||
// separate "Wellness summary" section. Empty array → null so the renderer
|
||||
// + FHIR builder skip the section entirely.
|
||||
// v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
|
||||
// in `byType`/`stats` (they're only filtered out of the clinical vitals
|
||||
// table). Summarise each present score type for the separate "Wellness
|
||||
// summary" section. Empty array → null so the renderer + FHIR builder skip
|
||||
// the section entirely.
|
||||
//
|
||||
// RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
|
||||
// the same day; mixing them into one min/avg/max would blend two distinct
|
||||
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
|
||||
// the SAME value the tile + iOS feed show — one number, one engine.
|
||||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||||
if (type === "RECOVERY_SCORE") {
|
||||
const summary = summariseCanonicalRecovery(measurements);
|
||||
return summary ? [summary] : [];
|
||||
}
|
||||
const s = stats[type];
|
||||
const rows = byType[type];
|
||||
if (!s || !rows || rows.length === 0) return [];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveCanonicalRecovery,
|
||||
type RecoveryRow,
|
||||
} from "../recovery-resolve";
|
||||
|
||||
function row(iso: string, value: number, source: string): RecoveryRow {
|
||||
return { value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
describe("resolveCanonicalRecovery", () => {
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy for the same day", () => {
|
||||
const rows = [
|
||||
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 80, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("WHOOP");
|
||||
expect(resolved[0].value).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to COMPUTED when no WHOOP row exists for the day", () => {
|
||||
const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(55);
|
||||
});
|
||||
|
||||
it("resolves per day — WHOOP one day, COMPUTED another", () => {
|
||||
const rows = [
|
||||
row("2026-06-02T12:00:00Z", 51, "COMPUTED"),
|
||||
row("2026-06-01T12:00:00Z", 49, "COMPUTED"),
|
||||
row("2026-06-01T06:00:00Z", 70, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
// Sorted desc by measuredAt: 06-02 COMPUTED first, then 06-01 WHOOP.
|
||||
expect(resolved).toHaveLength(2);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(51);
|
||||
expect(resolved[1].source).toBe("WHOOP");
|
||||
expect(resolved[1].value).toBe(70);
|
||||
});
|
||||
|
||||
it("keeps the latest same-source row within a day", () => {
|
||||
const rows = [
|
||||
row("2026-06-01T06:00:00Z", 60, "WHOOP"),
|
||||
row("2026-06-01T18:00:00Z", 75, "WHOOP"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].value).toBe(75);
|
||||
});
|
||||
|
||||
it("returns an empty list for no rows", () => {
|
||||
expect(resolveCanonicalRecovery([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe("computeWellnessScore", () => {
|
||||
|
||||
it("RECOVERY does not read the strain cache and carries a null anchor", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") },
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -133,4 +133,33 @@ describe("computeWellnessScore", () => {
|
||||
expect((r.value as WellnessScoreValue).anchor).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => {
|
||||
// Same day written by both the COMPUTED proxy AND the WHOOP-native sync.
|
||||
// The native row is canonical → the tile shows 80, not the proxy's 50.
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 50, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
{ value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
});
|
||||
// The read must NOT hard-filter to COMPUTED — both sources reach the resolver.
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBeUndefined();
|
||||
expect(r.status).toBe("ok");
|
||||
if (r.status === "ok") {
|
||||
expect((r.value as WellnessScoreValue).score).toBe(80);
|
||||
expect((r.value as WellnessScoreValue).band).toBe("green");
|
||||
}
|
||||
});
|
||||
|
||||
it("STRESS still hard-filters to the COMPUTED source", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 30, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { now: NOW });
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBe("COMPUTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* v1.16.16 — canonical recovery resolution (one number, one engine).
|
||||
*
|
||||
* `RECOVERY_SCORE` is written by TWO sources that mean different things for the
|
||||
* same day:
|
||||
* - `WHOOP` — the device's own native recovery percentage (ground truth
|
||||
* when the user wears a WHOOP strap), ingested by the sync.
|
||||
* - `COMPUTED` — the server-derived proxy (the READINESS blend persisted by
|
||||
* the nightly `recovery-score` job) — the fallback for a user
|
||||
* with no native recovery signal.
|
||||
*
|
||||
* The DECISION (locked): the WHOOP-native row is canonical WHEN PRESENT; the
|
||||
* COMPUTED proxy is the fallback. Every read surface — the wellness tile, the
|
||||
* doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so
|
||||
* none of them shows the proxy and the native value as two competing series.
|
||||
*
|
||||
* Pure resolution: group rows by their calendar day and, per day, keep the
|
||||
* WHOOP row when one exists, else the COMPUTED one. No DB access here — the
|
||||
* caller does the bounded read and passes rows in.
|
||||
*/
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
/** A `RECOVERY_SCORE` row as read for resolution. */
|
||||
export interface RecoveryRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks
|
||||
* the COMPUTED proxy for the same day. Lower number = higher authority. A
|
||||
* source not listed (there is none today) falls back below both.
|
||||
*/
|
||||
const RECOVERY_SOURCE_RANK: Partial<Record<MeasurementSource, number>> = {
|
||||
WHOOP: 0,
|
||||
COMPUTED: 1,
|
||||
};
|
||||
|
||||
function rankOf(source: MeasurementSource): number {
|
||||
return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/** The UTC calendar-day key a recovery row is filed under. */
|
||||
function dayKeyOf(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per day:
|
||||
* the WHOOP-native row wins when present, else the COMPUTED proxy. Within a
|
||||
* source the latest `measuredAt` wins (a same-day re-score). Returns the
|
||||
* canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the
|
||||
* first element is the most recent canonical day — matching the order the
|
||||
* wellness reader already expects.
|
||||
*/
|
||||
export function resolveCanonicalRecovery(rows: RecoveryRow[]): RecoveryRow[] {
|
||||
const byDay = new Map<string, RecoveryRow>();
|
||||
for (const row of rows) {
|
||||
const key = dayKeyOf(row.measuredAt);
|
||||
const incumbent = byDay.get(key);
|
||||
if (incumbent === undefined) {
|
||||
byDay.set(key, row);
|
||||
continue;
|
||||
}
|
||||
const rowRank = rankOf(row.source);
|
||||
const incumbentRank = rankOf(incumbent.source);
|
||||
const better =
|
||||
rowRank < incumbentRank ||
|
||||
(rowRank === incumbentRank &&
|
||||
row.measuredAt.getTime() > incumbent.measuredAt.getTime());
|
||||
if (better) byDay.set(key, row);
|
||||
}
|
||||
return [...byDay.values()].sort(
|
||||
(a, b) => b.measuredAt.getTime() - a.measuredAt.getTime(),
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,14 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
|
||||
import type { BaselineProfile } from "./baseline";
|
||||
import type { StrainAnchor } from "@/lib/insights/strain-score";
|
||||
import { resolveCanonicalRecovery } from "./recovery-resolve";
|
||||
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
|
||||
|
||||
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
|
||||
@@ -106,18 +110,38 @@ export async function computeWellnessScore(
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType;
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
// RECOVERY_SCORE is written by TWO sources for the same day — the WHOOP
|
||||
// native percentage and the COMPUTED proxy. The native row is canonical when
|
||||
// present, so the read must NOT hard-filter to COMPUTED (that silently drops
|
||||
// every ingested native row); read both sources and resolve per day below.
|
||||
// STRESS / STRAIN are COMPUTED-only, so they keep the source filter.
|
||||
const isRecovery = type === "RECOVERY_SCORE";
|
||||
const rawRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: measurementType,
|
||||
source: "COMPUTED",
|
||||
...(isRecovery ? {} : { source: "COMPUTED" as MeasurementSource }),
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: cutoff, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
|
||||
// Collapse a mixed-source recovery set to ONE canonical row per day (WHOOP
|
||||
// wins over COMPUTED). Non-recovery sets are already single-source, so the
|
||||
// resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the
|
||||
// SAME canonical value.
|
||||
const rows = isRecovery
|
||||
? resolveCanonicalRecovery(
|
||||
rawRows.map((r) => ({
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
source: r.source,
|
||||
})),
|
||||
)
|
||||
: rawRows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return buildInsufficient<WellnessScoreValue>({
|
||||
coverage: {
|
||||
|
||||
Reference in new issue
Block a user