Files
HealthLog/src/components/day/day-values-model.ts
T

158 lines
5.3 KiB
TypeScript

/**
* 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<string, DayValue[]>();
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<string, DayValue[]>();
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<string, DayValueTile>();
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) };
}