mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
The targets page read a year of raw glucose readings to keep the newest one per meal context, and thirty days of raw heart rate to derive one resting figure per day. A sensor reporting every five minutes puts 105 000 glucose readings in that year and a watch recording once a minute puts 43 000 heart-rate readings in the month, and the page is read on every visit from the web and the app. Glucose now reads one row per context (DISTINCT ON) for the newest value and one row per context and local day for the thirty-day mean and the consistency strip; the strip takes day sums directly through rollupConsistencyFromDays. Heart rate reaches the resting-pulse card through readRestingPulseProxy, the SQL fold the insights page already uses, merged per day with the native resting rows exactly as before. The cards are unchanged: the integration test rebuilds them from the raw readings through the in-memory folds and compares.
248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
import { userDayKey } from "@/lib/tz/resolver";
|
|
import type { DayBand, TargetConsistency } from "./types";
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
const EMPTY_STRIP = [null, null, null, null, null, null, null] as const;
|
|
|
|
const EMPTY_CONSISTENCY: TargetConsistency = {
|
|
daysInRange7d: 0,
|
|
daysLogged7d: 0,
|
|
daysInRange30d: 0,
|
|
daysLogged30d: 0,
|
|
lastMetGoalAt: null,
|
|
streakDays: 0,
|
|
insufficientData: true,
|
|
consistency7d: EMPTY_STRIP,
|
|
};
|
|
|
|
interface ConsistencyClock {
|
|
timezone: string;
|
|
now: Date;
|
|
}
|
|
|
|
interface RollupConsistencyInput extends ConsistencyClock {
|
|
events: ReadonlyArray<{ measuredAt: Date; value: number }>;
|
|
classify: (mean: number) => DayBand | null;
|
|
totalReadingsThreshold?: number;
|
|
}
|
|
|
|
interface RollupDayMapInput extends ConsistencyClock {
|
|
dayBandByKey: ReadonlyMap<string, DayBand | null>;
|
|
totalDaysThreshold?: number;
|
|
}
|
|
|
|
function dayKeyAtOffset(now: Date, timezone: string, daysAgo: number): string {
|
|
return userDayKey(new Date(now.getTime() - daysAgo * DAY_MS), timezone);
|
|
}
|
|
|
|
function last7DayKeys(now: Date, timezone: string): string[] {
|
|
const keys: string[] = [];
|
|
for (let daysAgo = 6; daysAgo >= 0; daysAgo--) {
|
|
keys.push(dayKeyAtOffset(now, timezone, daysAgo));
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** A day's readings folded to their sum and count, keyed by local day. */
|
|
export type DaySums = ReadonlyMap<string, { sum: number; count: number }>;
|
|
|
|
function sumByDay(
|
|
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
|
timezone: string,
|
|
): Map<string, { sum: number; count: number }> {
|
|
const byDay = new Map<string, { sum: number; count: number }>();
|
|
for (const event of events) {
|
|
const key = userDayKey(event.measuredAt, timezone);
|
|
const bucket = byDay.get(key) ?? { sum: 0, count: 0 };
|
|
bucket.sum += event.value;
|
|
bucket.count += 1;
|
|
byDay.set(key, bucket);
|
|
}
|
|
return byDay;
|
|
}
|
|
|
|
function computeStrip(
|
|
byDay: DaySums,
|
|
classify: (mean: number) => DayBand | null,
|
|
timezone: string,
|
|
now: Date,
|
|
) {
|
|
const consistency7d: Array<DayBand | null> = [];
|
|
let daysInRange7d = 0;
|
|
let daysLogged7d = 0;
|
|
for (const key of last7DayKeys(now, timezone)) {
|
|
const bucket = byDay.get(key);
|
|
if (!bucket) {
|
|
consistency7d.push(null);
|
|
continue;
|
|
}
|
|
daysLogged7d += 1;
|
|
const band = classify(bucket.sum / bucket.count);
|
|
consistency7d.push(band);
|
|
if (band === "in") daysInRange7d += 1;
|
|
}
|
|
return { consistency7d, daysInRange7d, daysLogged7d };
|
|
}
|
|
|
|
export function rollupConsistency({
|
|
events,
|
|
...rest
|
|
}: RollupConsistencyInput): TargetConsistency {
|
|
return rollupConsistencyFromDays({
|
|
...rest,
|
|
byDay: sumByDay(events, rest.timezone),
|
|
readingCount: events.length,
|
|
});
|
|
}
|
|
|
|
interface RollupDaySumsInput extends ConsistencyClock {
|
|
/** Each local day's readings as sum and count. */
|
|
byDay: DaySums;
|
|
/** Readings behind `byDay`, for the thin-data threshold. */
|
|
readingCount: number;
|
|
classify: (mean: number) => DayBand | null;
|
|
totalReadingsThreshold?: number;
|
|
}
|
|
|
|
/**
|
|
* The same consistency as {@link rollupConsistency}, from readings already
|
|
* folded per local day. Every figure here is a function of each day's mean
|
|
* and of the reading count, so a reader that folds in SQL hands over one row
|
|
* per day instead of every reading and gets the same answer.
|
|
*/
|
|
export function rollupConsistencyFromDays({
|
|
byDay,
|
|
readingCount,
|
|
classify,
|
|
timezone,
|
|
now,
|
|
totalReadingsThreshold = 3,
|
|
}: RollupDaySumsInput): TargetConsistency {
|
|
if (readingCount < totalReadingsThreshold) {
|
|
const partial = computeStrip(byDay, classify, timezone, now);
|
|
return {
|
|
...EMPTY_CONSISTENCY,
|
|
...partial,
|
|
insufficientData: true,
|
|
};
|
|
}
|
|
|
|
const strip = computeStrip(byDay, classify, timezone, now);
|
|
|
|
let daysInRange30d = 0;
|
|
let daysLogged30d = 0;
|
|
for (let daysAgo = 29; daysAgo >= 0; daysAgo--) {
|
|
const bucket = byDay.get(dayKeyAtOffset(now, timezone, daysAgo));
|
|
if (!bucket) continue;
|
|
daysLogged30d += 1;
|
|
if (classify(bucket.sum / bucket.count) === "in") daysInRange30d += 1;
|
|
}
|
|
|
|
let lastMetGoalAt: string | null = null;
|
|
for (let daysAgo = 0; daysAgo < 30; daysAgo++) {
|
|
const key = dayKeyAtOffset(now, timezone, daysAgo);
|
|
const bucket = byDay.get(key);
|
|
if (!bucket) continue;
|
|
if (classify(bucket.sum / bucket.count) === "in") {
|
|
lastMetGoalAt = key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
let streakDays = 0;
|
|
for (let daysAgo = 0; daysAgo < 365; daysAgo++) {
|
|
const bucket = byDay.get(dayKeyAtOffset(now, timezone, daysAgo));
|
|
if (!bucket || classify(bucket.sum / bucket.count) !== "in") break;
|
|
streakDays += 1;
|
|
}
|
|
|
|
return {
|
|
daysInRange7d: strip.daysInRange7d,
|
|
daysLogged7d: strip.daysLogged7d,
|
|
daysInRange30d,
|
|
daysLogged30d,
|
|
lastMetGoalAt,
|
|
streakDays,
|
|
insufficientData:
|
|
readingCount < totalReadingsThreshold || strip.daysLogged7d < 1,
|
|
consistency7d: strip.consistency7d,
|
|
};
|
|
}
|
|
|
|
export function rollupFromDayMap({
|
|
dayBandByKey,
|
|
timezone,
|
|
now,
|
|
totalDaysThreshold = 3,
|
|
}: RollupDayMapInput): TargetConsistency {
|
|
const loggedKeys = Array.from(dayBandByKey.values()).filter(
|
|
(band) => band !== null,
|
|
);
|
|
const strip = last7DayKeys(now, timezone).map(
|
|
(key) => dayBandByKey.get(key) ?? null,
|
|
);
|
|
const daysLogged7d = strip.filter((band) => band !== null).length;
|
|
const daysInRange7d = strip.filter((band) => band === "in").length;
|
|
|
|
if (loggedKeys.length < totalDaysThreshold) {
|
|
return {
|
|
...EMPTY_CONSISTENCY,
|
|
consistency7d: strip,
|
|
daysLogged7d,
|
|
daysInRange7d,
|
|
insufficientData: true,
|
|
};
|
|
}
|
|
|
|
let daysInRange30d = 0;
|
|
let daysLogged30d = 0;
|
|
for (let daysAgo = 29; daysAgo >= 0; daysAgo--) {
|
|
const band = dayBandByKey.get(dayKeyAtOffset(now, timezone, daysAgo));
|
|
if (!band) continue;
|
|
daysLogged30d += 1;
|
|
if (band === "in") daysInRange30d += 1;
|
|
}
|
|
|
|
let lastMetGoalAt: string | null = null;
|
|
for (let daysAgo = 0; daysAgo < 30; daysAgo++) {
|
|
const key = dayKeyAtOffset(now, timezone, daysAgo);
|
|
if (dayBandByKey.get(key) === "in") {
|
|
lastMetGoalAt = key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
let streakDays = 0;
|
|
for (let daysAgo = 0; daysAgo < 365; daysAgo++) {
|
|
const band = dayBandByKey.get(dayKeyAtOffset(now, timezone, daysAgo));
|
|
if (band === undefined || band === null || band !== "in") break;
|
|
streakDays += 1;
|
|
}
|
|
|
|
return {
|
|
daysInRange7d,
|
|
daysLogged7d,
|
|
daysInRange30d,
|
|
daysLogged30d,
|
|
lastMetGoalAt,
|
|
streakDays,
|
|
insufficientData: loggedKeys.length < totalDaysThreshold,
|
|
consistency7d: strip,
|
|
};
|
|
}
|
|
|
|
export function makeRangeClassifier(
|
|
range: { min: number; max: number } | null,
|
|
options?: { orangeMin?: number; orangeMax?: number },
|
|
): (mean: number) => DayBand | null {
|
|
if (!range) return () => null;
|
|
const span = range.max - range.min;
|
|
const orangeMin = options?.orangeMin ?? range.min - span * 0.3;
|
|
const orangeMax = options?.orangeMax ?? range.max + span * 0.3;
|
|
return (mean) => {
|
|
if (mean >= range.min && mean <= range.max) return "in";
|
|
if (mean >= orangeMin && mean <= orangeMax) return "near";
|
|
return "out";
|
|
};
|
|
}
|