mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): correct DST handling in cross-metric daily bucket pairing
The `pairDailyBuckets` helper in blood-pressure-status, weight-status, and mood-status synthesised a `date` field via `new Date(now − dayOffset·86_400_000)`. That subtraction treats every day as exactly 24h — wrong on the Europe/Berlin DST boundaries (the spring-forward day is 23h, the fall-back day is 25h). The result was that `toBerlinDayKey(date)` slipped one calendar day for ~2 days/year, causing miscredited correlations and mismatched same-day BP joins on those boundaries. Adds and exports `dayOffsetToBerlinDayKey(now, dayOffset)` from `bucket-series.ts`. The helper anchors at Berlin Y-M-D (DST-immune via Intl) then subtracts in UTC-anchored Berlin-day space, where every day is exactly 86_400_000 ms wide because both endpoints are UTC midnights of consecutive Berlin calendar days. All three `pairDailyBuckets` helpers and the four other call sites that synthesised a date from a dayOffset now use the helper. The returned pair carries `dayKey` directly so callers don't have to re-format. The synthesised `date` field is anchored at the UTC midnight of the Berlin day so any code that still formats it via `toBerlinDayKey()` gets the same DST-safe answer. Adds unit tests across the 2024 spring (2024-03-31 02:00 → 03:00) and autumn (2024-10-27 03:00 → 02:00) DST boundaries from late-evening and early-morning Berlin times — the cases where the naive 24h subtraction visibly slips a day. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
512a6a619c
commit
cb6a59a431
5 files changed
+162
-31
No files matched your search
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { applyPayloadBudget, bucketSeries } from "../bucket-series";
|
import {
|
||||||
|
applyPayloadBudget,
|
||||||
|
bucketSeries,
|
||||||
|
dayOffsetToBerlinDayKey,
|
||||||
|
} from "../bucket-series";
|
||||||
|
|
||||||
const dayMs = 24 * 60 * 60 * 1000;
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -71,6 +75,68 @@ describe("bucketSeries()", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dayOffsetToBerlinDayKey() across DST boundaries", () => {
|
||||||
|
// Europe/Berlin DST in 2024: spring-forward at 2024-03-31 02:00 → 03:00,
|
||||||
|
// fall-back at 2024-10-27 03:00 → 02:00. Naive `now − dayOffset·86_400_000`
|
||||||
|
// would crawl past those boundaries by an hour and silently land on the
|
||||||
|
// wrong calendar day for ~2 days/year. The helper must treat dayOffset
|
||||||
|
// as CALENDAR days, not 24-hour ticks.
|
||||||
|
|
||||||
|
it("produces today's Berlin date for dayOffset 0 around the spring-forward", () => {
|
||||||
|
// 2024-03-31 11:30 Berlin (= 09:30 UTC, after the gap).
|
||||||
|
const now = new Date("2024-03-31T09:30:00.000Z");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-03-31");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crossing the spring-forward DST boundary stays on the correct Berlin calendar day", () => {
|
||||||
|
// April 2 11:30 Berlin → 09:30 UTC (post-DST CEST).
|
||||||
|
const now = new Date("2024-04-02T09:30:00.000Z");
|
||||||
|
// Naive subtraction (now − 3·86_400_000) = 2024-03-30T09:30:00 UTC →
|
||||||
|
// 11:30 CET → still 2024-03-30. That happens to look right here, so
|
||||||
|
// the killer case is dayOffset 2 from a moment LATE on the day before
|
||||||
|
// the gap.
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-02");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-04-01");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-31");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 3)).toBe("2024-03-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crossing the spring-forward boundary from late-evening Berlin time does not slip a day", () => {
|
||||||
|
// 2024-04-01 00:30 Berlin (= 2024-03-31 22:30 UTC, in CEST). Naive
|
||||||
|
// (now − 1·86_400_000) lands at 2024-03-30 22:30 UTC = 2024-03-30
|
||||||
|
// 23:30 CET, which Intl formats as 2024-03-30. The CALENDAR-day
|
||||||
|
// answer is 2024-03-31 — one Berlin calendar day earlier than today.
|
||||||
|
const now = new Date("2024-03-31T22:30:00.000Z");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-04-01");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-03-31");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-03-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crossing the fall-back boundary from late-evening Berlin time does not slip a day", () => {
|
||||||
|
// 2024-10-28 00:30 Berlin (CET, post-fall-back) = 2024-10-27 23:30 UTC.
|
||||||
|
// Naive (now − 1·86_400_000) = 2024-10-26 23:30 UTC, which Intl
|
||||||
|
// formats with timeZone Europe/Berlin (still CEST!) as 2024-10-27
|
||||||
|
// 01:30 → 2024-10-27. That's actually correct by coincidence, so we
|
||||||
|
// probe the dayOffset where the bias bites:
|
||||||
|
const now = new Date("2024-10-27T23:30:00.000Z");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-28");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-27");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 2)).toBe("2024-10-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crossing the fall-back boundary the other direction (early-morning) stays put", () => {
|
||||||
|
// 2024-10-27 02:30 Berlin AFTER the fall-back = 02:30 CET = 01:30 UTC.
|
||||||
|
// dayOffset 1 should be 2024-10-26 (calendar), not 2024-10-25 (which
|
||||||
|
// is what naive 24h subtraction gives because the day was 25 hours
|
||||||
|
// long).
|
||||||
|
const now = new Date("2024-10-27T01:30:00.000Z");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 0)).toBe("2024-10-27");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 1)).toBe("2024-10-26");
|
||||||
|
expect(dayOffsetToBerlinDayKey(now, 7)).toBe("2024-10-20");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("applyPayloadBudget()", () => {
|
describe("applyPayloadBudget()", () => {
|
||||||
const now = new Date("2026-05-09T12:00:00Z");
|
const now = new Date("2026-05-09T12:00:00Z");
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
|||||||
import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks";
|
import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||||
import {
|
import {
|
||||||
applyPayloadBudget,
|
applyPayloadBudget,
|
||||||
|
dayOffsetToBerlinDayKey,
|
||||||
type DailyBucket,
|
type DailyBucket,
|
||||||
} from "@/lib/insights/bucket-series";
|
} from "@/lib/insights/bucket-series";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
|
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: "Europe/Berlin",
|
timeZone: "Europe/Berlin",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -80,25 +79,39 @@ function summarizeSeries(series: Array<{ value: number }>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||||
|
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||||
|
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
|
||||||
|
* don't slip the day-key by one. Each pair also carries `dayKey`
|
||||||
|
* directly so callers can label points without re-formatting.
|
||||||
|
*/
|
||||||
function pairDailyBuckets(
|
function pairDailyBuckets(
|
||||||
seriesA: DailyBucket[],
|
seriesA: DailyBucket[],
|
||||||
seriesB: DailyBucket[],
|
seriesB: DailyBucket[],
|
||||||
now: Date,
|
now: Date,
|
||||||
): PairedPoint[] {
|
): Array<PairedPoint & { dayKey: string }> {
|
||||||
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
||||||
const nowMs = now.getTime();
|
|
||||||
|
|
||||||
return seriesA
|
return seriesA
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const b = mapB.get(entry.dayOffset);
|
const b = mapB.get(entry.dayOffset);
|
||||||
if (b == null) return null;
|
if (b == null) return null;
|
||||||
|
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
|
||||||
|
// UTC midnight of the Berlin day — formatting this Date with
|
||||||
|
// `toBerlinDayKey()` is guaranteed DST-safe because the y-m-d
|
||||||
|
// fields below are the Berlin calendar day fields by construction.
|
||||||
|
const [y, m, d] = dayKey.split("-").map(Number);
|
||||||
return {
|
return {
|
||||||
a: entry.value,
|
a: entry.value,
|
||||||
b,
|
b,
|
||||||
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
|
date: new Date(Date.UTC(y, m - 1, d)),
|
||||||
|
dayKey,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is PairedPoint => entry !== null);
|
.filter(
|
||||||
|
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateBloodPressureStatusForUser(
|
export async function generateBloodPressureStatusForUser(
|
||||||
@@ -217,7 +230,7 @@ export async function generateBloodPressureStatusForUser(
|
|||||||
diaSeries.daily,
|
diaSeries.daily,
|
||||||
now,
|
now,
|
||||||
).map((entry) => ({
|
).map((entry) => ({
|
||||||
day: toBerlinDayKey(entry.date),
|
day: entry.dayKey,
|
||||||
sys: entry.a,
|
sys: entry.a,
|
||||||
dia: entry.b,
|
dia: entry.b,
|
||||||
inTarget:
|
inTarget:
|
||||||
@@ -322,10 +335,9 @@ export async function generateBloodPressureStatusForUser(
|
|||||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowMs = now.getTime();
|
|
||||||
const continuityVsSystolicSeries = sysSeries.daily.map((point) => {
|
const continuityVsSystolicSeries = sysSeries.daily.map((point) => {
|
||||||
const dayDate = new Date(nowMs - point.dayOffset * MS_PER_DAY);
|
// DST-safe: dayOffsetToBerlinDayKey computes calendar days, not 24h ticks.
|
||||||
const dayKey = toBerlinDayKey(dayDate);
|
const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset);
|
||||||
const taken = takenByDay.get(dayKey) ?? 0;
|
const taken = takenByDay.get(dayKey) ?? 0;
|
||||||
const continuityPct =
|
const continuityPct =
|
||||||
expectedBpIntakesPerDay > 0
|
expectedBpIntakesPerDay > 0
|
||||||
@@ -361,10 +373,12 @@ export async function generateBloodPressureStatusForUser(
|
|||||||
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
|
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
if (entry.continuityPct == null) return null;
|
if (entry.continuityPct == null) return null;
|
||||||
|
const [y, m, d] = entry.day.split("-").map(Number);
|
||||||
return {
|
return {
|
||||||
a: entry.continuityPct,
|
a: entry.continuityPct,
|
||||||
b: entry.sys,
|
b: entry.sys,
|
||||||
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
|
// UTC midnight of the Berlin day — DST-safe (see pairDailyBuckets).
|
||||||
|
date: new Date(Date.UTC(y, m - 1, d)),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is PairedPoint => entry !== null);
|
.filter((entry): entry is PairedPoint => entry !== null);
|
||||||
@@ -432,7 +446,7 @@ export async function generateBloodPressureStatusForUser(
|
|||||||
weightVsSystolic: {
|
weightVsSystolic: {
|
||||||
correlation: weightVsSystolicCorrelation,
|
correlation: weightVsSystolicCorrelation,
|
||||||
pairs: weightVsSystolicPairs.map((entry) => ({
|
pairs: weightVsSystolicPairs.map((entry) => ({
|
||||||
day: toBerlinDayKey(entry.date),
|
day: entry.dayKey,
|
||||||
weight: round(entry.a, 2),
|
weight: round(entry.a, 2),
|
||||||
systolic: round(entry.b, 2),
|
systolic: round(entry.b, 2),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -83,6 +83,38 @@ function utcMidnightOfBerlinDay(date: Date): number {
|
|||||||
return Date.UTC(year, month - 1, day);
|
return Date.UTC(year, month - 1, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a `dayOffset` (0 = today in Berlin, 1 = yesterday, …) back to
|
||||||
|
* the `YYYY-MM-DD` Berlin calendar day key the offset refers to.
|
||||||
|
*
|
||||||
|
* The naive `new Date(now − dayOffset·86_400_000)` then formatting in
|
||||||
|
* `Europe/Berlin` is OFF-BY-ONE-DAY across DST boundaries — the day of
|
||||||
|
* the spring-forward is 23h long and the day of the fall-back is 25h
|
||||||
|
* long, so a 24h subtraction crawls past the boundary by one hour and
|
||||||
|
* silently lands on the wrong calendar day for ~2 days/year.
|
||||||
|
*
|
||||||
|
* This helper anchors on Berlin Y-M-D first (DST-immune via Intl), then
|
||||||
|
* does the subtraction in UTC-of-Y-M-D space where every day is exactly
|
||||||
|
* 86_400_000 ms wide — so calendar arithmetic stays exact.
|
||||||
|
*
|
||||||
|
* Exported so the cross-metric `pairDailyBuckets` helpers in
|
||||||
|
* `blood-pressure-status.ts`, `weight-status.ts`, `mood-status.ts`,
|
||||||
|
* etc. all use the same source of truth.
|
||||||
|
*/
|
||||||
|
export function dayOffsetToBerlinDayKey(now: Date, dayOffset: number): string {
|
||||||
|
const todayMidnight = utcMidnightOfBerlinDay(now);
|
||||||
|
// Subtraction in UTC-anchored Berlin-day space: every day is 24h wide
|
||||||
|
// because both endpoints are UTC midnights of consecutive Berlin
|
||||||
|
// calendar days, regardless of how long the wall-clock day actually was.
|
||||||
|
const targetUtc = new Date(todayMidnight - dayOffset * MS_PER_DAY);
|
||||||
|
// Read the Y-M-D off the UTC fields directly — we anchored at UTC
|
||||||
|
// midnight, so `getUTC*` gives back the Berlin calendar day exactly.
|
||||||
|
const year = targetUtc.getUTCFullYear();
|
||||||
|
const month = String(targetUtc.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(targetUtc.getUTCDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function round(value: number): number {
|
function round(value: number): number {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/lib/analytics/correlations";
|
} from "@/lib/analytics/correlations";
|
||||||
import {
|
import {
|
||||||
applyPayloadBudget,
|
applyPayloadBudget,
|
||||||
|
dayOffsetToBerlinDayKey,
|
||||||
type DailyBucket,
|
type DailyBucket,
|
||||||
} from "@/lib/insights/bucket-series";
|
} from "@/lib/insights/bucket-series";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
@@ -73,25 +74,36 @@ function summarizeSeries(series: Array<{ value: number }>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||||
|
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||||
|
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
|
||||||
|
* don't slip the day-key by one. Each pair also carries `dayKey`
|
||||||
|
* directly so callers can label points without re-formatting.
|
||||||
|
*/
|
||||||
function pairDailyBuckets(
|
function pairDailyBuckets(
|
||||||
seriesA: DailyBucket[],
|
seriesA: DailyBucket[],
|
||||||
seriesB: DailyBucket[],
|
seriesB: DailyBucket[],
|
||||||
now: Date,
|
now: Date,
|
||||||
): PairedPoint[] {
|
): Array<PairedPoint & { dayKey: string }> {
|
||||||
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
||||||
const nowMs = now.getTime();
|
|
||||||
|
|
||||||
return seriesA
|
return seriesA
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const b = mapB.get(entry.dayOffset);
|
const b = mapB.get(entry.dayOffset);
|
||||||
if (b == null) return null;
|
if (b == null) return null;
|
||||||
|
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
|
||||||
|
const [y, m, d] = dayKey.split("-").map(Number);
|
||||||
return {
|
return {
|
||||||
a: entry.value,
|
a: entry.value,
|
||||||
b,
|
b,
|
||||||
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
|
date: new Date(Date.UTC(y, m - 1, d)),
|
||||||
|
dayKey,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is PairedPoint => entry !== null);
|
.filter(
|
||||||
|
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMoodStatusForUser(
|
export async function generateMoodStatusForUser(
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import {
|
|||||||
} from "@/lib/insights/memory";
|
} from "@/lib/insights/memory";
|
||||||
import {
|
import {
|
||||||
applyPayloadBudget,
|
applyPayloadBudget,
|
||||||
|
dayOffsetToBerlinDayKey,
|
||||||
type DailyBucket,
|
type DailyBucket,
|
||||||
} from "@/lib/insights/bucket-series";
|
} from "@/lib/insights/bucket-series";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
|
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: "Europe/Berlin",
|
timeZone: "Europe/Berlin",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -76,25 +75,36 @@ function summarizeSeries(series: Array<{ value: number }>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||||
|
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||||
|
* `dayOffsetToBerlinDayKey()` is the source of truth so DST boundaries
|
||||||
|
* don't slip the day-key by one. Each pair also carries `dayKey`
|
||||||
|
* directly so callers can label points without re-formatting.
|
||||||
|
*/
|
||||||
function pairDailyBuckets(
|
function pairDailyBuckets(
|
||||||
seriesA: DailyBucket[],
|
seriesA: DailyBucket[],
|
||||||
seriesB: DailyBucket[],
|
seriesB: DailyBucket[],
|
||||||
now: Date,
|
now: Date,
|
||||||
): PairedPoint[] {
|
): Array<PairedPoint & { dayKey: string }> {
|
||||||
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value]));
|
||||||
const nowMs = now.getTime();
|
|
||||||
|
|
||||||
return seriesA
|
return seriesA
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const b = mapB.get(entry.dayOffset);
|
const b = mapB.get(entry.dayOffset);
|
||||||
if (b == null) return null;
|
if (b == null) return null;
|
||||||
|
const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset);
|
||||||
|
const [y, m, d] = dayKey.split("-").map(Number);
|
||||||
return {
|
return {
|
||||||
a: entry.value,
|
a: entry.value,
|
||||||
b,
|
b,
|
||||||
date: new Date(nowMs - entry.dayOffset * MS_PER_DAY),
|
date: new Date(Date.UTC(y, m - 1, d)),
|
||||||
|
dayKey,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry): entry is PairedPoint => entry !== null);
|
.filter(
|
||||||
|
(entry): entry is PairedPoint & { dayKey: string } => entry !== null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateWeightStatusForUser(
|
export async function generateWeightStatusForUser(
|
||||||
@@ -169,7 +179,6 @@ export async function generateWeightStatusForUser(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nowMs = now.getTime();
|
|
||||||
|
|
||||||
const weightSeries = applyPayloadBudget(
|
const weightSeries = applyPayloadBudget(
|
||||||
measurements
|
measurements
|
||||||
@@ -232,7 +241,7 @@ export async function generateWeightStatusForUser(
|
|||||||
diaSeries.daily,
|
diaSeries.daily,
|
||||||
now,
|
now,
|
||||||
).map((entry) => ({
|
).map((entry) => ({
|
||||||
day: toBerlinDayKey(entry.date),
|
day: entry.dayKey,
|
||||||
sys: entry.a,
|
sys: entry.a,
|
||||||
dia: entry.b,
|
dia: entry.b,
|
||||||
mean: round((entry.a + entry.b) / 2, 2),
|
mean: round((entry.a + entry.b) / 2, 2),
|
||||||
@@ -242,9 +251,7 @@ export async function generateWeightStatusForUser(
|
|||||||
// pairDailyBuckets — derive dayOffset from the offsets in sysSeries.
|
// pairDailyBuckets — derive dayOffset from the offsets in sysSeries.
|
||||||
const sysOffsetByDay = new Map(
|
const sysOffsetByDay = new Map(
|
||||||
sysSeries.daily.map((bucket) => {
|
sysSeries.daily.map((bucket) => {
|
||||||
const dayKey = toBerlinDayKey(
|
const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset);
|
||||||
new Date(nowMs - bucket.dayOffset * MS_PER_DAY),
|
|
||||||
);
|
|
||||||
return [dayKey, bucket.dayOffset];
|
return [dayKey, bucket.dayOffset];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -267,7 +274,7 @@ export async function generateWeightStatusForUser(
|
|||||||
const latestWeight = weightSeries.daily[0] ?? null;
|
const latestWeight = weightSeries.daily[0] ?? null;
|
||||||
const previousWeight = weightSeries.daily[1] ?? null;
|
const previousWeight = weightSeries.daily[1] ?? null;
|
||||||
const latestWeightDay = latestWeight
|
const latestWeightDay = latestWeight
|
||||||
? toBerlinDayKey(new Date(nowMs - latestWeight.dayOffset * MS_PER_DAY))
|
? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset)
|
||||||
: null;
|
: null;
|
||||||
const sameDayBp = latestWeightDay
|
const sameDayBp = latestWeightDay
|
||||||
? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ??
|
? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ??
|
||||||
@@ -338,7 +345,7 @@ export async function generateWeightStatusForUser(
|
|||||||
weightVsSystolic: {
|
weightVsSystolic: {
|
||||||
correlation: weightVsSystolicCorrelation,
|
correlation: weightVsSystolicCorrelation,
|
||||||
pairs: weightVsSystolicPairs.map((entry) => ({
|
pairs: weightVsSystolicPairs.map((entry) => ({
|
||||||
day: toBerlinDayKey(entry.date),
|
day: entry.dayKey,
|
||||||
weight: round(entry.a, 2),
|
weight: round(entry.a, 2),
|
||||||
systolic: round(entry.b, 2),
|
systolic: round(entry.b, 2),
|
||||||
})),
|
})),
|
||||||
@@ -346,7 +353,7 @@ export async function generateWeightStatusForUser(
|
|||||||
weightVsMeanBloodPressure: {
|
weightVsMeanBloodPressure: {
|
||||||
correlation: weightVsMeanBpCorrelation,
|
correlation: weightVsMeanBpCorrelation,
|
||||||
pairs: weightVsMeanBpPairs.map((entry) => ({
|
pairs: weightVsMeanBpPairs.map((entry) => ({
|
||||||
day: toBerlinDayKey(entry.date),
|
day: entry.dayKey,
|
||||||
weight: round(entry.a, 2),
|
weight: round(entry.a, 2),
|
||||||
meanBloodPressure: round(entry.b, 2),
|
meanBloodPressure: round(entry.b, 2),
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in new issue
Block a user