mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
7 files changed
+207
-17
No files matched your search
@@ -2,6 +2,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.12.5] — 2026-06-05 — correct sleep summary and readiness contributors
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The sleep summary now reflects the whole night, not a single stage.** The Insights sleep figure and prose were reading one sleep stage (~16 minutes) instead of the reconstructed night total; they now use the night's time-asleep, the same number the dashboard and charts already show.
|
||||
- **Readiness contributors no longer read a misleading 0.** Resting heart rate, heart-rate variability, and respiratory rate are hidden (rather than shown as 0) when there isn't yet enough history to establish a baseline; a genuine low sub-score still shows.
|
||||
|
||||
## [1.12.4] — 2026-06-05 — a calmer, more consistent Insights
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.12.4
|
||||
version: 1.12.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.12.4",
|
||||
"version": "1.12.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -22,7 +22,20 @@ vi.mock("@/lib/insights/metric-correlation-context", () => ({
|
||||
getRelevantCorrelationsForMetric: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/tz/resolver")>(
|
||||
"@/lib/tz/resolver",
|
||||
);
|
||||
return { ...actual, resolveUserTimezone: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
|
||||
import { formatPreviousContextForPrompt } from "@/lib/insights/memory";
|
||||
@@ -68,6 +81,60 @@ beforeEach(() => {
|
||||
// are no-ops unless a test overrides them.
|
||||
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValue([]);
|
||||
vi.mocked(formatPreviousContextForPrompt).mockReturnValue("");
|
||||
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
|
||||
vi.mocked(loadUserSourcePriority).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => {
|
||||
it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date("2026-06-04T07:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
// One overnight session of granular stages, each row ONE stage (minutes).
|
||||
// Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h).
|
||||
// A single stage (DEEP, 90 min) is the largest, and an old per-row read
|
||||
// would have surfaced the LATEST stage (~75 min REM) as "current sleep".
|
||||
const wake = new Date("2026-06-04T06:30:00.000Z");
|
||||
const m = 60 * 1000;
|
||||
const stageRows = [
|
||||
{ value: 300, measuredAt: new Date(wake.getTime() - 90 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
|
||||
{ value: 90, measuredAt: new Date(wake.getTime() - 30 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
|
||||
{ value: 75, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
|
||||
];
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(
|
||||
stageRows.length as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
stageRows as never,
|
||||
);
|
||||
|
||||
const capture = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion("Summary: solide Nacht.", capture);
|
||||
|
||||
const res = await generateMetricStatus({
|
||||
metric: "SLEEP_DURATION",
|
||||
userId: "u1",
|
||||
locale: "de",
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(res.text).toBeTruthy();
|
||||
expect(capture.userPrompt).toBeTruthy();
|
||||
const prompt = capture.userPrompt as string;
|
||||
// The snapshot must carry the NIGHT total (465 min), never a lone stage.
|
||||
expect(prompt).toContain("465");
|
||||
expect(prompt).not.toContain('"value": 75');
|
||||
expect(prompt).not.toContain('"value": 90');
|
||||
});
|
||||
});
|
||||
|
||||
describe("metric-status registry", () => {
|
||||
|
||||
@@ -168,6 +168,52 @@ describe("computeReadiness gating", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("emits null (not 0) for a vital with readings below the baseline floor (iOS F2)", async () => {
|
||||
// The user HAS recorded RHR + HRV + respiratory, but only on 2 distinct
|
||||
// days each — below the baseline-band history floor. The contributor must
|
||||
// be NULL (dropped → iOS hides it), never 0 (which would read as a real
|
||||
// worst-case sub-score). Mood + sleep carry the headline so the blend
|
||||
// still reaches the minimum-components floor.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const sparse = (type: string, base: number) =>
|
||||
Array.from({ length: 2 }, (_, i) => ({
|
||||
value: base,
|
||||
measuredAt: new Date(NOW.getTime() - (i + 1) * DAY_MS),
|
||||
type,
|
||||
}));
|
||||
moodFindMany.mockResolvedValue(
|
||||
Array.from({ length: 8 }, (_, i) => ({
|
||||
score: 4,
|
||||
moodLoggedAt: new Date(`2026-05-${String(20 + i).padStart(2, "0")}T20:00:00Z`),
|
||||
})),
|
||||
);
|
||||
measurementFindMany.mockImplementation(
|
||||
async (args: { where: { type: string } }) => {
|
||||
const type = args.where.type;
|
||||
if (type === "RESTING_HEART_RATE") return sparse(type, 58);
|
||||
if (type === "HEART_RATE_VARIABILITY") return sparse(type, 65);
|
||||
if (type === "RESPIRATORY_RATE") return sparse(type, 14);
|
||||
if (type === "SLEEP_DURATION") {
|
||||
return [
|
||||
{ value: 420, measuredAt: new Date("2026-05-31T06:00:00Z"), sleepStage: "ASLEEP" },
|
||||
{ value: 430, measuredAt: new Date("2026-06-01T06:10:00Z"), sleepStage: "ASLEEP" },
|
||||
{ value: 410, measuredAt: new Date("2026-06-02T06:05:00Z"), sleepStage: "ASLEEP" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
const result = await computeReadiness("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
for (const key of ["rhr", "hrv", "respiratory"] as const) {
|
||||
const c = result.value.components.find((x) => x.key === key)!;
|
||||
expect(c.value).toBeNull();
|
||||
expect(c.value).not.toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("provenance source is 'live' for a blend backed only by sleep + mood", async () => {
|
||||
// No vital readings (RHR/HRV/resp all gate). Mood entries present + a
|
||||
// scorable sleep night → the two present components are both live reads,
|
||||
|
||||
@@ -194,8 +194,19 @@ async function readLatestDayMean(
|
||||
|
||||
/**
|
||||
* Score one baseline-deviation vital component (RHR / HRV / respiratory).
|
||||
* Returns null when the baseline is insufficient or there is no recent
|
||||
* reading — the component then drops from the blend.
|
||||
*
|
||||
* null-vs-0 contract (iOS v0.14.3 F2)
|
||||
* -----------------------------------
|
||||
* A contributor is `null` ONLY when there is no usable signal: the baseline
|
||||
* is not established (insufficient history / band failed), there is no recent
|
||||
* reading, or the inputs are non-finite. A `null` contributor drops from the
|
||||
* blend and the client hides it. A FINITE number — including a genuine `0` —
|
||||
* means the baseline IS established and the latest reading really does deviate
|
||||
* that far (≥ 2 spreads in the unfavourable direction maps to 0; see
|
||||
* `scoreDeviation`). We deliberately never emit `0` as a "missing data"
|
||||
* sentinel: the previous behaviour of letting a non-finite `today`/`center`
|
||||
* fall through `clamp100` could surface a misleading 0 for an account that had
|
||||
* the metric but no scorable baseline — that case is now `null`.
|
||||
*/
|
||||
async function scoreVitalDeviation(
|
||||
userId: string,
|
||||
@@ -225,7 +236,15 @@ async function scoreVitalDeviation(
|
||||
};
|
||||
}
|
||||
const today = await readLatestDayMean(userId, type, windowDays, now);
|
||||
if (today == null) {
|
||||
// No recent reading, or a non-finite baseline center / today value: there
|
||||
// is no scorable deviation. Return null (hidden) rather than letting a NaN
|
||||
// fall through `scoreDeviation` → `clamp100`, which would surface a
|
||||
// misleading 0 for an account that actually has the metric (iOS F2).
|
||||
if (
|
||||
today == null ||
|
||||
!Number.isFinite(today) ||
|
||||
!Number.isFinite(baseline.value.center)
|
||||
) {
|
||||
return {
|
||||
value: null,
|
||||
source: baseline.provenance.source,
|
||||
|
||||
@@ -59,6 +59,12 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
@@ -206,19 +212,64 @@ export async function generateMetricStatus(args: {
|
||||
// Bounded raw read for the derived recent stats; the monthly/yearly
|
||||
// tail comes from the rollup tier (full-history fallback on a miss),
|
||||
// exactly as the specialised cards do.
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: { userId: args.userId, type: meta.measurementType, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
select: { value: true, measuredAt: true },
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
//
|
||||
// SLEEP_DURATION is the one type stored ONE ROW PER STAGE per night
|
||||
// (IN_BED / AWAKE / CORE / DEEP / REM, minutes each), so a raw per-row
|
||||
// read would feed the status prose a single ~16 min stage as the
|
||||
// "current sleep" instead of the night total. Route it through
|
||||
// `reconstructSleepNights` (the same helper the dashboard / series /
|
||||
// targets use) so every derived value here — `series`, `summary`,
|
||||
// `latest` — is a per-night TIME-ASLEEP total in minutes (the registry
|
||||
// unit + normalRange are already minutes). The night reconstruction
|
||||
// clusters stages into sessions, dedups multi-source nights via the
|
||||
// user's `sleep` priority ladder, and handles the midnight split. Gated
|
||||
// to SLEEP_DURATION only so no other metric's status path changes.
|
||||
const isSleep = meta.measurementType === "SLEEP_DURATION";
|
||||
|
||||
const points = measurements.map((m) => ({
|
||||
measuredAt: m.measuredAt,
|
||||
value: m.value,
|
||||
}));
|
||||
const points: Array<{ measuredAt: Date; value: number }> = [];
|
||||
let measurements: Array<{ measuredAt: Date }> = [];
|
||||
if (isSleep) {
|
||||
const [tz, priorityJson, sleepRows] = await Promise.all([
|
||||
resolveUserTimezone(args.userId),
|
||||
loadUserSourcePriority(args.userId),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year
|
||||
select: { value: true, measuredAt: true, sleepStage: true, source: true },
|
||||
}),
|
||||
]);
|
||||
const nights = reconstructSleepNights(
|
||||
sleepRows as SleepStageRow[],
|
||||
tz,
|
||||
priorityJson,
|
||||
).filter((n) => n.asleepMinutes > 0);
|
||||
for (const n of nights) {
|
||||
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });
|
||||
}
|
||||
measurements = nights.map((n) => ({ measuredAt: n.measuredAt }));
|
||||
} else {
|
||||
const rows = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
type: meta.measurementType,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365,
|
||||
select: { value: true, measuredAt: true },
|
||||
})
|
||||
.then((r) => r.reverse());
|
||||
for (const m of rows) {
|
||||
points.push({ measuredAt: m.measuredAt, value: m.value });
|
||||
}
|
||||
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
|
||||
}
|
||||
const series = applyPayloadBudget(points, { now });
|
||||
const graded = await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
|
||||
Reference in new issue
Block a user