import type { Goal, Observation } from 'fhir/r4'; import { formatShortDate } from './dateUtils'; import { getGoalUnit, goalReadings } from './goalRow'; import { readingTime } from './goalProgress'; // The line on an opened goal: every reading over time, with the target as a dashed rule. // // Spencer 2026-09-03: "show an actual up/down graph over time in the detailed view of the goal instead // of just a horizontal progress bar". The list keeps its bar; a regain the bar would hide is on this line. // Geometry follows the approved goals deck: a 330 x 150 box, values on the left, months along the foot. export const CHART_WIDTH = 330; export const CHART_HEIGHT = 150; const LEFT = 30; const RIGHT = 324; const TOP = 12; const AXIS = 138; const PLOT_BOTTOM = 130; export interface GoalChartPoint { x: number; y: number; /** "Aug 30: 248 lb" - the tooltip, so a point can be read without the list. */ label: string; } export interface GoalChartModel { points: GoalChartPoint[]; target?: { y: number; label: string; labelY: number }; ticks: Array<{ y: number; label: string }>; months: Array<{ x: number; label: string }>; axisY: number; left: number; right: number; summary: string; } export interface GoalReadingPoint { on: string; value: number; } const formatNumber = (value: number): string => value.toLocaleString('en-US', { maximumFractionDigits: 1 }); const withUnit = (value: number, unit?: string): string => (unit ? `${formatNumber(value)} ${unit}` : formatNumber(value)); /** The goal's readings that can be placed on a time axis, oldest first, timed as the list times them. */ export const chartableReadings = (goal: Goal | undefined, observations: Observation[]): GoalReadingPoint[] => goalReadings(goal, observations) .filter((observation) => !Number.isNaN(Date.parse(readingTime(observation)))) .map((observation) => ({ on: readingTime(observation), value: observation.valueQuantity?.value as number, })); /** Round steps (1, 2, 5 x 10^n) so the value labels read as numbers a person would write. */ const niceStep = (span: number): number => { const rough = span / 3; const power = 10 ** Math.floor(Math.log10(rough)); const scaled = rough / power; return (scaled >= 5 ? 5 : scaled >= 2 ? 2 : 1) * power; }; /** * The drawing for a goal with at least two dated readings. * * Undefined below two: one point is not a line, and an empty frame says something is missing rather * than that nothing has been measured yet. The detail says that in words instead. */ export const buildGoalChart = (goal: Goal | undefined, observations: Observation[]): GoalChartModel | undefined => { const readings = chartableReadings(goal, observations); if (!goal || readings.length < 2) return undefined; const unit = getGoalUnit(goal); const targetValue = goal.target?.[0]?.detailQuantity?.value; const values = readings.map((reading) => reading.value); const bounds = typeof targetValue === 'number' ? [...values, targetValue] : values; let low = Math.min(...bounds); let high = Math.max(...bounds); if (low === high) { low -= 1; high += 1; } const pad = (high - low) * 0.06; low -= pad; high += pad; const times = readings.map((reading) => Date.parse(reading.on)); const first = times[0]; const last = times[times.length - 1]; const spread = last - first; // Evenly spaced when every reading shares one instant, rather than stacking them on one x. const xAt = (index: number) => spread > 0 ? LEFT + 4 + ((times[index] - first) / spread) * (RIGHT - LEFT - 10) : LEFT + 4 + (index / (readings.length - 1)) * (RIGHT - LEFT - 10); const yAt = (value: number) => TOP + ((high - value) / (high - low)) * (PLOT_BOTTOM - TOP); const round = (value: number) => Math.round(value * 10) / 10; const points = readings.map((reading, index) => ({ x: round(xAt(index)), y: round(yAt(reading.value)), label: `${formatShortDate(reading.on)}: ${withUnit(reading.value, unit)}`, })); const step = niceStep(high - low); const ticks: GoalChartModel['ticks'] = []; for (let value = Math.ceil(low / step) * step; value <= high && ticks.length < 5; value += step) { ticks.push({ y: round(yAt(value) + 3.5), label: formatNumber(value) }); } // One label per month, thinned to four so they never collide at 390. const monthStarts: GoalChartModel['months'] = []; let previous = ''; readings.forEach((reading, index) => { const month = new Date(reading.on).toLocaleDateString('en-US', { month: 'short' }); if (month !== previous) monthStarts.push({ x: round(Math.max(LEFT, xAt(index) - 8)), label: month }); previous = month; }); const keep = Math.ceil(monthStarts.length / 4); const months = monthStarts.filter((_, index) => index % keep === 0); const target = typeof targetValue === 'number' ? (() => { const y = round(yAt(targetValue)); return { y, label: `Target ${withUnit(targetValue, unit)}`, labelY: y < TOP + 12 ? y + 11 : y - 4 }; })() : undefined; const firstReading = readings[0]; const latest = readings[readings.length - 1]; const title = goal.description?.text?.trim() || 'This goal'; const summary = `${title}: from ${withUnit(firstReading.value, unit)} on ${formatShortDate(firstReading.on)}` + ` to ${withUnit(latest.value, unit)} on ${formatShortDate(latest.on)}, ${readings.length} readings.` + (target ? ` ${target.label}.` : ''); return { points, target, ticks, months, axisY: AXIS, left: LEFT, right: RIGHT, summary }; };