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:
Marc-André Bombeck committed 2026-05-14 12:21:25 +02:00
1 parent 86a52dac33
commit f3c9e2d7a6
2 files changed
+606 -77

No files matched your search

+587 -77
View File
@@ -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,
+19
View File
@@ -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;