Files
HealthLog/src/components/day/day-values-model.ts
T
Marc-André Bombeck bf83d414e2 feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.

The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.

Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.

Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
2026-10-08 14:20:01 +02:00

117 lines
4.0 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 type (canonical source, band already decided); this only orders the
* rows and folds the two halves of a blood pressure into one tile, because a
* reading of 138 over 88 is one thing to a person, not two.
*/
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, systolic before diastolic for the pair. */
values: DayValue[];
}
/** 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 tiles = new Map<string, DayValueTile>();
for (const value of values) {
const key = tileKeyOf(value.type);
const tile = tiles.get(key) ?? { key, values: [] };
tile.values.push(value);
tiles.set(key, tile);
}
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) };
}