From f3c9e2d7a6fad9fe78498641200372b2046b32d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 12:21:25 +0200 Subject: [PATCH] feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/insights/targets/route.ts | 664 +++++++++++++++++++++++--- src/app/targets/page.tsx | 19 + 2 files changed, 606 insertions(+), 77 deletions(-) diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 3f516337c..2355795a2 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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; + } + + 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(); + 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; + daysInRange7d: number; + daysLogged7d: number; + } { + const byDay = new Map(); + 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 = []; + 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 { + 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(); + 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, + 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 = 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 = 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(); + 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, diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index c982c71a5..a95989188 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -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;