From 15c0a63c2272d91c384265b1bb3a3ec5f3a52e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:30:55 +0200 Subject: [PATCH 01/45] feat(glucose): add clinical-metrics core for TIR, GMI, eA1C and CV --- .../__tests__/glucose-metrics.test.ts | 234 +++++++++++ src/lib/analytics/glucose-metrics.ts | 380 ++++++++++++++++++ 2 files changed, 614 insertions(+) create mode 100644 src/lib/analytics/__tests__/glucose-metrics.test.ts create mode 100644 src/lib/analytics/glucose-metrics.ts diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts new file mode 100644 index 000000000..0fdfbd685 --- /dev/null +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -0,0 +1,234 @@ +/** + * Known-value pins for the glucose clinical-metrics core. + * + * Worked examples are computed straight from the cited primary literature: + * Battelino 2019 (TIR bands), Bergenstal 2018 (GMI), Nathan 2008 (eA1C), + * Monnier 2017 (CV% 36% cutoff). The numbers below are the formulas applied + * by hand, not back-derived from the implementation. + */ +import { describe, expect, it } from "vitest"; +import { + CV_INSTABILITY_THRESHOLD, + DEFAULT_WINDOW_DAYS, + computeGlucoseClinicalMetrics, + estimatedA1c, + glucoseSD, + glucoseVariability, + gmi, + timeInRange, + type GlucoseReading, +} from "../glucose-metrics"; + +const DAY = 24 * 60 * 60 * 1000; + +/** Build readings spaced one per day ending at `anchor`. */ +function dailyReadings(values: number[], anchor: Date): GlucoseReading[] { + return values.map((mgdl, i) => ({ + mgdl, + // oldest first, newest = anchor + measuredAt: new Date(anchor.getTime() - (values.length - 1 - i) * DAY), + })); +} + +describe("gmi (Bergenstal 2018)", () => { + it("maps a mean of 154 mg/dL to ~6.99%", () => { + // 3.31 + 0.02392 * 154 = 6.99368 + expect(gmi(154)).toBeCloseTo(6.99368, 5); + }); + + it("maps a mean of 100 mg/dL to 5.702%", () => { + expect(gmi(100)).toBeCloseTo(5.702, 5); + }); +}); + +describe("estimatedA1c (Nathan 2008 ADAG inverse)", () => { + it("maps a mean of 154 mg/dL to ~6.99%", () => { + // (154 + 46.7) / 28.7 = 6.99303... + expect(estimatedA1c(154)).toBeCloseTo(6.99303, 4); + }); + + it("maps a mean of 100 mg/dL to ~5.11%", () => { + // (100 + 46.7) / 28.7 = 5.11149... + expect(estimatedA1c(100)).toBeCloseTo(5.11149, 4); + }); +}); + +describe("glucoseSD", () => { + it("returns null for fewer than two values", () => { + expect(glucoseSD([])).toBeNull(); + expect(glucoseSD([120])).toBeNull(); + }); + + it("computes the sample (n-1) standard deviation", () => { + // values 2,4,4,4,5,5,7,9 → mean 5, sample SD = sqrt(32/7) = 2.13809 + expect(glucoseSD([2, 4, 4, 4, 5, 5, 7, 9])).toBeCloseTo(2.13809, 4); + }); +}); + +describe("glucoseVariability (Monnier 2017 CV cutoff)", () => { + it("flags instability at CV ≥ 36%", () => { + // mean 100, SD 36 → CV exactly 36 → unstable (>=) + const v = glucoseVariability([136, 64, 100]); + // mean = 100; SD of {136,64,100} = sqrt(((36^2)+(36^2)+0)/2) = 36 + expect(v).not.toBeNull(); + expect(v!.cv).toBeCloseTo(36, 6); + expect(v!.unstable).toBe(true); + expect(CV_INSTABILITY_THRESHOLD).toBe(36); + }); + + it("does not flag a stable low-variability set", () => { + const v = glucoseVariability([100, 102, 98, 101, 99]); + expect(v!.unstable).toBe(false); + expect(v!.cv).toBeLessThan(36); + }); +}); + +describe("timeInRange (Battelino 2019 bands)", () => { + it("returns null for an empty set", () => { + expect(timeInRange([])).toBeNull(); + }); + + it("computes exact per-band fractions with nested level-2 sub-bands", () => { + // 10 readings: + // 40 → <54 and <70 (TBR2 + TBR1) + // 60 → <70 only (TBR1) + // 70,120,150,180 → in range (4) + // 200,210 → >180 only (TAR1) + // 300 → >250 and >180 (TAR2 + TAR1) + const d = timeInRange([40, 60, 70, 120, 150, 180, 200, 210, 300, 90]); + expect(d).not.toBeNull(); + // in range: 70,120,150,180,90 = 5 + expect(d!.tir).toBeCloseTo(0.5, 10); + // <70: 40,60 = 2 + expect(d!.tbrLevel1).toBeCloseTo(0.2, 10); + // <54: 40 = 1 + expect(d!.tbrLevel2).toBeCloseTo(0.1, 10); + // >180: 200,210,300 = 3 + expect(d!.tarLevel1).toBeCloseTo(0.3, 10); + // >250: 300 = 1 + expect(d!.tarLevel2).toBeCloseTo(0.1, 10); + }); + + it("treats the band edges 70 and 180 as in range, 54 as below", () => { + const d = timeInRange([54, 70, 180, 181, 69]); + // in range: 70,180 = 2/5 + expect(d!.tir).toBeCloseTo(0.4, 10); + // <70: 54,69 = 2/5 + expect(d!.tbrLevel1).toBeCloseTo(0.4, 10); + // <54: none (54 is not < 54) + expect(d!.tbrLevel2).toBe(0); + // >180: 181 = 1/5 + expect(d!.tarLevel1).toBeCloseTo(0.2, 10); + }); + + it("emits minutes-of-a-day equivalents (fraction × 1440)", () => { + const d = timeInRange([100, 100, 100, 300]); // 3 in range, 1 above + expect(d!.minutesEquivalent.tir).toBeCloseTo(0.75 * 1440, 6); + expect(d!.minutesEquivalent.tarLevel1).toBeCloseTo(0.25 * 1440, 6); + }); +}); + +describe("computeGlucoseClinicalMetrics — window + adequacy", () => { + const now = new Date("2026-06-14T12:00:00Z"); + + it("defaults to a 14-day window", () => { + const m = computeGlucoseClinicalMetrics([], { now }); + expect(m.windowDays).toBe(DEFAULT_WINDOW_DAYS); + expect(DEFAULT_WINDOW_DAYS).toBe(14); + }); + + it("excludes readings older than the window", () => { + const readings: GlucoseReading[] = [ + { mgdl: 120, measuredAt: new Date(now.getTime() - 2 * DAY) }, + { mgdl: 130, measuredAt: new Date(now.getTime() - 20 * DAY) }, // out + ]; + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(1); + }); + + it("ignores non-finite values", () => { + const readings: GlucoseReading[] = [ + { mgdl: 120, measuredAt: new Date(now.getTime() - 1 * DAY) }, + { mgdl: NaN, measuredAt: new Date(now.getTime() - 2 * DAY) }, + ]; + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(1); + }); + + it("always marks the result as a spot estimate", () => { + const m = computeGlucoseClinicalMetrics( + dailyReadings([100], now), + { now }, + ); + expect(m.isSpotEstimate).toBe(true); + }); + + describe("learning gate", () => { + it("fires when there are no readings", () => { + const m = computeGlucoseClinicalMetrics([], { now }); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/no glucose readings/i); + expect(m.meanMgdl).toBeNull(); + expect(m.distribution).toBeNull(); + expect(m.gmi).toBeNull(); + expect(m.estimatedA1c).toBeNull(); + expect(m.variability).toBeNull(); + }); + + it("fires below the minimum reading count, reporting N and D", () => { + // 5 readings across 5 days — below the default 14-reading floor + const m = computeGlucoseClinicalMetrics( + dailyReadings([100, 110, 120, 130, 140], now), + { now }, + ); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/5 readings over 4 days/); + expect(m.readingCount).toBe(5); + // numbers are still populated for a calm preview + expect(m.meanMgdl).toBeCloseTo(120, 6); + expect(m.distribution).not.toBeNull(); + }); + + it("fires when the span is too short even with enough readings", () => { + // 16 readings all within a single day → span < 7 days + const values = Array.from({ length: 16 }, (_, i) => 100 + i); + const readings: GlucoseReading[] = values.map((mgdl, i) => ({ + mgdl, + measuredAt: new Date(now.getTime() - i * 60 * 60 * 1000), // hourly + })); + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(16); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/span only/i); + }); + + it("clears above both thresholds and asserts the panel", () => { + // 14 readings, one per day → 13-day span, count 14 + const values = Array.from({ length: 14 }, () => 154); + const m = computeGlucoseClinicalMetrics( + dailyReadings(values, now), + { now }, + ); + expect(m.readingCount).toBe(14); + expect(m.actualSpanDays).toBeCloseTo(13, 6); + expect(m.stillLearning).toBe(false); + expect(m.stillLearningReason).toBeNull(); + // mean 154 → GMI ~6.99, eA1C ~6.99 + expect(m.meanMgdl).toBeCloseTo(154, 6); + expect(m.gmi).toBeCloseTo(6.99368, 5); + expect(m.estimatedA1c).toBeCloseTo(6.99303, 4); + expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range + expect(m.variability!.sd).toBe(0); + expect(m.variability!.unstable).toBe(false); + }); + + it("honours configurable thresholds", () => { + const m = computeGlucoseClinicalMetrics( + dailyReadings([100, 110, 120], now), + { now, minReadings: 3, minSpanDays: 2 }, + ); + // 3 readings over a 2-day span clears the lowered bars + expect(m.stillLearning).toBe(false); + }); + }); +}); diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts new file mode 100644 index 000000000..67d5de31b --- /dev/null +++ b/src/lib/analytics/glucose-metrics.ts @@ -0,0 +1,380 @@ +/** + * Glucose clinical-metrics core — server-authoritative analytics layer. + * + * Pure, side-effect-free functions over an array of spot blood-glucose + * readings (canonical **mg/dL**, the HealthLog store unit — see + * `src/lib/glucose.ts` for unit conversion; this module never duplicates it). + * One engine: the analytics route, the AI coach, the doctor-report PDF/FHIR + * export, and the iOS client all read these numbers. iOS RENDERS the output, + * it never recomputes — keeping the clinical math in one literature-locked + * place. + * + * IMPORTANT honesty contract: HealthLog stores SPOT readings (FASTING / + * POSTPRANDIAL / RANDOM / BEDTIME), NOT a dense CGM stream. The classic + * CGM-adequacy bar (Battelino 2019: 14 days with ≥70% of possible readings) + * cannot be met by spot data. Therefore every TIR / GMI / eA1C produced here + * is a SPOT-READING ESTIMATE — a "% of readings" distribution, NOT a "% of + * time" AGP report. The {@link GlucoseClinicalMetrics.stillLearning} gate and + * the per-metric labelling exist so callers never assert a clinical AGP off + * thin spot data. + * + * Thresholds and formulas are clean-room from the primary literature (the + * consensus band values and coefficients are mathematical facts, not + * copyrightable). Each function cites its source inline. Defer the + * dense-stream indices (MAGE, CONGA, MODD, ADRR, AGP, time-weighted mean) to + * the future CGM integration — they need ordered, closely-spaced samples this + * module deliberately does not assume. + * + * Citations: + * Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028. + * Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581. + * Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545. + * Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769. + */ + +// ── Inputs ─────────────────────────────────────────────── + +/** A single spot blood-glucose reading. Value is canonical mg/dL. */ +export interface GlucoseReading { + /** When the sample was taken. */ + measuredAt: Date; + /** Glucose value in mg/dL (HealthLog canonical store unit). */ + mgdl: number; +} + +/** Options for the clinical-window computation. */ +export interface GlucoseMetricsOptions { + /** + * Clinical reporting window in days. Default 14 — the Battelino 2019 + * consensus reporting period. Readings older than `now - windowDays` are + * excluded. Configurable so the doctor report or a longer trend view can + * widen it. + */ + windowDays?: number; + /** + * Anchor for the window. Defaults to `new Date()`. Injectable purely to keep + * the module deterministic under test — there is no other side effect. + */ + now?: Date; + /** + * Minimum number of in-window readings before TIR/GMI/eA1C/CV are asserted + * rather than gated behind `stillLearning`. Default 14 — roughly one reading + * per day across the window; below this a spot-data estimate is not + * clinically meaningful. + */ + minReadings?: number; + /** + * Minimum span (first→last reading, in days) the window's readings must + * cover. Default 7. A burst of readings inside a single day cannot stand in + * for two weeks of glycaemic behaviour. + */ + minSpanDays?: number; +} + +// ── Consensus thresholds (Battelino 2019) ──────────────── +// Bands in mg/dL. These are consensus FACTS, cited above. +const TBR_LEVEL2_MAX = 54; // very low: G < 54 +const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69) +const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250) +const TAR_LEVEL2_MIN = 250; // very high: G > 250 +// Time-in-range target band is [70, 180] inclusive. + +/** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */ +export const CV_INSTABILITY_THRESHOLD = 36; + +/** Battelino 2019 reporting defaults. */ +export const DEFAULT_WINDOW_DAYS = 14; +const DEFAULT_MIN_READINGS = 14; +const DEFAULT_MIN_SPAN_DAYS = 7; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// ── Output shapes ──────────────────────────────────────── + +/** + * Battelino 2019 time-in-range distribution as fractions of readings (0–1). + * `*Minutes` carry the minutes-equivalent over a 24h day (fraction × 1440) so + * the UI can echo the consensus " 0.70. */ + tir: number; + /** % readings G < 70 (includes the very-low sub-band). Goal < 0.04. */ + tbrLevel1: number; + /** % readings G < 54. Goal < 0.01. */ + tbrLevel2: number; + /** % readings G > 180 (includes the very-high sub-band). Goal < 0.25. */ + tarLevel1: number; + /** % readings G > 250. Goal < 0.05. */ + tarLevel2: number; + /** Minutes-of-a-day equivalents (fraction × 1440). */ + minutesEquivalent: { + tir: number; + tbrLevel1: number; + tbrLevel2: number; + tarLevel1: number; + tarLevel2: number; + }; +} + +/** Variability summary: SD + CV% with the Monnier stability flag. */ +export interface GlucoseVariability { + /** Sample standard deviation (n−1), mg/dL. */ + sd: number; + /** Coefficient of variation, percent: SD / mean × 100. */ + cv: number; + /** True when CV% ≥ 36 (Monnier 2017 instability cutoff). */ + unstable: boolean; +} + +/** The full clinical-metrics result for a window of spot readings. */ +export interface GlucoseClinicalMetrics { + /** + * When true, the window has too few readings or too short a span to assert a + * clinically meaningful estimate. The numeric fields below are still + * populated from whatever data exists (so a calm preview can render) but MUST + * NOT be presented as a clinical assessment. The UI should render a + * "still learning — N readings over D days" state. + */ + stillLearning: boolean; + /** Human/UI-facing reason for the gate (null once it clears). */ + stillLearningReason: string | null; + + /** Declared clinical window in days (the requested `windowDays`). */ + windowDays: number; + /** Actual span the in-window readings cover (first→last), in days. */ + actualSpanDays: number; + /** Number of readings used (those falling inside the window). */ + readingCount: number; + + /** Arithmetic mean glucose over the window, mg/dL (null if no readings). */ + meanMgdl: number | null; + + /** Battelino 2019 TIR/TBR/TAR distribution (null if no readings). */ + distribution: TimeInRangeDistribution | null; + /** Bergenstal 2018 Glucose Management Indicator, percent (null if no data). */ + gmi: number | null; + /** Nathan 2008 estimated A1C, percent (null if no data). */ + estimatedA1c: number | null; + /** SD + CV% + instability flag (null if < 2 readings). */ + variability: GlucoseVariability | null; + + /** + * Always true for this module: every number is a spot-reading estimate, not a + * CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the + * caveat without re-deriving it. + */ + isSpotEstimate: true; +} + +// ── Core formulas ──────────────────────────────────────── + +/** Arithmetic mean. Caller guarantees a non-empty array. */ +function mean(values: number[]): number { + let sum = 0; + for (const v of values) sum += v; + return sum / values.length; +} + +/** + * Sample standard deviation, sqrt(Σ(Gᵢ−mean)² / (n−1)). + * Standard variability foundation reported alongside mean in Battelino 2019. + * Returns null for n < 2 (undefined sample SD). + */ +export function glucoseSD(values: number[]): number | null { + const n = values.length; + if (n < 2) return null; + const m = mean(values); + let ss = 0; + for (const v of values) { + const d = v - m; + ss += d * d; + } + return Math.sqrt(ss / (n - 1)); +} + +/** + * Coefficient of variation (%) with the Monnier 2017 instability flag. + * %CV = SD / mean × 100; CV% ≥ 36 → unstable. + * Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769; + * endorsed in Battelino 2019. + */ +export function glucoseVariability(values: number[]): GlucoseVariability | null { + const sd = glucoseSD(values); + if (sd === null) return null; + const m = mean(values); + if (m === 0) return null; + const cv = (sd / m) * 100; + return { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD }; +} + +/** + * Glucose Management Indicator (GMI), percent, from mean glucose in mg/dL. + * GMI% = 3.31 + 0.02392 × mean(mg/dL). + * Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581. + */ +export function gmi(meanMgdl: number): number { + return 3.31 + 0.02392 * meanMgdl; +} + +/** + * Estimated A1C (%) from mean glucose in mg/dL (ADAG inverse of eAG). + * eAG = 28.7 × A1C − 46.7 ⇒ A1C% = (mean + 46.7) / 28.7. + * Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545. + * Preferred for spot/SMBG data; GMI is the CGM-native sibling. + */ +export function estimatedA1c(meanMgdl: number): number { + return (meanMgdl + 46.7) / 28.7; +} + +/** + * Time-in-range distribution over the Battelino 2019 consensus bands, as + * fractions of readings (NOT % of time — spot data, labelled honestly). + * TIR : 70 ≤ G ≤ 180 (goal > 0.70) + * TBR1 : G < 70 (goal < 0.04) + * TBR2 : G < 54 (goal < 0.01) + * TAR1 : G > 180 (goal < 0.25) + * TAR2 : G > 250 (goal < 0.05) + * TBR2 ⊆ TBR1 and TAR2 ⊆ TAR1 (the consensus reports level-2 as a sub-band of + * level-1), so the five fractions are NOT mutually exclusive by design. + * Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028. + */ +export function timeInRange( + values: number[], +): TimeInRangeDistribution | null { + const n = values.length; + if (n === 0) return null; + + let inRange = 0; + let below70 = 0; + let below54 = 0; + let above180 = 0; + let above250 = 0; + + for (const g of values) { + if (g < TBR_LEVEL1_MAX) { + below70 += 1; + if (g < TBR_LEVEL2_MAX) below54 += 1; + } else if (g > TAR_LEVEL1_MIN) { + above180 += 1; + if (g > TAR_LEVEL2_MIN) above250 += 1; + } else { + // 70 ≤ G ≤ 180 + inRange += 1; + } + } + + const tir = inRange / n; + const tbrLevel1 = below70 / n; + const tbrLevel2 = below54 / n; + const tarLevel1 = above180 / n; + const tarLevel2 = above250 / n; + const MIN_PER_DAY = 1440; + + return { + tir, + tbrLevel1, + tbrLevel2, + tarLevel1, + tarLevel2, + minutesEquivalent: { + tir: tir * MIN_PER_DAY, + tbrLevel1: tbrLevel1 * MIN_PER_DAY, + tbrLevel2: tbrLevel2 * MIN_PER_DAY, + tarLevel1: tarLevel1 * MIN_PER_DAY, + tarLevel2: tarLevel2 * MIN_PER_DAY, + }, + }; +} + +// ── Window + adequacy orchestration ────────────────────── + +/** + * Compute the full clinical-metrics panel for a set of spot readings. + * + * Pins a declared clinical window (default 14d, Battelino 2019), reports the + * actual span + reading count used, and applies the learning/adequacy gate so + * the caller never presents a thin-data estimate as a clinical assessment. + * + * Pure: no Prisma, no I/O. `now` is injectable for deterministic tests. + */ +export function computeGlucoseClinicalMetrics( + readings: GlucoseReading[], + options: GlucoseMetricsOptions = {}, +): GlucoseClinicalMetrics { + const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = options.now ?? new Date(); + const minReadings = options.minReadings ?? DEFAULT_MIN_READINGS; + const minSpanDays = options.minSpanDays ?? DEFAULT_MIN_SPAN_DAYS; + + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const inWindow = readings + .filter( + (r) => + r.measuredAt.getTime() >= cutoff && + r.measuredAt.getTime() <= now.getTime() && + Number.isFinite(r.mgdl), + ) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const readingCount = inWindow.length; + const actualSpanDays = + readingCount >= 2 + ? (inWindow[readingCount - 1].measuredAt.getTime() - + inWindow[0].measuredAt.getTime()) / + MS_PER_DAY + : 0; + + const values = inWindow.map((r) => r.mgdl); + const meanMgdl = values.length > 0 ? mean(values) : null; + + const distribution = timeInRange(values); + const variability = glucoseVariability(values); + const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null; + const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null; + + // Learning/adequacy gate. Spot data cannot meet the CGM adequacy bar; this + // is the honest "is the estimate worth asserting yet" check. Order matters: + // report the most actionable reason first. + let stillLearning = false; + let stillLearningReason: string | null = null; + + if (readingCount === 0) { + stillLearning = true; + stillLearningReason = `No glucose readings in the last ${windowDays} days.`; + } else if (readingCount < minReadings) { + stillLearning = true; + stillLearningReason = `Still learning — ${readingCount} reading${ + readingCount === 1 ? "" : "s" + } over ${roundSpan(actualSpanDays)} day${ + roundSpan(actualSpanDays) === 1 ? "" : "s" + }; at least ${minReadings} are needed for a meaningful estimate.`; + } else if (actualSpanDays < minSpanDays) { + stillLearning = true; + stillLearningReason = `Still learning — readings span only ${roundSpan( + actualSpanDays, + )} day${ + roundSpan(actualSpanDays) === 1 ? "" : "s" + }; at least ${minSpanDays} days of coverage are needed.`; + } + + return { + stillLearning, + stillLearningReason, + windowDays, + actualSpanDays, + readingCount, + meanMgdl, + distribution, + gmi: gmiValue, + estimatedA1c: eA1cValue, + variability, + isSpotEstimate: true, + }; +} + +/** Round a span to whole days for the human-facing learning message. */ +function roundSpan(spanDays: number): number { + return Math.max(0, Math.round(spanDays)); +} From edaa345f4ec555474b399b1029b6ca3a4c23081c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:32:40 +0200 Subject: [PATCH 02/45] feat(insights): add sleep-debt and MCTQ chronotype math --- .../derived/__tests__/chronotype.test.ts | 158 +++++++++++++ .../derived/__tests__/sleep-debt.test.ts | 102 +++++++++ src/lib/insights/derived/chronotype.ts | 214 ++++++++++++++++++ src/lib/insights/derived/sleep-debt.ts | 158 +++++++++++++ 4 files changed, 632 insertions(+) create mode 100644 src/lib/insights/derived/__tests__/chronotype.test.ts create mode 100644 src/lib/insights/derived/__tests__/sleep-debt.test.ts create mode 100644 src/lib/insights/derived/chronotype.ts create mode 100644 src/lib/insights/derived/sleep-debt.ts diff --git a/src/lib/insights/derived/__tests__/chronotype.test.ts b/src/lib/insights/derived/__tests__/chronotype.test.ts new file mode 100644 index 000000000..b607ddd4a --- /dev/null +++ b/src/lib/insights/derived/__tests__/chronotype.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest"; +import { + computeChronotype, + bandForMSFsc, + type ChronotypeNight, +} from "../chronotype"; + +/** Minutes-of-day for HH:MM. */ +function hm(h: number, m = 0): number { + return h * 60 + m; +} + +function freeNight( + day: number, + midpointMinutes: number, + asleepMinutes: number, +): ChronotypeNight { + return { + night: `2026-06-${String(day).padStart(2, "0")}`, + midpointMinutes, + asleepMinutes, + dayType: "free", + }; +} + +function workNight( + day: number, + midpointMinutes: number, + asleepMinutes: number, +): ChronotypeNight { + return { + night: `2026-06-${String(day).padStart(2, "0")}`, + midpointMinutes, + asleepMinutes, + dayType: "work", + }; +} + +describe("bandForMSFsc", () => { + it("bands MSFsc minutes-of-day into MCTQ classes", () => { + expect(bandForMSFsc(hm(2, 0))).toBe("extreme_early"); // 02:00 + expect(bandForMSFsc(hm(3, 0))).toBe("early"); // 03:00 + expect(bandForMSFsc(hm(4, 0))).toBe("intermediate"); // 04:00 + expect(bandForMSFsc(hm(5, 30))).toBe("late"); // 05:30 + expect(bandForMSFsc(hm(7, 0))).toBe("extreme_late"); // 07:00 + }); + + it("normalises a midnight-straddling / corrected value onto the clock", () => { + expect(bandForMSFsc(hm(24, 0) + hm(1, 0))).toBe("extreme_early"); // 25:00→01:00 + expect(bandForMSFsc(-30)).toBe("extreme_late"); // −0:30 → 23:30 + }); +}); + +describe("computeChronotype", () => { + it("computes MSF + band from a free-day mid-sleep set (no debt correction)", () => { + // Three free nights all centred at 04:00, equal-length → SDf == SDweek → + // MSFsc == MSF == 240 → intermediate. + const res = computeChronotype([ + freeNight(1, hm(4), 480), + freeNight(2, hm(4), 480), + freeNight(3, hm(4), 480), + ]); + expect(res.state).toBe("ready"); + expect(res.msfMinutes).toBeCloseTo(240, 6); + expect(res.msfScMinutes).toBeCloseTo(240, 6); + expect(res.band).toBe("intermediate"); + }); + + it("applies the MSFsc sleep-debt correction when free days oversleep", () => { + // 3 free nights @ 04:00 / 9 h (540) + 3 work nights @ 03:00 / 6 h (360). + // SDf = 540; SDweek = (540·3 + 360·3)/6 = 450; oversleep = 90. + // MSFsc = 240 − 0.5·90 = 195 → "early" (< 03:30). + const res = computeChronotype([ + freeNight(1, hm(4), 540), + freeNight(2, hm(4), 540), + freeNight(3, hm(4), 540), + workNight(4, hm(3), 360), + workNight(5, hm(3), 360), + workNight(6, hm(3), 360), + ]); + expect(res.msfMinutes).toBeCloseTo(240, 6); + expect(res.msfScMinutes).toBeCloseTo(195, 6); + expect(res.band).toBe("early"); // 195 → early (< 03:30) + }); + + it("computes social jetlag as circular |MSF_work − MSF_free|", () => { + // Free mid-sleep 04:00 (240), work mid-sleep 03:00 (180) → SJL = 60. + const res = computeChronotype([ + freeNight(1, hm(4), 480), + freeNight(2, hm(4), 480), + freeNight(3, hm(4), 480), + workNight(4, hm(3), 480), + workNight(5, hm(3), 480), + ]); + expect(res.socialJetlagMinutes).toBeCloseTo(60, 6); + expect(res.workNightsCounted).toBe(2); + expect(res.freeNightsCounted).toBe(3); + }); + + it("measures social jetlag across midnight by the shorter arc", () => { + // Work mid-sleep 23:30 (1410), free mid-sleep 00:30 (30) → circular 60, + // not 1380 — the midnight-straddle handling from circularMinuteDistance. + const res = computeChronotype([ + freeNight(1, hm(0, 30), 480), + freeNight(2, hm(0, 30), 480), + freeNight(3, hm(0, 30), 480), + workNight(4, hm(23, 30), 480), + ]); + expect(res.socialJetlagMinutes).toBeCloseTo(60, 6); + }); + + it("fires the learning gate under the free-night threshold", () => { + const res = computeChronotype( + [freeNight(1, hm(4), 480), freeNight(2, hm(4), 480)], + { minFreeNights: 3 }, + ); + expect(res.state).toBe("learning"); + expect(res.band).toBeNull(); + expect(res.msfMinutes).toBeNull(); + expect(res.msfScMinutes).toBeNull(); + expect(res.freeNightsCounted).toBe(2); + expect(res.freeNightsUntilReady).toBe(1); // 3 − 2 + }); + + it("clears the learning gate at the threshold", () => { + const res = computeChronotype( + [ + freeNight(1, hm(4), 480), + freeNight(2, hm(4), 480), + freeNight(3, hm(4), 480), + ], + { minFreeNights: 3 }, + ); + expect(res.state).toBe("ready"); + expect(res.band).not.toBeNull(); + }); + + it("work-only data stays learning and reports no social jetlag", () => { + const res = computeChronotype([ + workNight(1, hm(3), 480), + workNight(2, hm(3), 480), + workNight(3, hm(3), 480), + ]); + expect(res.state).toBe("learning"); + expect(res.socialJetlagMinutes).toBeNull(); // no free side to compare + expect(res.freeNightsCounted).toBe(0); + }); + + it("ignores nights with zero asleep minutes", () => { + const res = computeChronotype([ + freeNight(1, hm(4), 480), + freeNight(2, hm(4), 0), // dropped + freeNight(3, hm(4), 480), + ]); + expect(res.freeNightsCounted).toBe(2); + expect(res.state).toBe("learning"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-debt.test.ts b/src/lib/insights/derived/__tests__/sleep-debt.test.ts new file mode 100644 index 000000000..5ff39fbf1 --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-debt.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + computeSleepDebt, + type SleepDebtNight, +} from "../sleep-debt"; + +/** Build a contiguous run of nights ending YYYY-06-?? with given asleep mins. */ +function nights(asleepPerNight: number[]): SleepDebtNight[] { + return asleepPerNight.map((asleepMinutes, i) => ({ + night: `2026-06-${String(i + 1).padStart(2, "0")}`, + asleepMinutes, + })); +} + +const NEED = 480; // 8 h need, pinned so the deficit math is exact. + +describe("computeSleepDebt", () => { + it("sums a deficit sequence into the exact cumulative debt", () => { + // need 480; nights of 420 / 360 / 480 / 450 → deficits 60 / 120 / 0 / 30. + const res = computeSleepDebt(nights([420, 360, 480, 450]), NEED, { + minNights: 1, + }); + expect(res.state).toBe("ready"); + expect(res.debtMinutes).toBe(60 + 120 + 0 + 30); // 210 + expect(res.perNight.map((n) => n.deficitMinutes)).toEqual([60, 120, 0, 30]); + expect(res.nightsCounted).toBe(4); + expect(res.needMinutes).toBe(NEED); + }); + + it("never mints negative deficit from catch-up sleep", () => { + // A 10 h night (600) is 120 over need — deficit floors at 0, not −120. + const res = computeSleepDebt(nights([600, 600]), NEED, { minNights: 1 }); + expect(res.debtMinutes).toBe(0); + expect(res.perNight.every((n) => n.deficitMinutes === 0)).toBe(true); + }); + + it("caps a single catastrophic night's deficit", () => { + // A 1 h night (60) is 420 short; the per-night cap (180) holds. + const res = computeSleepDebt(nights([60]), NEED, { + minNights: 1, + maxNightlyDeficitMinutes: 180, + }); + expect(res.perNight[0].deficitMinutes).toBe(180); + expect(res.debtMinutes).toBe(180); + }); + + it("caps the cumulative total", () => { + // Ten 0 h nights × 180 cap = 1800 raw, clamped to 1200 total cap. + const res = computeSleepDebt(nights(Array(10).fill(0)), NEED, { + minNights: 1, + maxTotalDebtMinutes: 1200, + }); + expect(res.debtMinutes).toBe(1200); + }); + + it("takes only the most recent windowNights and sorts oldest→newest", () => { + // 16 nights of 420 (60 short each); window 14 → 14 × 60 = 840. + const res = computeSleepDebt(nights(Array(16).fill(420)), NEED, { + windowNights: 14, + }); + expect(res.nightsCounted).toBe(14); + expect(res.debtMinutes).toBe(14 * 60); + expect(res.windowNights).toBe(14); + // Oldest kept night is 2026-06-03 (06-01 and 06-02 aged out). + expect(res.perNight[0].night).toBe("2026-06-03"); + expect(res.perNight.at(-1)?.night).toBe("2026-06-16"); + }); + + it("debt drains as deficit nights age out of the rolling window", () => { + // Two short nights then twelve full ones, window 14 → only the short + // nights inside the window count; thirteen full + one short → drains. + const fourteen = nights([360, ...Array(13).fill(480)]); // 1 short, 13 full + const res = computeSleepDebt(fourteen, NEED, { windowNights: 14 }); + expect(res.debtMinutes).toBe(120); // only the 360 night carries debt + }); + + it("returns a calm partial state under the night threshold", () => { + const res = computeSleepDebt(nights([300, 300, 300]), NEED, { + minNights: 7, + }); + expect(res.state).toBe("partial"); + expect(res.nightsCounted).toBe(3); + expect(res.nightsUntilReady).toBe(4); // 7 − 3 + // It still reports the running figure so the UI can show a soft preview. + expect(res.debtMinutes).toBe(3 * 180); // each night 180 short + }); + + it("clears the partial state at or above the threshold", () => { + const res = computeSleepDebt(nights(Array(7).fill(420)), NEED, { + minNights: 7, + }); + expect(res.state).toBe("ready"); + expect(res.nightsUntilReady).toBe(0); + }); + + it("guards a non-positive need (no fabricated debt)", () => { + const res = computeSleepDebt(nights([300, 300]), 0, { minNights: 1 }); + expect(res.state).toBe("partial"); + expect(res.debtMinutes).toBe(0); + expect(res.needMinutes).toBe(0); + }); +}); diff --git a/src/lib/insights/derived/chronotype.ts b/src/lib/insights/derived/chronotype.ts new file mode 100644 index 000000000..11d2fa052 --- /dev/null +++ b/src/lib/insights/derived/chronotype.ts @@ -0,0 +1,214 @@ +/** + * v1.17.0 — MCTQ chronotype + social jetlag (server-authoritative). + * + * Implements the Munich ChronoType Questionnaire derivation from observed + * sleep, not a survey: Roenneberg, Wirz-Justice & Merrow (2003), *J Biol + * Rhythms* 18(1):80-90; Roenneberg et al. (2007), *Sleep Med Rev* 11(6):429- + * 438. The chronotype marker is MID-SLEEP ON FREE DAYS (MSF) — the clock time + * halfway between sleep onset and wake on days without an alarm — corrected + * for the sleep debt people pay off by sleeping in on free days (MSFsc). + * + * MSF = circular mean of free-day sleep midpoints (minutes-of-day). + * MSFsc = MSF − 0.5·(SDf − SDweek) when SDf > SDweek, else MSF + * where SDf = average free-day sleep DURATION, + * SDweek = average sleep duration across work + free days. + * SJL = social jetlag = circular |MSF_work − MSF_free|. + * + * The MSFsc correction (Roenneberg 2007) removes the morning "oversleep" that + * a sleep-debt rebound adds to the free-day midpoint, so a workweek-deprived + * sleeper is not mislabelled later than their true phase. + * + * ONE MIDPOINT ESTIMATOR + * ---------------------- + * The per-night `midpointMinutes` is the SAME minutes-of-day midpoint the + * Sleep Score's Timing sub-score reads (centre of the canonical asleep span, + * in the user's wall clock). The caller supplies it; chronotype does NOT + * re-derive a second midpoint — and it reuses `circularMeanMinutes` / + * `circularMinuteDistance` from the Sleep Score so the midnight-straddle / DST + * handling is identical. Pure — no Prisma, no I/O; route, Coach, PDF, and the + * iOS serializer all reuse it. + * + * LEARNING GATE + * ------------- + * Chronotype is a stable trait and a thin sample is noise. Below + * `minFreeNights` free-day nights the result is a calm "still learning — N of + * M nights" state and asserts NO type. This mirrors the Sleep Score's + * "< 3 nights → null" timing gate; the band only appears once the free-day + * sample is large enough to mean something. + * + * FREE vs WORK + * ------------ + * The app has no work calendar, so the day-type signal is an INPUT per night + * (the caller maps weekday/weekend or a future shift schedule). A modelling + * assumption, surfaced as such — not hardcoded here. + */ +import { + circularMeanMinutes, + circularMinuteDistance, +} from "./sleep-score"; + +const MINUTES_PER_DAY = 1440; + +/** One night fed to the chronotype estimator. */ +export interface ChronotypeNight { + /** Wake-day key (YYYY-MM-DD), user timezone. */ + night: string; + /** + * Sleep midpoint as minutes-of-day (0..1439) in the user's wall clock — the + * SAME estimator the Sleep Score Timing sub-score uses. The caller passes it; + * this module does not re-derive a midpoint. + */ + midpointMinutes: number; + /** Canonical time-asleep minutes (`SleepNight.asleepMinutes`) for SD math. */ + asleepMinutes: number; + /** Day type — caller-supplied (no work calendar is hardcoded here). */ + dayType: "work" | "free"; +} + +export interface ChronotypeOptions { + /** + * Minimum free-day nights before a chronotype band is asserted. MCTQ needs a + * stable free-day estimate; below this the result stays in the calm learning + * state. 3 mirrors the Sleep Score's timing/consistency floor — enough to + * dampen a single anomalous lie-in without demanding a month of data. + */ + minFreeNights?: number; +} + +const DEFAULTS: Required = { + minFreeNights: 3, +}; + +/** + * MCTQ chronotype band off MSFsc (minutes-of-day). The cut points follow the + * Roenneberg population percentiles (extreme-early through extreme-late around + * a ~04:00 mid-sleep mode). One banding helper — never re-thresholded per + * surface. + */ +export type ChronotypeBand = + | "extreme_early" + | "early" + | "intermediate" + | "late" + | "extreme_late"; + +/** + * Band a corrected mid-sleep (MSFsc, minutes-of-day) into the MCTQ chronotype + * classes. Cut points (local clock): < 02:30 extreme-early, < 03:30 early, + * < 05:00 intermediate, < 06:00 late, else extreme-late — the standard MCTQ + * MSFsc deciles centred on the ~04:00 population mode. + */ +export function bandForMSFsc(msfscMinutes: number): ChronotypeBand { + // Normalise onto [0,1440) so a value that the correction pushed below 0 or a + // late-night midpoint past midnight still bands on the wall clock. + const m = ((msfscMinutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % + MINUTES_PER_DAY; + if (m < 150) return "extreme_early"; // < 02:30 + if (m < 210) return "early"; // < 03:30 + if (m < 300) return "intermediate"; // < 05:00 + if (m < 360) return "late"; // < 06:00 + return "extreme_late"; +} + +export interface ChronotypeResult { + /** + * `learning` until `minFreeNights` free-day nights exist — no band asserted. + * `ready` once the free-day sample is large enough. + */ + state: "learning" | "ready"; + /** Mid-sleep on free days (minutes-of-day), or null while learning. */ + msfMinutes: number | null; + /** Sleep-debt-corrected mid-sleep on free days (minutes-of-day), or null. */ + msfScMinutes: number | null; + /** Chronotype band off MSFsc, or null while learning. */ + band: ChronotypeBand | null; + /** + * Social jetlag in minutes = circular |MSF_work − MSF_free|. Null when there + * is no work-day OR no free-day midpoint to compare (one side missing). + */ + socialJetlagMinutes: number | null; + /** Free-day nights counted. */ + freeNightsCounted: number; + /** Work-day nights counted. */ + workNightsCounted: number; + /** `minFreeNights` − `freeNightsCounted` while learning, else 0. */ + freeNightsUntilReady: number; +} + +function mean(values: readonly number[]): number | null { + if (values.length === 0) return null; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +/** + * Derive MCTQ chronotype, MSFsc, and social jetlag from per-night midpoints + + * day types. Pure. Holds the learning gate until enough free-day nights exist. + */ +export function computeChronotype( + nights: readonly ChronotypeNight[], + options: ChronotypeOptions = {}, +): ChronotypeResult { + const opts = { ...DEFAULTS, ...options }; + const usable = nights.filter( + (n) => + Number.isFinite(n.midpointMinutes) && + Number.isFinite(n.asleepMinutes) && + n.asleepMinutes > 0, + ); + const free = usable.filter((n) => n.dayType === "free"); + const work = usable.filter((n) => n.dayType === "work"); + + const freeNightsCounted = free.length; + const workNightsCounted = work.length; + + // MSF = circular mean of free-day midpoints (handles the midnight straddle). + const msfMinutes = circularMeanMinutes(free.map((n) => n.midpointMinutes)); + // MSW = circular mean of work-day midpoints — only used for social jetlag. + const mswMinutes = circularMeanMinutes(work.map((n) => n.midpointMinutes)); + + // Social jetlag = circular distance between the work and free mid-sleeps. + // Both sides must exist; otherwise the offset is undefined (not zero). + const socialJetlagMinutes = + msfMinutes != null && mswMinutes != null + ? circularMinuteDistance(mswMinutes, msfMinutes) + : null; + + const ready = freeNightsCounted >= opts.minFreeNights && msfMinutes != null; + if (!ready) { + return { + state: "learning", + msfMinutes: null, + msfScMinutes: null, + band: null, + socialJetlagMinutes, + freeNightsCounted, + workNightsCounted, + freeNightsUntilReady: Math.max( + 0, + opts.minFreeNights - freeNightsCounted, + ), + }; + } + + // MSFsc correction (Roenneberg 2007): subtract half the free-day oversleep + // (free-day duration minus the week-average duration), applied only when + // people sleep LONGER on free days (paying off workweek debt). When SDf ≤ + // SDweek there is no debt rebound, so MSFsc = MSF. + const sdFree = mean(free.map((n) => n.asleepMinutes)) ?? 0; + const sdWeek = mean(usable.map((n) => n.asleepMinutes)) ?? sdFree; + const oversleep = Math.max(0, sdFree - sdWeek); + const msfScRaw = msfMinutes - 0.5 * oversleep; + const msfScMinutes = + ((msfScRaw % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; + + return { + state: "ready", + msfMinutes, + msfScMinutes, + band: bandForMSFsc(msfScMinutes), + socialJetlagMinutes, + freeNightsCounted, + workNightsCounted, + freeNightsUntilReady: 0, + }; +} diff --git a/src/lib/insights/derived/sleep-debt.ts b/src/lib/insights/derived/sleep-debt.ts new file mode 100644 index 000000000..573bc4232 --- /dev/null +++ b/src/lib/insights/derived/sleep-debt.ts @@ -0,0 +1,158 @@ +/** + * v1.17.0 — cumulative sleep debt (server-authoritative timing math). + * + * Sleep debt is the running shortfall between how much sleep a person NEEDS + * and how much they actually got, accumulated over a rolling window. Per + * night the deficit is `max(0, need − asleep)` — only undersleep accrues + * debt; a night of catch-up sleep does NOT mint a negative deficit that + * silently erases yesterday's shortfall (the body does not bank surplus sleep + * minute-for-minute; Van Dongen et al. 2003, *Sleep* 26(2):117-126 show the + * recovery curve is sub-linear). Catch-up instead pays the running debt DOWN + * through the rolling window: as a deficit night ages out of the window it + * stops contributing, so a stretch of full nights drains the debt toward zero. + * + * ONE ENGINE, NO DUPLICATION + * -------------------------- + * This module is PURE — it consumes the canonical per-night `asleepMinutes` + * the shared sleep engine (`reconstructSleepNights`) already produces and + * takes the sleep `need` as a parameter (the caller resolves it via the one + * existing `sleepNeedMinutes(ageYears)` table — there is no second need table + * here). "actual" MUST be the canonical asleep total, never a divergent + * reconstruction, so this figure can never contradict the dashboard / Sleep + * Score / hypnogram for the same night. No Prisma, no I/O — the route, Coach + * snapshot, doctor PDF, and iOS serializer all reuse it. + * + * Composition with the Sleep Score: Sufficiency (`scoreSufficiency`) is the + * SINGLE-NIGHT view of `need − actual` expressed 0–100; this is the ROLLING + * CUMULATIVE of the same `need − actual`. Same inputs, two framings — a + * timing/trend signal, not a second sleep-adequacy score. + */ + +/** One night's canonical asleep total, keyed by its wake day. */ +export interface SleepDebtNight { + /** Wake-day key (YYYY-MM-DD), user timezone — from the canonical engine. */ + night: string; + /** Canonical time-asleep minutes for the night (`SleepNight.asleepMinutes`). */ + asleepMinutes: number; +} + +/** Tunable knobs; every default is documented inline. */ +export interface SleepDebtOptions { + /** + * Rolling window length in nights. 14 is the standard two-week sleep-debt + * horizon used across the consumer-sleep literature — long enough that a + * single bad night does not dominate, short enough that month-old debt no + * longer shapes today's signal. + */ + windowNights?: number; + /** + * Minimum tracked nights before the cumulative debt is asserted. Below this + * the result is a calm PARTIAL state ("still learning") rather than a + * confident total off a near-empty window — a one-night window would read a + * single rough night as a fortnight of debt. + */ + minNights?: number; + /** + * Per-night deficit cap (minutes). One catastrophic night (a 2-hour sleep, + * or a missing-stage artefact) must not swamp a fortnight of otherwise-fine + * sleep; 180 min (3 h) is the largest single-night shortfall that still + * reads as "a rough night" rather than a data glitch. + */ + maxNightlyDeficitMinutes?: number; + /** + * Cumulative-debt cap (minutes). Beyond a point more debt is not + * meaningfully more actionable — the message is the same ("you are deeply + * under-slept"). 1200 min (20 h) caps the headline so the bar / number + * stays legible and a long gap of untracked-then-rough nights cannot mint + * an absurd total. + */ + maxTotalDebtMinutes?: number; +} + +const DEFAULTS: Required = { + windowNights: 14, + minNights: 7, + maxNightlyDeficitMinutes: 180, + maxTotalDebtMinutes: 1200, +}; + +/** Per-night deficit detail, oldest → newest, for the trend strip / tooltip. */ +export interface SleepDebtNightDetail { + night: string; + asleepMinutes: number; + /** `max(0, need − asleep)` after the per-night cap, in minutes. */ + deficitMinutes: number; +} + +export interface SleepDebtResult { + /** + * `partial` until `minNights` tracked nights exist — the calm "still + * learning" state. `ready` once the window has enough nights to assert a + * cumulative debt. + */ + state: "partial" | "ready"; + /** Cumulative sleep debt in minutes over the window, after caps. */ + debtMinutes: number; + /** The sleep need (minutes) used for the deficit — echoed for transparency. */ + needMinutes: number; + /** Nights actually counted (≤ windowNights). */ + nightsCounted: number; + /** The configured rolling window length in nights. */ + windowNights: number; + /** `minNights` − `nightsCounted` while partial, else 0 — drives the nudge. */ + nightsUntilReady: number; + /** Per-night deficits over the window, oldest → newest. */ + perNight: SleepDebtNightDetail[]; +} + +/** + * Compute cumulative sleep debt over the trailing window. Pure. + * + * `nights` may arrive in any order and span more than the window — the most + * recent `windowNights` (by wake-day key) are taken. `needMinutes` is the + * caller-resolved age-based need (`sleepNeedMinutes`); passing it in keeps + * this module free of the need table and lets a test pin an exact value. + */ +export function computeSleepDebt( + nights: readonly SleepDebtNight[], + needMinutes: number, + options: SleepDebtOptions = {}, +): SleepDebtResult { + const opts = { ...DEFAULTS, ...options }; + const safeNeed = Number.isFinite(needMinutes) && needMinutes > 0 + ? needMinutes + : 0; + + // Take the most recent `windowNights` by wake-day key. Sort ascending so the + // perNight strip reads oldest → newest like every other trend surface. + const windowed = [...nights] + .filter((n) => Number.isFinite(n.asleepMinutes) && n.asleepMinutes >= 0) + .sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0)) + .slice(-opts.windowNights); + + const perNight: SleepDebtNightDetail[] = windowed.map((n) => { + const rawDeficit = Math.max(0, safeNeed - n.asleepMinutes); + // Cap one catastrophic night so it cannot swamp the fortnight. + const deficitMinutes = Math.min(rawDeficit, opts.maxNightlyDeficitMinutes); + return { + night: n.night, + asleepMinutes: n.asleepMinutes, + deficitMinutes, + }; + }); + + const nightsCounted = perNight.length; + const rawDebt = perNight.reduce((sum, n) => sum + n.deficitMinutes, 0); + const debtMinutes = Math.min(Math.round(rawDebt), opts.maxTotalDebtMinutes); + + const ready = nightsCounted >= opts.minNights && safeNeed > 0; + return { + state: ready ? "ready" : "partial", + debtMinutes, + needMinutes: safeNeed, + nightsCounted, + windowNights: opts.windowNights, + nightsUntilReady: ready ? 0 : Math.max(0, opts.minNights - nightsCounted), + perNight, + }; +} From 079be9d5af9927b0190451ddbcd6fa3d98d2465d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:44:51 +0200 Subject: [PATCH 03/45] feat(ui): add collapsible SheetSection with summary badge slot --- .../ui/__tests__/sheet-section.test.tsx | 74 ++++++++++++ src/components/ui/sheet-section.tsx | 107 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/components/ui/__tests__/sheet-section.test.tsx create mode 100644 src/components/ui/sheet-section.tsx diff --git a/src/components/ui/__tests__/sheet-section.test.tsx b/src/components/ui/__tests__/sheet-section.test.tsx new file mode 100644 index 000000000..b3f719c71 --- /dev/null +++ b/src/components/ui/__tests__/sheet-section.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SheetSection, SheetSectionCount } from "../sheet-section"; + +describe("", () => { + it("renders the title and summary slot on the trigger row", () => { + const html = renderToStaticMarkup( + summary-badge}> +

section body

+
, + ); + expect(html).toContain("More tags"); + expect(html).toContain("summary-badge"); + }); + + it("renders children inside an open section", () => { + // Radix Collapsible omits the content subtree while collapsed, so the + // body is asserted on an expanded section. + const html = renderToStaticMarkup( + +

section body

+
, + ); + expect(html).toContain("section body"); + }); + + it("starts collapsed by default and open when defaultOpen", () => { + const closed = renderToStaticMarkup( + +

x

+
, + ); + const open = renderToStaticMarkup( + +

x

+
, + ); + expect(closed).toContain('data-state="closed"'); + expect(open).toContain('data-state="open"'); + }); + + it("hides the leading icon from assistive tech", () => { + const html = renderToStaticMarkup( + }> +

x

+
, + ); + expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]* { + const html = renderToStaticMarkup( + +

y

+
, + ); + expect(html).toContain("motion-reduce:animate-none"); + expect(html).toContain("motion-reduce:transition-none"); + }); +}); + +describe("", () => { + it("renders the count when positive", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("3"); + expect(html).toContain('data-slot="sheet-section-count"'); + }); + + it("renders nothing for zero or negative counts", () => { + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); + }); +}); diff --git a/src/components/ui/sheet-section.tsx b/src/components/ui/sheet-section.tsx new file mode 100644 index 000000000..1eadf6028 --- /dev/null +++ b/src/components/ui/sheet-section.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import { ChevronDown } from "lucide-react"; +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +/** + * v1.17.0 — `SheetSection` is the collapsible disclosure primitive that + * powers the sectioned single-sheet add flows (mood, cycle). Each section + * carries a title, an optional leading icon, and a `summary` slot that + * communicates what is selected inside it while collapsed (e.g. a count + * badge) — so a closed section never hides its contents silently. + * + * Built on Radix Collapsible. The chevron rotates and the content height + * animates open/closed via the `tw-animate-css` `collapsible-down/up` + * keyframes; both motions are suppressed under `prefers-reduced-motion`. + * Matches the shadcn new-york + zinc token language used across the app. + */ + +interface SheetSectionProps { + /** Section heading shown on the always-visible trigger row. */ + title: string; + /** + * A node rendered on the trigger row's trailing edge — typically a count + * badge that reports what is selected inside while the section is + * collapsed. Stays visible whether the section is open or closed. + */ + summary?: React.ReactNode; + /** Optional leading icon (a Lucide component or any node). */ + icon?: React.ReactNode; + /** Whether the section starts expanded. Defaults to collapsed. */ + defaultOpen?: boolean; + /** Optional className merged onto the section wrapper. */ + className?: string; + children: React.ReactNode; +} + +export function SheetSection({ + title, + summary, + icon, + defaultOpen = false, + className, + children, +}: SheetSectionProps) { + return ( + + + {icon ? ( + + ) : null} + + {title} + + {summary ? ( + + {summary} + + ) : null} + + +
{children}
+
+
+ ); +} + +/** + * A compact "(N)" count badge for a `SheetSection` summary slot. Renders + * nothing when `count <= 0`, so a section with no selections shows a bare + * chevron rather than a "(0)". + */ +export function SheetSectionCount({ count }: { count: number }) { + if (count <= 0) return null; + return ( + + {count} + + ); +} From 524626d2bda9db817c065a8ba41c753a2454d47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:47:14 +0200 Subject: [PATCH 04/45] feat(sources): add Nightscout, Polar, Oura schema foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend MeasurementSource with NIGHTSCOUT, POLAR, OURA and add the per-user encrypted credential columns each source needs: a Nightscout base URL + token plus a private-host opt-in flag, and Polar/Oura OAuth tokens mirroring the WHOOP columns. Migration 0160 is additive only — new enum values, nullable columns, one boolean default false. Register the three sources across the read enum, the integration-status key set, and the source-priority ladders. --- docs/api/openapi.yaml | 330 ++++++++++++++++++ .../migration.sql | 52 +++ prisma/schema.prisma | 39 +++ src/lib/integrations/status.ts | 35 +- .../__tests__/source-priority.test.ts | 32 +- src/lib/validations/measurement.ts | 12 + src/lib/validations/source-priority.ts | 41 ++- 7 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/0160_v1170_new_measurement_sources/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ef3b07d43..5a859419a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1634,6 +1634,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - in: query name: groupBy schema: @@ -2080,6 +2083,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: type: string maxLength: 128 @@ -3083,6 +3089,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -3096,6 +3105,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -3109,6 +3121,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -3122,6 +3137,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -3135,6 +3153,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -3148,6 +3169,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -3161,6 +3185,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -3174,6 +3201,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -3187,6 +3217,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -3200,6 +3233,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -3213,6 +3249,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -3226,6 +3265,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -3239,6 +3281,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -3252,6 +3297,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -3265,6 +3313,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -3278,6 +3329,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -3291,6 +3345,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -3307,6 +3364,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -3320,6 +3380,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -3333,6 +3396,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -3346,6 +3412,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -3359,6 +3428,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -3372,6 +3444,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -3385,6 +3460,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -3398,6 +3476,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -3411,6 +3492,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -3424,6 +3508,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -3437,6 +3524,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -3450,6 +3540,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -3463,6 +3556,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -3476,6 +3572,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -3489,6 +3588,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -3502,6 +3604,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -3515,6 +3620,9 @@ paths: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA deviceTypePriority: type: object properties: @@ -8157,6 +8265,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA notes: anyOf: - type: string @@ -8583,6 +8694,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA notes: anyOf: - type: string @@ -8797,6 +8911,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - type: "null" start: type: string @@ -8914,6 +9031,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA - type: "null" start: type: string @@ -9120,6 +9240,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: anyOf: - type: string @@ -9225,6 +9348,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA externalId: anyOf: - type: string @@ -11176,6 +11302,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11189,6 +11318,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11202,6 +11334,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11215,6 +11350,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11228,6 +11366,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11241,6 +11382,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11254,6 +11398,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11267,6 +11414,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11280,6 +11430,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11293,6 +11446,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11306,6 +11462,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11319,6 +11478,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11332,6 +11494,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11345,6 +11510,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11358,6 +11526,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11371,6 +11542,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11384,6 +11558,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -11400,6 +11577,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11413,6 +11593,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11426,6 +11609,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11439,6 +11625,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11452,6 +11641,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11465,6 +11657,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11478,6 +11673,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11491,6 +11689,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11504,6 +11705,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11517,6 +11721,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11530,6 +11737,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11543,6 +11753,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11556,6 +11769,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11569,6 +11785,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11582,6 +11801,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11595,6 +11817,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11608,6 +11833,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object @@ -11670,6 +11898,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11683,6 +11914,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11696,6 +11930,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11709,6 +11946,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11722,6 +11962,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11735,6 +11978,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11748,6 +11994,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11761,6 +12010,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11774,6 +12026,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -11787,6 +12042,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -11800,6 +12058,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -11813,6 +12074,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -11826,6 +12090,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -11839,6 +12106,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -11852,6 +12122,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -11865,6 +12138,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -11878,6 +12154,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA metricPriority: type: object properties: @@ -11894,6 +12173,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA activeEnergy: maxItems: 8 type: array @@ -11907,6 +12189,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA walkingRunningDistance: maxItems: 8 type: array @@ -11920,6 +12205,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA flightsClimbed: maxItems: 8 type: array @@ -11933,6 +12221,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA sleep: maxItems: 8 type: array @@ -11946,6 +12237,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA weight: maxItems: 8 type: array @@ -11959,6 +12253,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bloodPressure: maxItems: 8 type: array @@ -11972,6 +12269,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA pulse: maxItems: 8 type: array @@ -11985,6 +12285,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyFat: maxItems: 8 type: array @@ -11998,6 +12301,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA bodyTemperature: maxItems: 8 type: array @@ -12011,6 +12317,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA spo2: maxItems: 8 type: array @@ -12024,6 +12333,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA hrv: maxItems: 8 type: array @@ -12037,6 +12349,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA restingHeartRate: maxItems: 8 type: array @@ -12050,6 +12365,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA vo2Max: maxItems: 8 type: array @@ -12063,6 +12381,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA skinTemperature: maxItems: 8 type: array @@ -12076,6 +12397,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA respiratoryRate: maxItems: 8 type: array @@ -12089,6 +12413,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA recovery: maxItems: 8 type: array @@ -12102,6 +12429,9 @@ components: - COMPUTED - WHOOP - FITBIT + - NIGHTSCOUT + - POLAR + - OURA additionalProperties: false deviceTypePriority: type: object diff --git a/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql b/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql new file mode 100644 index 000000000..8931b5dfb --- /dev/null +++ b/prisma/migrations/0160_v1170_new_measurement_sources/migration.sql @@ -0,0 +1,52 @@ +-- v1.17.0 — schema foundation for three new measurement sources: Nightscout +-- glucose (F1) and Polar + Oura OAuth wearables (F4). +-- +-- Purely-additive: three enum-extensions on `measurement_source` plus eight +-- new nullable columns on `users` (encrypted credentials + one boolean flag). +-- No backfill, no existing row touched, no new tables. The feature waves add +-- their own connection / oauth-state tables later; this step only unblocks +-- them from colliding on the enum + the credential surface. +-- +-- 1. `measurement_source` += `NIGHTSCOUT`, `POLAR`, `OURA` for the +-- server-side native ingest of each source. +-- 2. `users` += Nightscout base URL + token (encrypted) + an +-- `allow_private_host` opt-in boolean (default false) that lets a +-- self-hoster point HealthLog at a private/LAN Nightscout instance past +-- the public-host SSRF floor. +-- 3. `users` += Polar OAuth credentials (access / refresh token + user id), +-- encrypted, mirroring the WHOOP credential columns. +-- 4. `users` += Oura OAuth credentials (access / refresh token), encrypted, +-- mirroring the WHOOP credential columns. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so the three sources +-- stay; with no rows carrying them they are inert. The columns drop with +-- `DROP COLUMN IF EXISTS`. + +-- ── 1. measurement_source — append the three server-owned sources ────── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'NIGHTSCOUT'; +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'POLAR'; +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'OURA'; + +-- ── 2. users — Nightscout credentials + private-host opt-in ──────────── +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "nightscout_url_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "nightscout_token_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "nightscout_allow_private_host" BOOLEAN NOT NULL DEFAULT false; + +-- ── 3. users — Polar OAuth credentials (encrypted at app level) ──────── +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_access_token_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_refresh_token_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "polar_user_id_encrypted" TEXT; + +-- ── 4. users — Oura OAuth credentials (encrypted at app level) ───────── +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_access_token_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "oura_refresh_token_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a07aacde..313ef0ff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -109,6 +109,32 @@ model User { fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted") fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted") + // v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app + // level). A self-hoster points HealthLog at their own Nightscout instance: + // the base URL + the API token / secret are stored encrypted. By default + // the base URL must resolve to a public host (the SSRF floor); a self-hoster + // running Nightscout on a private/LAN network can flip + // `nightscoutAllowPrivateHost` to opt that instance past the public-host + // requirement explicitly. + nightscoutUrlEncrypted String? @map("nightscout_url_encrypted") + nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") + nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host") + + // v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level). + // Mirrors the WHOOP credential columns: the per-app authorized-user cap on + // Polar AccessLink makes a single shared app unworkable for a multi-operator + // product, so each self-hoster registers their own Polar app and the OAuth + // tokens are stored encrypted. + polarAccessTokenEncrypted String? @map("polar_access_token_encrypted") + polarRefreshTokenEncrypted String? @map("polar_refresh_token_encrypted") + polarUserIdEncrypted String? @map("polar_user_id_encrypted") + + // v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level). + // Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored + // encrypted. + ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") + ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") + // moodLog integration (per-user, encrypted) moodLogUrlEncrypted String? @map("mood_log_url_encrypted") moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted") @@ -790,6 +816,19 @@ enum MeasurementSource { // (no client write path); rejected on batch + single POST like WITHINGS/WHOOP. // iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows. FITBIT + // v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client + // write path); glucose rows ingest from the user's Nightscout instance. + // Rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO + // mirrors this exact spelling so /api/sync/changes decodes the rows. + NIGHTSCOUT + // v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write + // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS + // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. + POLAR + // v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write + // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS + // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. + OURA @@map("measurement_source") } diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 126baab0c..ef0fd09c9 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -39,7 +39,15 @@ import { auditLog } from "@/lib/auth/audit"; import { getEvent } from "@/lib/logging/context"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog"; +export type IntegrationKey = + | "withings" + | "whoop" + | "fitbit" + // v1.17.0 — Nightscout glucose (F1) + Polar / Oura OAuth (F4). + | "nightscout" + | "polar" + | "oura" + | "moodlog"; /** * Failure kinds carried into `recordSyncFailure`. @@ -774,6 +782,22 @@ export interface AlertInput { * kind is a one-row table edit instead of two more arms in two * different ternary stacks (the style-guide forbids nested ternaries). */ +/** + * Display label per `IntegrationKey`. A one-row table edit replaces the + * nested-ternary chain the admin-alert formatter used to carry — adding + * a new integration is a single line here instead of another arm in a + * ternary stack (the style-guide forbids nested ternaries). + */ +const INTEGRATION_LABELS: Record = { + withings: "Withings", + whoop: "WHOOP", + fitbit: "Fitbit", + nightscout: "Nightscout", + polar: "Polar", + oura: "Oura", + moodlog: "moodLog", +}; + const FAILURE_KIND_COPY: Record< FailureKind, { reason: string; action: string } @@ -819,14 +843,7 @@ export function formatAdminAlertPayload(input: AlertInput): { message: string; metadata: Record; } { - const integrationLabel = - input.integration === "withings" - ? "Withings" - : input.integration === "whoop" - ? "WHOOP" - : input.integration === "fitbit" - ? "Fitbit" - : "moodLog"; + const integrationLabel = INTEGRATION_LABELS[input.integration]; const subjectLabel = input.subjectLabel ?? input.userId; const { reason: reasonLabel, action: actionLabel } = FAILURE_KIND_COPY[input.kind]; diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index 856822b0c..b286cb052 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -217,11 +217,35 @@ describe("DEFAULT_SOURCE_PRIORITY", () => { } }); - it("ranks WHOOP native recovery above the COMPUTED proxy (v1.11)", () => { - // v1.11.0 — native-vs-derived: the device-native Recovery outranks + it("ranks device-native recovery above the COMPUTED proxy (v1.11/v1.17)", () => { + // v1.11.0 — native-vs-derived: a device-native Recovery outranks // HealthLog's COMPUTED proxy when both exist, with the proxy as the - // fallback for users without a strap. - expect(DEFAULT_SOURCE_PRIORITY.recovery).toEqual(["WHOOP", "COMPUTED"]); + // fallback for users without a wearable. v1.17.0 — Oura readiness and + // Polar nightly recovery slot between WHOOP and the proxy. + expect(DEFAULT_SOURCE_PRIORITY.recovery).toEqual([ + "WHOOP", + "OURA", + "POLAR", + "COMPUTED", + ]); + // The COMPUTED proxy stays the last resort regardless of how many + // native wearables precede it. + expect(DEFAULT_SOURCE_PRIORITY.recovery.at(-1)).toBe("COMPUTED"); + }); + + it("slots Polar + Oura below the established wearables (v1.17)", () => { + // v1.17.0 — Polar / Oura are worn wearables in the same overnight class + // as WHOOP/Fitbit; they rank below the established straps but above the + // iPhone-relayed HealthKit summary and the Withings nightly summary on + // the recovery-input ladders. + for (const key of ["sleep", "hrv", "restingHeartRate"] as const) { + const ladder = DEFAULT_SOURCE_PRIORITY[key]; + expect(ladder).toContain("OURA"); + expect(ladder).toContain("POLAR"); + expect(ladder.indexOf("OURA")).toBeLessThan(ladder.indexOf("APPLE_HEALTH")); + expect(ladder.indexOf("POLAR")).toBeLessThan(ladder.indexOf("APPLE_HEALTH")); + expect(ladder.indexOf("WHOOP")).toBeLessThan(ladder.indexOf("OURA")); + } }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 041e6259f..a10432006 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -143,6 +143,18 @@ export const measurementSourceEnum = z.enum([ // (no client write path), exactly like WHOOP. Part of this enum so the // read/response shapes (and the iOS decoder) can decode the rows it surfaces. "FITBIT", + // v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client + // write path), exactly like WHOOP/FITBIT. Part of this enum so the + // read/response shapes (and the iOS decoder) can decode the rows it surfaces. + "NIGHTSCOUT", + // v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write + // path), exactly like WHOOP/FITBIT. Part of this enum so the read/response + // shapes (and the iOS decoder) can decode the rows it surfaces. + "POLAR", + // v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write + // path), exactly like WHOOP/FITBIT. Part of this enum so the read/response + // shapes (and the iOS decoder) can decode the rows it surfaces. + "OURA", ]); /** diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 87895c498..866472632 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -226,27 +226,46 @@ export const DEFAULT_SOURCE_PRIORITY: Required = { // the iPhone-relayed HealthKit summary or the Withings nightly summary. // v1.12.0 — Fitbit is a wrist wearable in the same class as WHOOP but with // a lower-fidelity nightly estimate, so it ranks just below it. - sleep: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"], - hrv: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"], - restingHeartRate: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"], + // v1.17.0 — Polar + Oura are worn wearables in the same overnight-sampling + // class as WHOOP/Fitbit; they rank below the established straps but above + // the iPhone-relayed HealthKit summary and the Withings nightly summary. + sleep: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"], + hrv: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"], + restingHeartRate: [ + "WHOOP", + "FITBIT", + "OURA", + "POLAR", + "APPLE_HEALTH", + "WITHINGS", + ], // A real scale beats a strap's body-measurement estimate for weight. weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP", "FITBIT"], bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"], - pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], + pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "POLAR", "OURA"], bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"], // Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second, - // Fitbit third. - spo2: ["WITHINGS", "WHOOP", "FITBIT", "APPLE_HEALTH", "MANUAL"], + // Fitbit third, Oura's ring fourth. + spo2: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH", "MANUAL"], vo2Max: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "MANUAL"], // v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the - // primary skin-temperature sensor; WHOOP's strap is second, Fitbit third. - skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "APPLE_HEALTH"], - respiratoryRate: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"], + // primary skin-temperature sensor; WHOOP's strap is second, Fitbit third, + // Oura's ring fourth. + skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH"], + respiratoryRate: [ + "WHOOP", + "FITBIT", + "OURA", + "POLAR", + "APPLE_HEALTH", + "WITHINGS", + ], // v1.11.0 — native-vs-derived recovery. WHOOP's device-native Recovery // outranks HealthLog's COMPUTED proxy when both exist; the proxy is the - // fallback for users without a strap. - recovery: ["WHOOP", "COMPUTED"], + // fallback for users without a strap. v1.17.0 — Oura readiness + Polar + // nightly recovery slot below WHOOP and above the computed proxy. + recovery: ["WHOOP", "OURA", "POLAR", "COMPUTED"], }; /** From 9e1e285fe40d2a59d43bab4b2b0f89627582bd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:50:11 +0200 Subject: [PATCH 05/45] feat(mood): sectioned add sheet with quick-tag row and disclosure --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- .../mood/__tests__/recent-tags.test.ts | 73 ++++ src/components/mood/mood-form.tsx | 333 +++++++++++------- src/components/mood/mood-quick-tags.tsx | 135 +++++++ src/components/mood/mood-tag-picker.tsx | 30 +- src/components/mood/recent-tags.ts | 137 +++++++ src/components/ui/sheet-section.tsx | 11 +- 12 files changed, 611 insertions(+), 150 deletions(-) create mode 100644 src/components/mood/__tests__/recent-tags.test.ts create mode 100644 src/components/mood/mood-quick-tags.tsx create mode 100644 src/components/mood/recent-tags.ts diff --git a/messages/de.json b/messages/de.json index cfea1b804..d52a6fa23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Wetter", "iconGroupPlaces": "Orte", "iconGroupMisc": "Sonstiges" - } + }, + "sectionMoreTags": "Weitere Tags", + "sectionFactors": "Faktoren", + "sectionNoteDetails": "Notiz & Details", + "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", + "quickTagsExpand": "Weitere" }, "medications": { "title": "Medikamente", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..626c41fde 100644 --- a/messages/en.json +++ b/messages/en.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Weather", "iconGroupPlaces": "Places", "iconGroupMisc": "Other" - } + }, + "sectionMoreTags": "More tags", + "sectionFactors": "Factors", + "sectionNoteDetails": "Note & details", + "quickTagsHelp": "Your frequent tags — one tap", + "quickTagsExpand": "More" }, "medications": { "title": "Medications", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..83cf1f912 100644 --- a/messages/es.json +++ b/messages/es.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Tiempo", "iconGroupPlaces": "Lugares", "iconGroupMisc": "Otros" - } + }, + "sectionMoreTags": "Más etiquetas", + "sectionFactors": "Factores", + "sectionNoteDetails": "Nota y detalles", + "quickTagsHelp": "Tus etiquetas frecuentes: un toque", + "quickTagsExpand": "Más" }, "medications": { "title": "Medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..e49bd86fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Météo", "iconGroupPlaces": "Lieux", "iconGroupMisc": "Autres" - } + }, + "sectionMoreTags": "Plus de tags", + "sectionFactors": "Facteurs", + "sectionNoteDetails": "Note et détails", + "quickTagsHelp": "Vos tags fréquents — en un geste", + "quickTagsExpand": "Plus" }, "medications": { "title": "Médicaments", diff --git a/messages/it.json b/messages/it.json index 04730edac..6aa821428 100644 --- a/messages/it.json +++ b/messages/it.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Meteo", "iconGroupPlaces": "Luoghi", "iconGroupMisc": "Altro" - } + }, + "sectionMoreTags": "Altri tag", + "sectionFactors": "Fattori", + "sectionNoteDetails": "Nota e dettagli", + "quickTagsHelp": "I tuoi tag frequenti: un tocco", + "quickTagsExpand": "Altro" }, "medications": { "title": "Farmaci", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..ecfb13c1c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -720,7 +720,12 @@ "iconGroupWeather": "Pogoda", "iconGroupPlaces": "Miejsca", "iconGroupMisc": "Inne" - } + }, + "sectionMoreTags": "Więcej tagów", + "sectionFactors": "Czynniki", + "sectionNoteDetails": "Notatka i szczegóły", + "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", + "quickTagsExpand": "Więcej" }, "medications": { "title": "Leki", diff --git a/src/components/mood/__tests__/recent-tags.test.ts b/src/components/mood/__tests__/recent-tags.test.ts new file mode 100644 index 000000000..f9d9390ac --- /dev/null +++ b/src/components/mood/__tests__/recent-tags.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { promoteRecentTags, selectQuickTagKeys } from "../recent-tags"; + +describe("promoteRecentTags", () => { + it("prepends a freshly-used key, most-recent first", () => { + expect(promoteRecentTags(["a", "b"], ["c"])).toEqual(["c", "a", "b"]); + }); + + it("moves an already-present key to the front without duplicating", () => { + expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([ + "b", + "a", + "c", + ]); + }); + + it("keeps the latest of a multi-key use at the front", () => { + // The last key in `usedKeys` is the most recent → leads the list. + expect(promoteRecentTags([], ["x", "y", "z"])).toEqual(["z", "y", "x"]); + }); + + it("de-duplicates within the used keys", () => { + expect(promoteRecentTags(["a"], ["a", "a"])).toEqual(["a"]); + }); + + it("drops empty keys", () => { + expect(promoteRecentTags(["a"], ["", "b"])).toEqual(["b", "a"]); + }); + + it("caps the history length", () => { + const existing = Array.from({ length: 30 }, (_, i) => `k${i}`); + const result = promoteRecentTags(existing, ["new"]); + expect(result.length).toBe(24); + expect(result[0]).toBe("new"); + }); +}); + +describe("selectQuickTagKeys", () => { + const catalog = ["a", "b", "c", "d", "e"]; + + it("returns the MRU keys that still exist in the catalog", () => { + expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("drops MRU keys no longer in the catalog", () => { + expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([ + "b", + "a", + ]); + }); + + it("falls back to the first catalog keys with no history", () => { + expect(selectQuickTagKeys([], catalog, 3)).toEqual(["a", "b", "c"]); + }); + + it("respects the limit", () => { + expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([ + "e", + "d", + ]); + }); + + it("never duplicates when topping up from the catalog", () => { + const result = selectQuickTagKeys(["b"], catalog, 5); + expect(new Set(result).size).toBe(result.length); + expect(result[0]).toBe("b"); + }); +}); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index aa0fd6001..d827cf790 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -24,13 +24,27 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; -import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; +import { + ListChecks, + Loader2, + MoreHorizontal, + Plus, + RotateCcw, + SlidersHorizontal, + StickyNote, +} from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { + SheetSection, + SheetSectionCount, +} from "@/components/ui/sheet-section"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { MoodQuickTags } from "./mood-quick-tags"; +import { useRecentTags } from "./recent-tags"; import { moodFaceIcon } from "./mood-tag-icons"; // v1.12.0 — best-on-the-left face order for the "How are you?" hero, @@ -116,6 +130,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { // fields the user actively fills count toward "dirty"; the timestamp // always carries an auto-populated default, so it is excluded. const [resetConfirmOpen, setResetConfirmOpen] = useState(false); + // v1.17.0 — MRU tag history powers the always-open Quick row; tapping the + // Quick row's "+" expands the full "More tags" section (controlled so the + // chip can open it). + const { recent, recordUse } = useRecentTags(); + const [moreTagsOpen, setMoreTagsOpen] = useState(false); const isDirty = mood !== "" || tagsInput.trim() !== "" || @@ -193,7 +212,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { moodLoggedAt: timestamp, }); + // v1.17.0 — promote the saved structured tags to the Quick row's MRU + // history so the next entry surfaces them as one-tap chips. + if (tagKeys.length > 0) recordUse(tagKeys); resetForm(); + setMoreTagsOpen(false); await invalidateKeys(queryClient, moodDependentKeys); toast.success(t("common.saved")); onSuccess?.(); @@ -259,6 +282,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { // primary input and a fast log is a single tap + Save. const moodPicked = mood !== ""; + // v1.17.0 — the "Note & details" badge counts the free-text tags entered + // plus the note (when present), so a collapsed section communicates what + // it holds. The auto-populated timestamp is excluded (it always carries a + // default, so it would never read as "empty"). + const freeTextTagCount = tagsInput + .split(",") + .map((s) => s.trim()) + .filter(Boolean).length; + const detailsCount = freeTextTagCount + (note.trim() ? 1 : 0); + return (
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a @@ -318,152 +351,182 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { {moodPicked && ( -
-
- - setMoodLoggedAt(e.target.value)} - // v1.17 W1b — match the server bound: no future instant. - max={getDefaultMoodLoggedAtValue()} - required - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - /> -
+
+ {/* v1.17.0 — always-open Quick row: MRU one-tap tags + a "+" that + expands the full "More tags" section. The common case stays a + face + a couple of taps + Save. */} + setMoreTagsOpen(true)} + /> -
-
- - - {t("mood.tagsHelp")} - -
- setTagsInput(e.target.value)} - placeholder={t("mood.tagsPlaceholder")} - enterKeyHint="done" - autoCapitalize="none" - autoComplete="off" + {/* v1.17.0 — "More tags": the full structured-tag taxonomy + (binary tiles + custom). Controlled so the Quick row's "+" can + open it; the badge reports how many tags are selected. */} + } + open={moreTagsOpen} + onOpenChange={setMoreTagsOpen} + summary={} + > + - {/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip - appends the localised label to the free-text tag list. - Always visible for now (cheap UX; the Coach side-effect - aggregator filters on the canonical English tag set so the - German labels still register correctly). */} -
-

- {t("medications.sideEffectTagsHelp")} -

-
- {GLP1_SIDE_EFFECT_KEYS.map((key) => { - const label = t(key); - const tags = tagsInput - .split(",") - .map((p) => p.trim().toLowerCase()); - const isActive = tags.includes(label.toLowerCase()); - return ( - - ); - })} -
-
-
+ - {/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next - to the free-text input above; an entry can carry both axes. The - picker also surfaces the FACTOR-kind tags as 1..scaleMax rating - controls, lifted here as `ratedFactors`. */} -
- + {/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept + exactly as the existing segmented control. */} + } + summary={} + > -
+ - {/* v1.8.5 (C1) — free-text note. */} -
-
-