mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): align BD-Zielbereich delta math with comparison window
The tile computed `bp30 - bpAll` regardless of which baseline the
user picked under Settings → Dashboard, while rendering a caption
keyed on `comparisonBaseline` ("vs. last month" / "vs. last year").
The math therefore lied to the user. Every other trend tile uses
`tileCompareDelta()` against period-aligned `avg30LastMonth` /
`avg30LastYear` baselines.
Add `priorMonth` (now-60d…now-30d) and `priorYear` (now-395d…
now-365d) windows to `computeBpInTargetWindows()`. Ship the pcts on
the analytics envelope as `bpInTargetPctPriorMonth` /
`bpInTargetPctPriorYear`. Route the dashboard's `bpCompareDelta` to
subtract the prior window matching the user's baseline; fall back
to null for `none`.
Three new bp-in-target unit tests (priorMonth, priorYear, null
windows) and two insights-polish guards (envelope contains the
fields, page no longer subtracts `bpAll`).
Closes Code-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
75ccc01984
commit
ef597bfbbd
5 files changed
+180
-2
No files matched your search
@@ -181,6 +181,40 @@ describe("v1.4.19 A3 — chart-token leak hardening", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta uses period-aligned baseline", () => {
|
||||||
|
/**
|
||||||
|
* Up to v1.4.22 W4 the BD tile computed `bp30 - bpAll`
|
||||||
|
* (last-30-days minus all-time) regardless of which window the user
|
||||||
|
* picked under Settings → Dashboard. The caption still rendered "vs.
|
||||||
|
* last month" / "vs. last year" via `comparison.captionLastMonth` /
|
||||||
|
* `comparison.captionLastYear`, so the user read a sentence whose
|
||||||
|
* numerator was actually a 30d-vs-all-time delta. Every other tile
|
||||||
|
* routes through `tileCompareDelta()` with `summary.avg30LastMonth`
|
||||||
|
* / `summary.avg30LastYear` for honest period-aligned math.
|
||||||
|
*
|
||||||
|
* This test pins that the dashboard now subtracts the prior-period
|
||||||
|
* window (priorMonth / priorYear) instead of all-time.
|
||||||
|
*/
|
||||||
|
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
|
||||||
|
const src = load(DASHBOARD_PATH);
|
||||||
|
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
|
||||||
|
expect(src).toContain("bpInTargetPctPriorMonth");
|
||||||
|
expect(src).toContain("bpInTargetPctPriorYear");
|
||||||
|
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
|
||||||
|
// for the comparison delta any more.
|
||||||
|
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
|
||||||
|
const ROUTE_SRC = readFileSync(
|
||||||
|
join(ROOT, "src/app/api/analytics/route.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth");
|
||||||
|
expect(ROUTE_SRC).toContain("bpInTargetPctPriorYear");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
|
describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
|
||||||
/**
|
/**
|
||||||
* The comparison-overlay toggle is a global preference. Up to v1.4.21
|
* The comparison-overlay toggle is a global preference. Up to v1.4.21
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export const GET = apiHandler(async () => {
|
|||||||
let bpInTargetPct7d: number | null = null;
|
let bpInTargetPct7d: number | null = null;
|
||||||
let bpInTargetPct30d: number | null = null;
|
let bpInTargetPct30d: number | null = null;
|
||||||
let bpInTargetPctAllTime: number | null = null;
|
let bpInTargetPctAllTime: number | null = null;
|
||||||
|
/**
|
||||||
|
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
|
||||||
|
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||||
|
* stops mismatching its math with its label. The tile's
|
||||||
|
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||||
|
* matching the user's `comparisonBaseline` selection, never
|
||||||
|
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||||
|
*/
|
||||||
|
let bpInTargetPctPriorMonth: number | null = null;
|
||||||
|
let bpInTargetPctPriorYear: number | null = null;
|
||||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||||
if (bpTargets) {
|
if (bpTargets) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -99,6 +109,8 @@ export const GET = apiHandler(async () => {
|
|||||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||||
|
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||||
|
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-context glucose summaries (canonical mg/dL).
|
// Per-context glucose summaries (canonical mg/dL).
|
||||||
@@ -153,6 +165,8 @@ export const GET = apiHandler(async () => {
|
|||||||
bpInTargetPct7d,
|
bpInTargetPct7d,
|
||||||
bpInTargetPct30d,
|
bpInTargetPct30d,
|
||||||
bpInTargetPctAllTime,
|
bpInTargetPctAllTime,
|
||||||
|
bpInTargetPctPriorMonth,
|
||||||
|
bpInTargetPctPriorYear,
|
||||||
glucoseByContext,
|
glucoseByContext,
|
||||||
correlations,
|
correlations,
|
||||||
healthScore,
|
healthScore,
|
||||||
|
|||||||
+29
-2
@@ -103,6 +103,15 @@ interface AnalyticsData {
|
|||||||
* on the BD-Zielbereich tile (alongside `7d` and `30d`).
|
* on the BD-Zielbereich tile (alongside `7d` and `30d`).
|
||||||
*/
|
*/
|
||||||
bpInTargetPctAllTime?: number | null;
|
bpInTargetPctAllTime?: number | null;
|
||||||
|
/**
|
||||||
|
* v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window
|
||||||
|
* pcts. The BD-Zielbereich tile's comparison-overlay caption picks
|
||||||
|
* `priorMonth` for `comparisonBaseline === "lastMonth"` and
|
||||||
|
* `priorYear` for `lastYear` so the rendered "Δ X% vs. last month"
|
||||||
|
* stays honest. Null when the prior window has no paired readings.
|
||||||
|
*/
|
||||||
|
bpInTargetPctPriorMonth?: number | null;
|
||||||
|
bpInTargetPctPriorYear?: number | null;
|
||||||
glucoseByContext?: Record<string, DataSummaryType>;
|
glucoseByContext?: Record<string, DataSummaryType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +826,8 @@ export default function DashboardPage() {
|
|||||||
const bp7 = data?.bpInTargetPct7d ?? null;
|
const bp7 = data?.bpInTargetPct7d ?? null;
|
||||||
const bp30 = data?.bpInTargetPct30d ?? null;
|
const bp30 = data?.bpInTargetPct30d ?? null;
|
||||||
const bpAll = data?.bpInTargetPctAllTime ?? null;
|
const bpAll = data?.bpInTargetPctAllTime ?? null;
|
||||||
|
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
|
||||||
|
const bpPriorYear = data?.bpInTargetPctPriorYear ?? null;
|
||||||
const bpTrendDelta =
|
const bpTrendDelta =
|
||||||
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
|
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
|
||||||
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
|
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
|
||||||
@@ -835,10 +846,26 @@ export default function DashboardPage() {
|
|||||||
// mis-interpret a `0` as "very low confidence".
|
// mis-interpret a `0` as "very low confidence".
|
||||||
confidence: 1,
|
confidence: 1,
|
||||||
};
|
};
|
||||||
|
// v1.4.22 W5 reconcile (Code-H2) — match the comparison
|
||||||
|
// window the user picked. `lastMonth` baseline ⇒ compare
|
||||||
|
// last30 against the now-60d…now-30d window (priorMonth);
|
||||||
|
// `lastYear` baseline ⇒ compare against the matching window
|
||||||
|
// shifted back 365 days. The previous shortcut subtracted
|
||||||
|
// `bpAll` regardless of baseline, which produced a number
|
||||||
|
// whose magnitude was honest but whose label ("vs. last
|
||||||
|
// month") lied to the user.
|
||||||
|
const bpComparePrior =
|
||||||
|
compareBaseline === "lastMonth"
|
||||||
|
? bpPriorMonth
|
||||||
|
: compareBaseline === "lastYear"
|
||||||
|
? bpPriorYear
|
||||||
|
: null;
|
||||||
const bpCompareDelta =
|
const bpCompareDelta =
|
||||||
compareBaseline === "none" || bp30 === null || bpAll === null
|
compareBaseline === "none" ||
|
||||||
|
bp30 === null ||
|
||||||
|
bpComparePrior === null
|
||||||
? null
|
? null
|
||||||
: Math.round((bp30 - bpAll) * 10) / 10;
|
: Math.round((bp30 - bpComparePrior) * 10) / 10;
|
||||||
trendCards.push({
|
trendCards.push({
|
||||||
id: "bpInTarget",
|
id: "bpInTarget",
|
||||||
order: widgetOrder("bpInTarget"),
|
order: widgetOrder("bpInTarget"),
|
||||||
|
|||||||
@@ -476,3 +476,63 @@ describe("computeBpInTargetWindows", () => {
|
|||||||
expect(result.last30Days).toEqual({ pct: 50, pairs: 10 });
|
expect(result.last30Days).toEqual({ pct: 50, pairs: 10 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeBpInTargetWindows — period-aligned prior windows (Code-H2)", () => {
|
||||||
|
/**
|
||||||
|
* v1.4.22 W5 reconcile (Code-H2) — the BD-Zielbereich tile's
|
||||||
|
* comparison-overlay caption used to compute `last30Days - allTime`
|
||||||
|
* regardless of the user's `comparisonBaseline` selection. The math
|
||||||
|
* therefore claimed "vs. last month" while subtracting an all-time
|
||||||
|
* average — dishonest by every other tile's standard. The fix is a
|
||||||
|
* pair of period-aligned prior windows shifted back by 30 / 365
|
||||||
|
* days so the tile's `compareDelta` matches its caption.
|
||||||
|
*/
|
||||||
|
const NOW = new Date("2026-05-09T12:00:00Z");
|
||||||
|
|
||||||
|
it("returns priorMonth shifted back by 30 days (now-60d…now-30d)", () => {
|
||||||
|
// 30-day window: only one IN paired reading.
|
||||||
|
// Prior-month window (now-60d…now-30d): two paired readings, one IN one OUT.
|
||||||
|
const sys = [
|
||||||
|
reading("2026-05-01T08:00:00Z", 117), // last30 IN
|
||||||
|
reading("2026-04-08T08:00:00Z", 122), // priorMonth IN (32d ago)
|
||||||
|
reading("2026-03-22T08:00:00Z", 145), // priorMonth OUT (48d ago)
|
||||||
|
reading("2026-02-08T08:00:00Z", 128), // older than 60d — neither window
|
||||||
|
];
|
||||||
|
const dia = [
|
||||||
|
reading("2026-05-01T08:00:00Z", 75),
|
||||||
|
reading("2026-04-08T08:00:00Z", 75),
|
||||||
|
reading("2026-03-22T08:00:00Z", 92),
|
||||||
|
reading("2026-02-08T08:00:00Z", 80),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
|
||||||
|
expect(result.last30Days).toEqual({ pct: 100, pairs: 1 });
|
||||||
|
expect(result.priorMonth).toEqual({ pct: 50, pairs: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns priorYear shifted back by 365 days (now-395d…now-365d)", () => {
|
||||||
|
// Pin one paired reading inside the now-395d…now-365d window and one
|
||||||
|
// outside it.
|
||||||
|
const sys = [
|
||||||
|
reading("2025-05-08T08:00:00Z", 117), // 366d ago — INSIDE priorYear, IN
|
||||||
|
reading("2025-04-30T08:00:00Z", 145), // 374d ago — INSIDE priorYear, OUT
|
||||||
|
reading("2025-04-01T08:00:00Z", 117), // 403d ago — OUTSIDE priorYear (too old)
|
||||||
|
];
|
||||||
|
const dia = [
|
||||||
|
reading("2025-05-08T08:00:00Z", 75),
|
||||||
|
reading("2025-04-30T08:00:00Z", 92),
|
||||||
|
reading("2025-04-01T08:00:00Z", 75),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
|
||||||
|
expect(result.priorYear).toEqual({ pct: 50, pairs: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for prior windows when no readings fall in them", () => {
|
||||||
|
const sys = [reading("2026-05-01T08:00:00Z", 117)]; // only last-30d
|
||||||
|
const dia = [reading("2026-05-01T08:00:00Z", 75)];
|
||||||
|
const result = computeBpInTargetWindows(sys, dia, TARGETS_UNDER_65, NOW);
|
||||||
|
expect(result.priorMonth).toBeNull();
|
||||||
|
expect(result.priorYear).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -221,9 +221,25 @@ export function computeBpInTargetWindows(
|
|||||||
last7Days: { pct: number; pairs: number } | null;
|
last7Days: { pct: number; pairs: number } | null;
|
||||||
last30Days: { pct: number; pairs: number } | null;
|
last30Days: { pct: number; pairs: number } | null;
|
||||||
allTime: { pct: number; pairs: number } | null;
|
allTime: { pct: number; pairs: number } | null;
|
||||||
|
/**
|
||||||
|
* v1.4.22 W5 reconcile (Code-H2) — period-aligned 30-day window
|
||||||
|
* shifted back by one month (now-60d … now-30d). Used by the
|
||||||
|
* BD-Zielbereich tile's comparison-overlay caption when the user's
|
||||||
|
* `comparisonBaseline === "lastMonth"` so the rendered "Δ X% vs.
|
||||||
|
* last month" is honest. Null when the prior window has no pairs.
|
||||||
|
*/
|
||||||
|
priorMonth: { pct: number; pairs: number } | null;
|
||||||
|
/**
|
||||||
|
* v1.4.22 W5 reconcile (Code-H2) — same shape, shifted back one
|
||||||
|
* year (now-395d … now-365d) for `comparisonBaseline === "lastYear"`.
|
||||||
|
*/
|
||||||
|
priorYear: { pct: number; pairs: number } | null;
|
||||||
} {
|
} {
|
||||||
const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS;
|
const sevenDaysAgoMs = now.getTime() - 7 * DAY_MS;
|
||||||
const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS;
|
const thirtyDaysAgoMs = now.getTime() - 30 * DAY_MS;
|
||||||
|
const sixtyDaysAgoMs = now.getTime() - 60 * DAY_MS;
|
||||||
|
const oneYearMinus30DaysAgoMs = now.getTime() - 365 * DAY_MS;
|
||||||
|
const oneYearAgoMs = now.getTime() - 395 * DAY_MS;
|
||||||
|
|
||||||
const sysLast7 = sysSeries.filter(
|
const sysLast7 = sysSeries.filter(
|
||||||
(r) => r.measuredAt.getTime() >= sevenDaysAgoMs,
|
(r) => r.measuredAt.getTime() >= sevenDaysAgoMs,
|
||||||
@@ -238,6 +254,31 @@ export function computeBpInTargetWindows(
|
|||||||
(r) => r.measuredAt.getTime() >= thirtyDaysAgoMs,
|
(r) => r.measuredAt.getTime() >= thirtyDaysAgoMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Period-aligned prior windows. Same 30-day arc, shifted by 30 / 365
|
||||||
|
// days respectively. Bounds are inclusive on the lower end and
|
||||||
|
// exclusive on the upper to avoid double-counting the boundary
|
||||||
|
// reading in both windows.
|
||||||
|
const sysPriorMonth = sysSeries.filter(
|
||||||
|
(r) =>
|
||||||
|
r.measuredAt.getTime() >= sixtyDaysAgoMs &&
|
||||||
|
r.measuredAt.getTime() < thirtyDaysAgoMs,
|
||||||
|
);
|
||||||
|
const diaPriorMonth = diaSeries.filter(
|
||||||
|
(r) =>
|
||||||
|
r.measuredAt.getTime() >= sixtyDaysAgoMs &&
|
||||||
|
r.measuredAt.getTime() < thirtyDaysAgoMs,
|
||||||
|
);
|
||||||
|
const sysPriorYear = sysSeries.filter(
|
||||||
|
(r) =>
|
||||||
|
r.measuredAt.getTime() >= oneYearAgoMs &&
|
||||||
|
r.measuredAt.getTime() < oneYearMinus30DaysAgoMs,
|
||||||
|
);
|
||||||
|
const diaPriorYear = diaSeries.filter(
|
||||||
|
(r) =>
|
||||||
|
r.measuredAt.getTime() >= oneYearAgoMs &&
|
||||||
|
r.measuredAt.getTime() < oneYearMinus30DaysAgoMs,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets),
|
last7Days: computeBpInTargetPct(sysLast7, diaLast7, targets),
|
||||||
last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets),
|
last30Days: computeBpInTargetPct(sysLast30, diaLast30, targets),
|
||||||
@@ -245,5 +286,7 @@ export function computeBpInTargetWindows(
|
|||||||
// analytics route now routes the dashboard tile's headline through
|
// analytics route now routes the dashboard tile's headline through
|
||||||
// this so it stops mirroring `last30Days`.
|
// this so it stops mirroring `last30Days`.
|
||||||
allTime: computeBpInTargetPct(sysSeries, diaSeries, targets),
|
allTime: computeBpInTargetPct(sysSeries, diaSeries, targets),
|
||||||
|
priorMonth: computeBpInTargetPct(sysPriorMonth, diaPriorMonth, targets),
|
||||||
|
priorYear: computeBpInTargetPct(sysPriorYear, diaPriorYear, targets),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user