diff --git a/messages/de.json b/messages/de.json index a8e3de4fb..b7cc77b50 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 Eintrag", "countEntriesFew": "{count} Einträge", "countEntriesOther": "{count} Einträge", + "meanOfReadingsOne": "Schnitt aus 1 Messung", + "meanOfReadingsFew": "Schnitt aus {count} Messungen", + "meanOfReadingsOther": "Schnitt aus {count} Messungen", "sinceLastVisit": { "title": "Seit dem letzten Besuch am {date}", "countOne": "1 Eintrag", diff --git a/messages/en.json b/messages/en.json index ab324e140..bdea7e6a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 entry", "countEntriesFew": "{count} entries", "countEntriesOther": "{count} entries", + "meanOfReadingsOne": "Average of 1 reading", + "meanOfReadingsFew": "Average of {count} readings", + "meanOfReadingsOther": "Average of {count} readings", "sinceLastVisit": { "title": "Since the last visit on {date}", "countOne": "1 entry", diff --git a/messages/es.json b/messages/es.json index f1ac82194..3e9806ff9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 entrada", "countEntriesFew": "{count} entradas", "countEntriesOther": "{count} entradas", + "meanOfReadingsOne": "Media de 1 medición", + "meanOfReadingsFew": "Media de {count} mediciones", + "meanOfReadingsOther": "Media de {count} mediciones", "sinceLastVisit": { "title": "Desde la última visita el {date}", "countOne": "1 entrada", diff --git a/messages/fr.json b/messages/fr.json index 633626fa5..4ffa3c91a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 entrée", "countEntriesFew": "{count} entrées", "countEntriesOther": "{count} entrées", + "meanOfReadingsOne": "Moyenne de 1 mesure", + "meanOfReadingsFew": "Moyenne de {count} mesures", + "meanOfReadingsOther": "Moyenne de {count} mesures", "sinceLastVisit": { "title": "Depuis la dernière visite le {date}", "countOne": "1 entrée", diff --git a/messages/it.json b/messages/it.json index cf8ffa384..7fb859048 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 voce", "countEntriesFew": "{count} voci", "countEntriesOther": "{count} voci", + "meanOfReadingsOne": "Media di 1 misurazione", + "meanOfReadingsFew": "Media di {count} misurazioni", + "meanOfReadingsOther": "Media di {count} misurazioni", "sinceLastVisit": { "title": "Dall’ultima visita del {date}", "countOne": "1 voce", diff --git a/messages/ko.json b/messages/ko.json index c9a078170..3405b2f39 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "기록 1개", "countEntriesFew": "기록 {count}개", "countEntriesOther": "기록 {count}개", + "meanOfReadingsOne": "측정 1회 평균", + "meanOfReadingsFew": "측정 {count}회 평균", + "meanOfReadingsOther": "측정 {count}회 평균", "sinceLastVisit": { "title": "{date} 마지막 방문 이후", "countOne": "기록 1개", diff --git a/messages/pl.json b/messages/pl.json index 25d214f65..7100d719b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12260,6 +12260,9 @@ "countEntriesOne": "1 wpis", "countEntriesFew": "{count} wpisy", "countEntriesOther": "{count} wpisów", + "meanOfReadingsOne": "Średnia z 1 pomiaru", + "meanOfReadingsFew": "Średnia z {count} pomiarów", + "meanOfReadingsOther": "Średnia z {count} pomiarów", "sinceLastVisit": { "title": "Od ostatniej wizyty {date}", "countOne": "1 wpis", diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 6f76b206d..1e418fc3a 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -114,6 +114,10 @@ const TS_READERS: Record = { via: "lib/ai/coach/snapshot-series.ts", passes: /buildDailyValueRows\([^)]*"PULSE"/, }, + "components/day/day-values-model.ts": { + exempt: + "means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)", + }, "components/charts/health-chart.tsx": { exempt: "a rolling average over the points the chart draws: display smoothing, not a stated figure", diff --git a/src/components/day/__tests__/day-values-model.test.ts b/src/components/day/__tests__/day-values-model.test.ts index f90364949..355eaad5c 100644 --- a/src/components/day/__tests__/day-values-model.test.ts +++ b/src/components/day/__tests__/day-values-model.test.ts @@ -8,12 +8,12 @@ import { numberLinePositions, } from "../day-values-model"; -function value(type: string, v = 1): DayValue { +function value(type: string, v = 1, at = "2026-01-03T07:12:00.000Z"): DayValue { return { type, value: v, unit: "x", - at: "2026-01-03T07:12:00.000Z", + at, source: "MANUAL", band: null, }; @@ -30,6 +30,30 @@ describe("curateDayValues", () => { expect(curated[0]!.values.map((v) => v.value)).toEqual([138, 88]); }); + it("shows two blood pressure readings of a day as their mean, not four numbers", () => { + const { curated } = curateDayValues([ + value("BLOOD_PRESSURE_SYS", 127, "2026-01-03T07:00:00.000Z"), + value("BLOOD_PRESSURE_DIA", 86, "2026-01-03T07:00:00.000Z"), + value("BLOOD_PRESSURE_SYS", 135, "2026-01-03T19:00:00.000Z"), + value("BLOOD_PRESSURE_DIA", 87, "2026-01-03T19:00:00.000Z"), + ]); + expect(curated).toHaveLength(1); + const tile = curated[0]!; + expect(tile.values.map((v) => v.type)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + expect(tile.values.map((v) => v.value)).toEqual([131, 86.5]); + expect(tile.values[0]!.at).toBe("2026-01-03T19:00:00.000Z"); + expect(tile.readings).toBe(2); + }); + + it("keeps a single reading as it is", () => { + const { curated } = curateDayValues([value("WEIGHT", 80.4)]); + expect(curated[0]!.values.map((v) => v.value)).toEqual([80.4]); + expect(curated[0]!.readings).toBe(1); + }); + it("shows eight tiles in reading order and keeps the rest for All", () => { const types = [ "WALKING_SPEED", diff --git a/src/components/day/__tests__/use-day-value-format.test.tsx b/src/components/day/__tests__/use-day-value-format.test.tsx index e7f1722b4..40a7605ed 100644 --- a/src/components/day/__tests__/use-day-value-format.test.tsx +++ b/src/components/day/__tests__/use-day-value-format.test.tsx @@ -34,6 +34,7 @@ function Probe({ const shown = formatTile({ key: type, values: [{ type, value, unit, at: "", source: "", band: null }], + readings: 1, }); return ( diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index f935f8f1c..df27e869b 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -302,7 +302,7 @@ function ValueTile({ tile: DayValueTile; selected: boolean; }) { - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); const { formatTile } = useDayValueFormat(); const shown = formatTile(tile); // The line plots the first row: systolic for a blood pressure. @@ -328,6 +328,14 @@ function ValueTile({ ) : null} + {tile.readings > 1 ? ( + + {tCount("day.meanOfReadings", tile.readings)} + + ) : null} {lead?.band ? ( <> diff --git a/src/components/day/day-values-model.ts b/src/components/day/day-values-model.ts index d8cd9f8fe..8d4be99b7 100644 --- a/src/components/day/day-values-model.ts +++ b/src/components/day/day-values-model.ts @@ -2,9 +2,12 @@ * 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. + * 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"; @@ -35,8 +38,42 @@ export const VALUE_PRIORITY: readonly string[] = [ 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. */ + /** + * 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. */ @@ -54,12 +91,16 @@ export function curateDayValues( values: readonly DayValue[], focusTypes: readonly string[] = [], ): { curated: DayValueTile[]; rest: DayValueTile[] } { - const tiles = new Map(); + const grouped = new Map(); 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); + 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") { diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 9a7768285..a8d24cca5 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -164,6 +164,7 @@ export function SinceLastVisit({ band: null, }, ], + readings: 1, }); return `${label} ${shown.value}${shown.unit ? ` ${shown.unit}` : ""}`; };