/** * Which of a day's values the view shows first, and how they pair up. * * Pure, so the curation can be pinned without rendering. The server sends one * row per reading for the types taken a few times a day (weight, blood * pressure, glucose) and one row per type otherwise, with the band already * decided. This orders the tiles, folds the two halves of a blood pressure * into one, because a reading of 138 over 88 is one thing to a person, not * two, and shows several readings of one type as their mean. The single * readings stay in the day's list of entries. */ import type { DayValue } from "@/lib/day/contract"; /** How many tiles the day shows before "All values". */ export const CURATED_VALUE_COUNT = 8; /** * The order a person reads a day in: the core vitals first, then sleep and * movement, then the rest. A type not listed comes after every listed one, * in the order the server sent it. */ export const VALUE_PRIORITY: readonly string[] = [ "BLOOD_PRESSURE_SYS", "RESTING_HEART_RATE", "PULSE", "WEIGHT", "BODY_TEMPERATURE", "SLEEP_DURATION", "ACTIVITY_STEPS", "HEART_RATE_VARIABILITY", "HRV_RMSSD", "BLOOD_GLUCOSE", "OXYGEN_SATURATION", "RESPIRATORY_RATE", "BODY_FAT", ]; export interface DayValueTile { /** Stable key: the first type, `BLOOD_PRESSURE` for the pair. */ key: string; /** * The rows the tile shows, one per type, systolic before diastolic for the * pair. A type read more than once that day is its mean. */ values: DayValue[]; /** How many readings the tile stands for; above 1 the value is a mean. */ readings: number; } /** * One row per type: several readings of a type become their mean, at the * time of the last. The band is the type's, so any reading's will do. */ function meanPerType(rows: readonly DayValue[]): { values: DayValue[]; readings: number; } { const byType = new Map(); for (const row of rows) { const list = byType.get(row.type) ?? []; list.push(row); byType.set(row.type, list); } let readings = 0; const values: DayValue[] = []; for (const list of byType.values()) { readings = Math.max(readings, list.length); if (list.length === 1) { values.push(list[0]!); continue; } const last = list.reduce((a, b) => (b.at > a.at ? b : a)); const mean = list.reduce((sum, r) => sum + r.value, 0) / list.length; values.push({ ...last, value: mean }); } return { values, readings }; } /** The tile key a type belongs to. */ export function tileKeyOf(type: string): string { return type === "BLOOD_PRESSURE_SYS" || type === "BLOOD_PRESSURE_DIA" ? "BLOOD_PRESSURE" : type; } /** * Fold, order and split the day's values. `focusTypes` (the value the person * came from) always lands among the first tiles, wherever its type ranks. */ export function curateDayValues( values: readonly DayValue[], focusTypes: readonly string[] = [], ): { curated: DayValueTile[]; rest: DayValueTile[] } { const grouped = new Map(); for (const value of values) { const key = tileKeyOf(value.type); const rows = grouped.get(key) ?? []; rows.push(value); grouped.set(key, rows); } const tiles = new Map(); for (const [key, rows] of grouped) { tiles.set(key, { key, ...meanPerType(rows) }); } for (const tile of tiles.values()) { if (tile.key === "BLOOD_PRESSURE") { tile.values.sort((a, b) => a.type === b.type ? 0 : a.type === "BLOOD_PRESSURE_SYS" ? -1 : 1, ); } } const rank = (tile: DayValueTile): number => { const first = tile.key === "BLOOD_PRESSURE" ? "BLOOD_PRESSURE_SYS" : tile.key; const index = VALUE_PRIORITY.indexOf(first); return index === -1 ? VALUE_PRIORITY.length : index; }; const sent = [...tiles.values()]; const ordered = sent .map((tile, arrival) => ({ tile, arrival })) .sort((a, b) => rank(a.tile) - rank(b.tile) || a.arrival - b.arrival) .map(({ tile }) => tile); const focusKeys = new Set(focusTypes.map(tileKeyOf)); const focused = ordered.filter((tile) => focusKeys.has(tile.key)); const others = ordered.filter((tile) => !focusKeys.has(tile.key)); const head = [...focused, ...others].slice(0, CURATED_VALUE_COUNT); const headKeys = new Set(head.map((tile) => tile.key)); // Keep the reading order inside the first tiles: the focus is marked, not // moved to the front. const curated = ordered.filter((tile) => headKeys.has(tile.key)); const rest = ordered.filter((tile) => !headKeys.has(tile.key)); return { curated, rest }; } /** * Where a value and its usual range sit on a short line, in percent. The * line spans the band with room on both sides so a value inside the range * reads as inside, and stretches to include a value outside it. */ export function numberLinePositions( value: number, band: { lo: number; hi: number }, ): { lo: number; hi: number; point: number } { const lo = Math.min(band.lo, band.hi); const hi = Math.max(band.lo, band.hi); const span = hi - lo || Math.max(Math.abs(hi) * 0.1, 1); let min = lo - span * 0.75; let max = hi + span * 0.75; if (value < min) min = value - span * 0.15; if (value > max) max = value + span * 0.15; const width = max - min; const at = (n: number) => Math.round(Math.min(100, Math.max(0, ((n - min) / width) * 100)) * 10) / 10; return { lo: at(lo), hi: at(hi), point: at(value) }; }