mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:
- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
week. The client renders 7 dots, one per day, filled when the day's
mean reading landed in the green band, hollow when logged but
out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
target band, or null when no in-range day in the last 30. Surfaces
the maintainer-tester case the W5 reconcile flagged (today lands
in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
OR < 1 day logged in 7d. The client hides the strip + percentage
when set so we never surface a misleading "0 of 7" caption.
Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.
Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).
Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
mood) — classify the day's mean against the same green band the
RangeBar paints. Orange band reuses the pulse / glucose
configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
≥ 50% = near, otherwise out. Days with zero scheduled doses
return null so they don't break the streak.
This commit is contained in:
1 parent
86a52dac33
commit
f3c9e2d7a6
2 files changed
+606
-77
No files matched your search
@@ -14,6 +14,7 @@ import {
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import { berlinDayKey } from "@/lib/analytics/berlin-day";
|
||||
import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
@@ -56,6 +57,39 @@ interface TargetItem {
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
/**
|
||||
* v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over
|
||||
* the last 7 days. `daysInRange7d` is the count of days whose mean
|
||||
* reading landed inside the target's green band; `daysLogged7d` is
|
||||
* how many of those days had at least one reading (so the strip can
|
||||
* render filled / hollow / dimmed dots without ambiguity).
|
||||
*
|
||||
* `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading
|
||||
* was in the green band (ISO date YYYY-MM-DD), or null if the user
|
||||
* has not hit goal in the last 30 days.
|
||||
*
|
||||
* `streakDays` is the count of consecutive Berlin days ending today
|
||||
* where the day's mean was in the green band. Capped at 365.
|
||||
*
|
||||
* `insufficientData` is true when the target has fewer than 3
|
||||
* readings over the last 30 days OR fewer than 1 day of data in the
|
||||
* last 7 days. The page hides the strip + percentage when this is
|
||||
* true.
|
||||
*/
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
lastMetGoalAt: string | null;
|
||||
streakDays: number;
|
||||
insufficientData: boolean;
|
||||
/**
|
||||
* v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days
|
||||
* for the consistency strip. Index 0 is six days ago, index 6 is
|
||||
* today. `null` slots represent days with no readings; non-null
|
||||
* slots represent days whose mean reading landed in the named band.
|
||||
*/
|
||||
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -65,6 +99,12 @@ interface TargetItem {
|
||||
};
|
||||
}
|
||||
|
||||
interface TargetPageSummary {
|
||||
targetsMetThisWeek: number;
|
||||
totalTargets: number;
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -163,6 +203,327 @@ export const GET = apiHandler(async () => {
|
||||
return "stable";
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day
|
||||
// and classifies each day's mean against the target's green band.
|
||||
// Single helper shared by every target type so the rule stays stable
|
||||
// (mean reading per day → in-range / near-range / out-of-range).
|
||||
// --------------------------------------------------------------------
|
||||
const last7DayKeys: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
last7DayKeys.push(berlinDayKey(d));
|
||||
}
|
||||
|
||||
type DayBand = "in" | "near" | "out";
|
||||
|
||||
interface ConsistencyOutput {
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
lastMetGoalAt: string | null;
|
||||
streakDays: number;
|
||||
insufficientData: boolean;
|
||||
consistency7d: ReadonlyArray<DayBand | null>;
|
||||
}
|
||||
|
||||
const EMPTY_CONSISTENCY: ConsistencyOutput = {
|
||||
daysInRange7d: 0,
|
||||
daysLogged7d: 0,
|
||||
daysInRange30d: 0,
|
||||
daysLogged30d: 0,
|
||||
lastMetGoalAt: null,
|
||||
streakDays: 0,
|
||||
insufficientData: true,
|
||||
consistency7d: [null, null, null, null, null, null, null] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bucket a series of `{ measuredAt, value }` events by Berlin-tz day,
|
||||
* classify each day's mean against the target band, and roll up the
|
||||
* 7-day strip + 30-day stats + recency + streak.
|
||||
*
|
||||
* `classify(meanValue)` returns the band the day's mean falls into.
|
||||
* Pass `null` to mean "no band — skip this day for classification".
|
||||
*/
|
||||
function rollupConsistency(
|
||||
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
totalReadingsThreshold = 3,
|
||||
): ConsistencyOutput {
|
||||
if (events.length < totalReadingsThreshold) {
|
||||
// Cold-start path. Still emit the per-day strip if we have any
|
||||
// recent readings, but mark insufficient so the UI hides the %.
|
||||
const partial = computeStrip(events, classify);
|
||||
return {
|
||||
...EMPTY_CONSISTENCY,
|
||||
consistency7d: partial.consistency7d,
|
||||
daysLogged7d: partial.daysLogged7d,
|
||||
daysInRange7d: partial.daysInRange7d,
|
||||
insufficientData: true,
|
||||
};
|
||||
}
|
||||
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const ev of events) {
|
||||
const day = berlinDayKey(ev.measuredAt);
|
||||
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += ev.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
|
||||
const stripPart = computeStrip(events, classify);
|
||||
|
||||
// 30-day stats: walk the last 30 Berlin-tz day keys.
|
||||
let daysInRange30d = 0;
|
||||
let daysLogged30d = 0;
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) continue;
|
||||
daysLogged30d += 1;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) === "in") daysInRange30d += 1;
|
||||
}
|
||||
|
||||
// lastMetGoalAt: walk back from today until a day is in range.
|
||||
let lastMetGoalAt: string | null = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) continue;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) === "in") {
|
||||
lastMetGoalAt = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// streakDays: consecutive days ending today that are in-range. A
|
||||
// day with no reading breaks the streak (the user did not meet the
|
||||
// goal that day, even if today is in-range).
|
||||
let streakDays = 0;
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) break;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) !== "in") break;
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
// insufficientData: < 3 readings total OR < 1 day in the last 7.
|
||||
const insufficientData =
|
||||
events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1;
|
||||
|
||||
return {
|
||||
daysInRange7d: stripPart.daysInRange7d,
|
||||
daysLogged7d: stripPart.daysLogged7d,
|
||||
daysInRange30d,
|
||||
daysLogged30d,
|
||||
lastMetGoalAt,
|
||||
streakDays,
|
||||
insufficientData,
|
||||
consistency7d: stripPart.consistency7d,
|
||||
};
|
||||
}
|
||||
|
||||
function computeStrip(
|
||||
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
): {
|
||||
consistency7d: ReadonlyArray<DayBand | null>;
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
} {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const ev of events) {
|
||||
const day = berlinDayKey(ev.measuredAt);
|
||||
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += ev.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
const strip: Array<DayBand | null> = [];
|
||||
let daysInRange7d = 0;
|
||||
let daysLogged7d = 0;
|
||||
for (const key of last7DayKeys) {
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) {
|
||||
strip.push(null);
|
||||
continue;
|
||||
}
|
||||
daysLogged7d += 1;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
const band = classify(mean);
|
||||
strip.push(band);
|
||||
if (band === "in") daysInRange7d += 1;
|
||||
}
|
||||
return { consistency7d: strip, daysInRange7d, daysLogged7d };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: classify a value against a target range with an orange band
|
||||
* symmetrical to the green band (the same heuristic RangeBar uses
|
||||
* client-side). When `min`/`max` is null we treat the input as
|
||||
* unclassifiable and return null.
|
||||
*/
|
||||
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: number) => {
|
||||
if (mean >= range.min && mean <= range.max) return "in";
|
||||
if (mean >= orangeMin && mean <= orangeMax) return "near";
|
||||
return "out";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day BP-in-target classifier: a day is "in" if every BP pair on
|
||||
* that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`.
|
||||
* "near" if at least one pair passes; "out" otherwise.
|
||||
*
|
||||
* Returns a function (day-key, dayEvents) → band. We pre-compute the
|
||||
* by-day map once because the route already paginates BP twice (sys
|
||||
* + dia) and we want to avoid quadratic work.
|
||||
*/
|
||||
function buildBpPairsByDay(): Map<string, DayBand | null> {
|
||||
if (!bpRange) return new Map();
|
||||
const sysByDay = new Map<
|
||||
string,
|
||||
Array<{ date: Date; value: number }>
|
||||
>();
|
||||
const diaByDay = new Map<
|
||||
string,
|
||||
Array<{ date: Date; value: number }>
|
||||
>();
|
||||
for (const m of recentMeasurements) {
|
||||
if (m.type === "BLOOD_PRESSURE_SYS") {
|
||||
const k = berlinDayKey(m.measuredAt);
|
||||
const arr = sysByDay.get(k) ?? [];
|
||||
arr.push({ date: m.measuredAt, value: m.value });
|
||||
sysByDay.set(k, arr);
|
||||
} else if (m.type === "BLOOD_PRESSURE_DIA") {
|
||||
const k = berlinDayKey(m.measuredAt);
|
||||
const arr = diaByDay.get(k) ?? [];
|
||||
arr.push({ date: m.measuredAt, value: m.value });
|
||||
diaByDay.set(k, arr);
|
||||
}
|
||||
}
|
||||
const out = new Map<string, DayBand | null>();
|
||||
const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]);
|
||||
for (const key of allKeys) {
|
||||
const sys = sysByDay.get(key) ?? [];
|
||||
const dia = diaByDay.get(key) ?? [];
|
||||
const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000);
|
||||
if (pairs.length === 0) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
const inCount = pairs.filter((p) =>
|
||||
isBpReadingInTarget(p.a, p.b, bpRange),
|
||||
).length;
|
||||
if (inCount === pairs.length) out.set(key, "in");
|
||||
else if (inCount > 0) out.set(key, "near");
|
||||
else out.set(key, "out");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// We need bpRange before the per-target loop runs so the helpers above
|
||||
// can use it; reorder the local so it's available.
|
||||
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
|
||||
|
||||
/**
|
||||
* Roll up consistency for a target whose bands come from a pre-mapped
|
||||
* `dayBandByKey` map (used for BP-in-target and medication compliance
|
||||
* which have day-level classification rather than mean-classification).
|
||||
*/
|
||||
function rollupFromDayMap(
|
||||
dayBandByKey: Map<string, DayBand | null>,
|
||||
totalDaysThreshold = 3,
|
||||
): ConsistencyOutput {
|
||||
const loggedKeys = Array.from(dayBandByKey.entries()).filter(
|
||||
([, v]) => v !== null,
|
||||
);
|
||||
if (loggedKeys.length < totalDaysThreshold) {
|
||||
// Cold-start: still emit the strip.
|
||||
const strip: Array<DayBand | null> = last7DayKeys.map(
|
||||
(k) => dayBandByKey.get(k) ?? null,
|
||||
);
|
||||
const daysLogged7d = strip.filter((b) => b !== null).length;
|
||||
const daysInRange7d = strip.filter((b) => b === "in").length;
|
||||
return {
|
||||
...EMPTY_CONSISTENCY,
|
||||
consistency7d: strip,
|
||||
daysLogged7d,
|
||||
daysInRange7d,
|
||||
insufficientData: true,
|
||||
};
|
||||
}
|
||||
|
||||
const strip: Array<DayBand | null> = last7DayKeys.map(
|
||||
(k) => dayBandByKey.get(k) ?? null,
|
||||
);
|
||||
const daysLogged7d = strip.filter((b) => b !== null).length;
|
||||
const daysInRange7d = strip.filter((b) => b === "in").length;
|
||||
|
||||
let daysInRange30d = 0;
|
||||
let daysLogged30d = 0;
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
const band = dayBandByKey.get(key);
|
||||
if (!band) continue;
|
||||
daysLogged30d += 1;
|
||||
if (band === "in") daysInRange30d += 1;
|
||||
}
|
||||
|
||||
let lastMetGoalAt: string | null = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
if (dayBandByKey.get(key) === "in") {
|
||||
lastMetGoalAt = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let streakDays = 0;
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = berlinDayKey(d);
|
||||
const band = dayBandByKey.get(key);
|
||||
if (band === undefined || band === null) break;
|
||||
if (band !== "in") break;
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
daysInRange7d,
|
||||
daysLogged7d,
|
||||
daysInRange30d,
|
||||
daysLogged30d,
|
||||
lastMetGoalAt,
|
||||
streakDays,
|
||||
insufficientData: loggedKeys.length < totalDaysThreshold,
|
||||
consistency7d: strip,
|
||||
};
|
||||
}
|
||||
|
||||
// Memo: pre-compute the BP-pairs day map once.
|
||||
const bpPairsByDay = buildBpPairsByDay();
|
||||
|
||||
// Build target items
|
||||
const targets: TargetItem[] = [];
|
||||
|
||||
@@ -175,20 +536,28 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyBMI(bmi);
|
||||
weightClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
unit: "kg",
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "WEIGHT"),
|
||||
makeRangeClassifier(weightRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
unit: "kg",
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Blood Pressure (sys/dia combined)
|
||||
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
|
||||
// `bpRange` is hoisted to the helpers block above so the BP-pairs
|
||||
// day-map can use it; do not redeclare here.
|
||||
let bpClassification: { category: string; color: string } | null = null;
|
||||
if (
|
||||
latestByType.BLOOD_PRESSURE_SYS != null &&
|
||||
@@ -200,18 +569,26 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
bpClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blood pressure",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
{
|
||||
// Per-day BP classifier reuses the sys+dia paired classifier from
|
||||
// `bpPairsByDay`. This is the right semantic for "in target band"
|
||||
// because a systolic in range with a diastolic out of range is
|
||||
// still an out-of-target reading.
|
||||
const consistency = rollupFromDayMap(bpPairsByDay);
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blood pressure",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
...consistency,
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
}
|
||||
|
||||
// 2b. Blood pressure target-hit rate over 30 days
|
||||
if (bpRange) {
|
||||
@@ -239,6 +616,9 @@ export const GET = apiHandler(async () => {
|
||||
? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10
|
||||
: null;
|
||||
|
||||
// BP-in-target consistency mirrors the BP pair-by-day map above:
|
||||
// a day is "in" when every pair on that day clears the BP target.
|
||||
const inTargetConsistency = rollupFromDayMap(bpPairsByDay);
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE_IN_TARGET",
|
||||
label: "Blood pressure on target",
|
||||
@@ -256,6 +636,7 @@ export const GET = apiHandler(async () => {
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESH 2023",
|
||||
...inTargetConsistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,17 +647,31 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
|
||||
pulseClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
unit: "bpm",
|
||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
});
|
||||
{
|
||||
const pulseRange = {
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
};
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "PULSE"),
|
||||
makeRangeClassifier(pulseRange, {
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
}),
|
||||
);
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
unit: "bpm",
|
||||
range: pulseRange,
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Sleep Duration
|
||||
const sleepRange = getSleepDurationRange();
|
||||
@@ -285,17 +680,24 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
|
||||
sleepClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Sleep duration",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
unit: "h",
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"),
|
||||
makeRangeClassifier(sleepRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Sleep duration",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
unit: "h",
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. BMI (derived from weight + height)
|
||||
if (heightCm) {
|
||||
@@ -318,6 +720,17 @@ export const GET = apiHandler(async () => {
|
||||
bmiClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
|
||||
// BMI consistency derives from the weight series: divide each
|
||||
// bucket by height\u00B2 so the day's mean BMI is classified against
|
||||
// the BMI range bar (18.5 \u2014 24.9).
|
||||
const bmiRange = { min: 18.5, max: 24.9 };
|
||||
const bmiEvents = recentMeasurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq }));
|
||||
const consistency = rollupConsistency(
|
||||
bmiEvents,
|
||||
makeRangeClassifier(bmiRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
@@ -325,9 +738,10 @@ export const GET = apiHandler(async () => {
|
||||
average30: avgBmi,
|
||||
trend: computeTrend("WEIGHT"), // BMI trend follows weight trend
|
||||
unit: "kg/m\u00B2",
|
||||
range: { min: 18.5, max: 24.9 },
|
||||
range: bmiRange,
|
||||
classification: bmiClassification,
|
||||
source: "WHO",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -343,17 +757,24 @@ export const GET = apiHandler(async () => {
|
||||
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
|
||||
// fitness + acceptable bands).
|
||||
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
unit: "%",
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "BODY_FAT"),
|
||||
makeRangeClassifier(bodyFatRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
unit: "%",
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Activity Steps
|
||||
const stepsRange = getStepsRange();
|
||||
@@ -362,23 +783,30 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifySteps(avg30ByType.ACTIVITY_STEPS);
|
||||
stepsClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Steps/day",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"),
|
||||
makeRangeClassifier(stepsRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Steps/day",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
@@ -467,6 +895,33 @@ export const GET = apiHandler(async () => {
|
||||
) / 10
|
||||
: complianceRate7;
|
||||
|
||||
// Per-day compliance: for each Berlin-tz day, classify as "in" if
|
||||
// every scheduled dose that day was taken; "near" if at least one
|
||||
// was taken; "out" if all were skipped/missed; null if there were
|
||||
// no scheduled doses (the user's regimen excluded that day).
|
||||
const dayCountsByKey = new Map<
|
||||
string,
|
||||
{ taken: number; expected: number }
|
||||
>();
|
||||
for (const event of intakeEvents) {
|
||||
const key = berlinDayKey(event.scheduledFor);
|
||||
const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 };
|
||||
cur.expected += 1;
|
||||
if (event.takenAt && !event.skipped) cur.taken += 1;
|
||||
dayCountsByKey.set(key, cur);
|
||||
}
|
||||
const dayBandByKey = new Map<string, DayBand | null>();
|
||||
for (const [key, counts] of dayCountsByKey.entries()) {
|
||||
if (counts.expected === 0) {
|
||||
dayBandByKey.set(key, null);
|
||||
continue;
|
||||
}
|
||||
const ratio = counts.taken / counts.expected;
|
||||
if (ratio >= 0.99) dayBandByKey.set(key, "in");
|
||||
else if (ratio >= 0.5) dayBandByKey.set(key, "near");
|
||||
else dayBandByKey.set(key, "out");
|
||||
}
|
||||
const consistency = rollupFromDayMap(dayBandByKey);
|
||||
targets.push({
|
||||
type: "MEDICATION_COMPLIANCE",
|
||||
label: "Medication compliance",
|
||||
@@ -491,6 +946,7 @@ export const GET = apiHandler(async () => {
|
||||
compliance30: medication.compliance30,
|
||||
})),
|
||||
},
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -545,6 +1001,15 @@ export const GET = apiHandler(async () => {
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null;
|
||||
|
||||
const moodRange = { min: 3.5, max: 5 };
|
||||
const moodEventsForConsistency = recentMood.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodConsistency = rollupConsistency(
|
||||
moodEventsForConsistency,
|
||||
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
|
||||
);
|
||||
targets.push({
|
||||
type: "MOOD_SCORE",
|
||||
label: "Mood",
|
||||
@@ -552,9 +1017,10 @@ export const GET = apiHandler(async () => {
|
||||
average30: moodAvg30,
|
||||
trend: moodTrend,
|
||||
unit: "/ 5",
|
||||
range: { min: 3.5, max: 5 },
|
||||
range: moodRange,
|
||||
classification: moodClassification,
|
||||
source: "moodLog",
|
||||
...moodConsistency,
|
||||
});
|
||||
|
||||
// Mood stability: standard deviation of recent scores (lower = more stable)
|
||||
@@ -572,6 +1038,10 @@ export const GET = apiHandler(async () => {
|
||||
? { category: "Stable", color: "#f1fa8c" }
|
||||
: { category: "Fluctuating", color: "#ff5555" };
|
||||
|
||||
// Mood stability is computed from the same recent-mood window;
|
||||
// consistency mirrors MOOD_SCORE so the strip aligns with the
|
||||
// user's logging cadence rather than introducing a synthetic
|
||||
// "\u03C3 in range per day" rule that would be hard to reason about.
|
||||
targets.push({
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Mood stability",
|
||||
@@ -582,6 +1052,7 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: 0, max: 0.5 },
|
||||
classification: stabilityClassification,
|
||||
source: "moodLog",
|
||||
...moodConsistency,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -648,6 +1119,15 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const consistency = rollupConsistency(
|
||||
recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
makeRangeClassifier(
|
||||
range,
|
||||
eff.range
|
||||
? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
targets.push({
|
||||
type: `BLOOD_GLUCOSE_${ctx}`,
|
||||
label: labelKeyByContext[ctx],
|
||||
@@ -658,16 +1138,46 @@ export const GET = apiHandler(async () => {
|
||||
range,
|
||||
classification,
|
||||
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// v1.4.25 W3e — page summary. "X of Y targets met this week" is the
|
||||
// single most-asked-about glanceable answer. We define "met this
|
||||
// week" as the target having `daysInRange7d >= 4` AND not flagged as
|
||||
// `insufficientData`. The streak highlight surfaces the metric with
|
||||
// the longest current ≥ 3 day streak; nothing renders client-side
|
||||
// when no target hits the bar.
|
||||
// --------------------------------------------------------------------
|
||||
const targetsMetThisWeek = targets.filter(
|
||||
(target) => !target.insufficientData && target.daysInRange7d >= 4,
|
||||
).length;
|
||||
let streakHighlight: TargetPageSummary["streakHighlight"] = null;
|
||||
for (const target of targets) {
|
||||
if (target.streakDays < 3) continue;
|
||||
if (!streakHighlight || target.streakDays > streakHighlight.days) {
|
||||
streakHighlight = { metric: target.type, days: target.streakDays };
|
||||
}
|
||||
}
|
||||
const pageSummary: TargetPageSummary = {
|
||||
targetsMetThisWeek,
|
||||
totalTargets: targets.length,
|
||||
streakHighlight,
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
meta: { targetCount: targets.length },
|
||||
meta: {
|
||||
targetCount: targets.length,
|
||||
targetsMetThisWeek,
|
||||
streakHighlightMetric: streakHighlight?.metric ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
pageSummary,
|
||||
// Extra diastolic data for BP display
|
||||
bpDiastolic: {
|
||||
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
|
||||
|
||||
@@ -38,6 +38,18 @@ interface TargetData {
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
// v1.4.25 W3e — consistency strip + recency + streak. Optional on the
|
||||
// client so the i18n / spacing tests that only pre-populate
|
||||
// classification + range still type-check; the runtime route always
|
||||
// sets these.
|
||||
daysInRange7d?: number;
|
||||
daysLogged7d?: number;
|
||||
daysInRange30d?: number;
|
||||
daysLogged30d?: number;
|
||||
lastMetGoalAt?: string | null;
|
||||
streakDays?: number;
|
||||
insufficientData?: boolean;
|
||||
consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -53,8 +65,15 @@ interface BpDiastolic {
|
||||
range: { min: number; max: number } | null;
|
||||
}
|
||||
|
||||
interface TargetPageSummary {
|
||||
targetsMetThisWeek: number;
|
||||
totalTargets: number;
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
}
|
||||
|
||||
interface TargetsResponse {
|
||||
targets: TargetData[];
|
||||
pageSummary?: TargetPageSummary;
|
||||
bpDiastolic: BpDiastolic;
|
||||
profile: {
|
||||
heightCm: number | null;
|
||||
|
||||
Reference in new issue
Block a user