From 74e691cecdc702e43d6c6c8e3e29cc77eaeca4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 00:38:53 +0100 Subject: [PATCH] feat(timeline): draw person-defined symptoms as value lines A custom symptom could feed pattern discovery and the Coach but could not be looked at over time. The timeline now offers every symptom the record has logged, hidden ones included, beside the measurements and lab markers, and draws a chosen one as a value line. `values` takes `symptom:`, checked for its shape and capped like a lab marker; an id the record does not hold draws nothing. A day's value is its highest intensity on the record's local day; a point is the mean of the bucket's day values over the days logged, with the number of those days, and a day without an entry is not a zero. The line sits on a fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the module and sharing domain the symptom routes use. An `all` window reaches back to a chosen symptom's first entry. The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI contract is regenerated. The page words a point's count as days and the value out of ten. The date-key schema moves to its own module so the symptom schemas can share it. --- docs/api/openapi.yaml | 149 ++++++++- e2e/timeline.spec.ts | 83 +++++ e2e/utils/mock-timeline.ts | 64 +++- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/it.json | 12 +- messages/ko.json | 12 +- messages/nl.json | 12 +- messages/pl.json | 12 +- messages/pt-BR.json | 12 +- messages/ru.json | 12 +- src/app/api/timeline/route.ts | 1 + .../__tests__/no-raw-code-guard.test.tsx | 2 + .../timeline/__tests__/timeline-fixture.ts | 2 + .../__tests__/timeline-geometry.test.ts | 43 +++ .../__tests__/timeline-labs.browser.test.tsx | 2 + .../__tests__/timeline-rework.test.tsx | 2 + .../timeline-symptoms.browser.test.tsx | 244 +++++++++++++++ src/components/timeline/selection-bar.tsx | 6 +- src/components/timeline/series-format.ts | 33 +- src/components/timeline/series-geometry.ts | 29 +- src/components/timeline/series-point-tips.tsx | 8 +- src/components/timeline/timeline-legend.tsx | 14 + src/components/timeline/timeline-menus.tsx | 102 +++++- src/components/timeline/timeline-tables.tsx | 6 +- src/components/timeline/timeline-view.tsx | 70 ++++- .../timeline/use-series-value-format.ts | 40 ++- src/lib/day/contract.ts | 61 +++- src/lib/day/date-key-schema.ts | 12 + src/lib/day/timeline-symptom-schemas.ts | 75 +++++ src/lib/day/wire-schemas.ts | 25 +- src/lib/openapi/routes/biomarkers.ts | 2 +- src/lib/openapi/routes/day.ts | 2 +- .../timeline/__tests__/symptom-series.test.ts | 205 ++++++++++++ src/lib/timeline/load-timeline.ts | 109 ++++--- src/lib/timeline/symptom-series.ts | 196 ++++++++++++ .../timeline-symptom-series.test.ts | 296 ++++++++++++++++++ 39 files changed, 1871 insertions(+), 132 deletions(-) create mode 100644 src/components/timeline/__tests__/timeline-symptoms.browser.test.tsx create mode 100644 src/lib/day/date-key-schema.ts create mode 100644 src/lib/day/timeline-symptom-schemas.ts create mode 100644 src/lib/timeline/__tests__/symptom-series.test.ts create mode 100644 src/lib/timeline/symptom-series.ts create mode 100644 tests/integration/timeline-symptom-series.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fe43de718..940a55849 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26948,9 +26948,12 @@ paths: - Records summary: The record over the years description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the - standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of - a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is - never sent to a delegate. Requires the opt-in `timeline` module. + standing items without a start, and monthly (or finer, by zoom) value series, among them the lab markers + (`labSeries`, single readings) and the person-defined symptoms (`symptomSeries`, the day's highest intensity + averaged over logged days, on a fixed 0 to 10 scale; read only with the illness module on and the illness + section visible) named in `values`. Empty lanes are not sent; lanes of a switched-off module never are. The + `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the + opt-in `timeline` module. parameters: - in: query name: zoom @@ -26979,19 +26982,22 @@ paths: - in: query name: values schema: - description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as - `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab - markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones - left out without an error. A lab marker that is not in the record (deleted, or another record's) is - likewise left out of `labSeries`, so a stored choice never breaks the page." + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as + `lab:` (see `availableLabs`), or a person-defined symptom as `symptom:` (see + `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an + unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 + symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any + further ones left out without an error. A lab marker or symptom that is not in the record (deleted, or + another record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks + the page." type: string - description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as - `lab:` (see `availableLabs`). A name given twice counts once, so the list is bounded by the - names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers - are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out - without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out - of `labSeries`, so a stored choice never breaks the page." + description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as + `lab:` (see `availableLabs`), or a person-defined symptom as `symptom:` (see + `availableSymptoms`). A name given twice counts once, so the list is bounded by the names that exist; an + unknown measurement name or a malformed marker or symptom key answers 422. At most 24 lab markers and 24 + symptoms are drawn (ids, unlike names, are not a closed set): the first 24 of each are kept and any further + ones left out without an error. A lab marker or symptom that is not in the record (deleted, or another + record's) is likewise left out of `labSeries` / `symptomSeries`, so a stored choice never breaks the page." responses: "200": description: The timeline for the requested zoom and window. @@ -94385,6 +94391,19 @@ components: $ref: "#/components/schemas/TimelineLabMarker" description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`. + symptomSeries: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomSeries" + description: The person-defined symptoms named in `values`, in the order named, each averaged per `bucket` over the days + it was logged. A symptom named but not found in the record is left out. + availableSymptoms: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomOption" + description: Every person-defined symptom this record has logged at least once, hidden ones included (their history is + kept), in the record's own order, when the caller may see illness data. Each may be named in `values` by its + `key`. notable: type: array items: @@ -94414,6 +94433,8 @@ components: - availableSeries - labSeries - availableLabs + - symptomSeries + - availableSymptoms - notable additionalProperties: false TimelineItem: @@ -94590,6 +94611,104 @@ components: - panel - unit additionalProperties: false + TimelineSymptomSeries: + type: object + properties: + key: + type: string + description: "`symptom:`, as named in `values`." + definitionId: + type: string + label: + description: The person's own name for the symptom. Null when it cannot be read; the client words a stand-in. + type: + - string + - "null" + icon: + description: The symptom's icon name; null for the default glyph. + type: + - string + - "null" + isActive: + type: boolean + description: "False for a hidden symptom: no longer offered for entry, its history kept and drawn." + scale: + type: object + properties: + min: + type: number + const: 0 + max: + type: number + const: 10 + required: + - min + - max + additionalProperties: false + description: The fixed scale the line is drawn on, the intensity scale, whatever the window holds. + points: + type: array + items: + $ref: "#/components/schemas/TimelineSymptomPoint" + description: Buckets of the response's `bucket` with at least one logged day, oldest first. A bucket without one is + absent, never zero and never interpolated. + required: + - key + - definitionId + - label + - icon + - isActive + - scale + - points + additionalProperties: false + TimelineSymptomPoint: + type: object + properties: + t: + type: string + description: The bucket's first day. + mean: + type: number + minimum: 0 + maximum: 10 + description: The mean of the bucket's day values over the days the symptom was logged. A day's value is the highest + intensity (0 to 10) logged on it; a day without an entry is not a zero and does not count. + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than + three as thin. + required: + - t + - mean + - count + additionalProperties: false + TimelineSymptomOption: + type: object + properties: + key: + type: string + description: "`symptom:`, to name in `values`." + definitionId: + type: string + label: + type: + - string + - "null" + icon: + type: + - string + - "null" + isActive: + type: boolean + required: + - key + - definitionId + - label + - icon + - isActive + additionalProperties: false TimelineReadinessEnvelope: type: object properties: diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index d75c82a79..422ce9e09 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -18,6 +18,9 @@ * 7. A lab marker (#1299) is picked from the values menu, drawn as its * single readings with the range behind them, read on its own, kept * across a reload, and listed on its lab day on a phone. + * 8. A symptom (#981) is picked from the values menu, drawn as a value + * line on the fixed 0 to 10 scale, read with the days behind a point, + * kept across a reload, and named in the phone chronicle. * * The timeline's server routes are mocked with the contract's shapes * (`utils/mock-timeline.ts`); the module switch is the real one. Every @@ -919,6 +922,86 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept", await expect(labs).toHaveCount(0); }); +test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY, { symptoms: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/timeline?zoom=all"); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({ + timeout: 20_000, + }); + + await page + .locator( + '[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]', + ) + .click(); + const menu = page.locator('[data-slot="timeline-values-menu"]'); + await expect( + menu.locator('[data-slot="timeline-values-symptoms"]'), + ).toBeVisible(); + // A symptom hidden from entry is still offered, and says so. + await expect( + menu.locator( + '[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]', + ), + ).toBeVisible(); + await menu + .locator( + '[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]', + ) + .click(); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + + const row = page.locator('g[data-series="symptom:sd-headache"]'); + const points = row.locator('[data-slot="timeline-series-point"]'); + await expect(points).toHaveCount(3); + // The quarter resting on two days is hollow. + await expect(points.nth(1)).toHaveAttribute("data-thin", "true"); + await expect( + page.locator('[data-slot="timeline-legend-symptom"]'), + ).toBeVisible(); + expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain( + "symptom:sd-headache", + ); + + const target = page.locator( + '[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]', + ); + const tip = page.locator('[data-slot="timeline-point-tip"]'); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", "2024-01-01"); + // Out of 10 in the account's own decimal mark, with the days behind it. + await expect(tip).toContainText(/6[.,]5\/10/); + await expect(tip).toContainText(/\b9\b/); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="timeline-page"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]); + + // The choice is kept: a reload asks for the symptom again. + await page.reload(); + await expect(points).toHaveCount(3, { timeout: 20_000 }); + + // On a phone: its means in the chronicle, no sideways scroll. + await page.setViewportSize({ width: 390, height: 844 }); + const means = page.locator( + '[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]', + ); + await expect(means.first()).toBeVisible(); + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +}); + test("the timeline page ends where its content does, at every desktop width", async ({ page, }) => { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index f0c905129..ef2284a41 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -514,6 +514,8 @@ export function fullTimeline(today: string) { availableSeries: FULL_AVAILABLE, labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } @@ -610,6 +612,50 @@ function labsLane(keys: readonly string[]) { }; } +/** + * Two of the person's own symptoms (#981): a headache logged over several + * quarters, one quarter resting on two days, and a nausea hidden from entry + * since, logged once. Their points are bucket means of day peaks on the + * fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all` + * view of the full record uses. + */ +export const SYMPTOM_OPTIONS = [ + { + key: "symptom:sd-headache", + definitionId: "sd-headache", + label: "Kopfschmerz", + icon: "brain", + isActive: true, + }, + { + key: "symptom:sd-nausea", + definitionId: "sd-nausea", + label: "Übelkeit", + icon: null, + isActive: false, + }, +]; + +const SYMPTOM_QUARTERS: Record< + string, + Array<{ t: string; mean: number; count: number }> +> = { + "symptom:sd-headache": [ + { t: "2024-01-01", mean: 6.5, count: 9 }, + { t: "2024-04-01", mean: 4, count: 2 }, + { t: "2025-01-01", mean: 3.25, count: 5 }, + ], + "symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }], +}; + +function symptomLines(keys: readonly string[], bucket: Bucket) { + return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({ + ...o, + scale: { min: 0, max: 10 }, + points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [], + })); +} + export function readyTimeline(today: string) { return { zoom: "all", @@ -733,6 +779,8 @@ export function readyTimeline(today: string) { availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [], }; } @@ -935,6 +983,8 @@ export async function mockTimeline( dataFrom?: string; /** The record has lab markers to offer and draw (#1299). */ labs?: boolean; + /** The record has logged symptoms to offer and draw (#981). */ + symptoms?: boolean; } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; @@ -950,7 +1000,11 @@ export async function mockTimeline( .filter(Boolean); // A lab marker is a line of its own (`labSeries`), never a mean. const labKeys = named.filter((key) => key.startsWith("lab:")); - const values = named.filter((key) => !key.startsWith("lab:")); + // A symptom is a line of its own too (`symptomSeries`), on 0 to 10. + const symptomKeys = named.filter((key) => key.startsWith("symptom:")); + const values = named.filter( + (key) => !key.startsWith("lab:") && !key.startsWith("symptom:"), + ); const labs = state === "full" && options.labs ? { @@ -966,6 +1020,13 @@ export async function mockTimeline( const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); + const symptoms = + state === "full" && options.symptoms + ? { + symptomSeries: symptomLines(symptomKeys, bucket), + availableSymptoms: SYMPTOM_OPTIONS, + } + : {}; const dataFrom = options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01"); if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { @@ -988,6 +1049,7 @@ export async function mockTimeline( return json(route, 200, { ...body, ...labs, + ...symptoms, zoom, bucket, ...(chosen diff --git a/messages/de.json b/messages/de.json index 6bee77d3c..6d0139ccd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Laborwert, wie gemessen", "legendLabOutside": "Außerhalb des Referenzbereichs", - "legendLabRange": "Referenzbereich" + "legendLabRange": "Referenzbereich", + "legendSymptom": "Symptomlinie: höchste Stärke jedes erfassten Tages (0 bis 10), gemittelt über die erfassten Tage", + "symptoms": { + "menuTitle": "Symptome", + "full": "Bis zu {max} Symptome gleichzeitig. Entferne eines, um ein anderes hinzuzufügen.", + "hidden": "ausgeblendet", + "unnamed": "Symptom", + "daysOne": "an 1 Tag", + "daysFew": "Schnitt aus {count} Tagen", + "daysOther": "Schnitt aus {count} Tagen" + } }, "lifeEvents": { "title": "Lebensereignis", diff --git a/messages/en.json b/messages/en.json index c8895686e..9e86a3f69 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Lab value, as measured", "legendLabOutside": "Outside the reference range", - "legendLabRange": "Reference range" + "legendLabRange": "Reference range", + "legendSymptom": "Symptom line: each logged day's highest intensity (0 to 10), averaged over the logged days", + "symptoms": { + "menuTitle": "Symptoms", + "full": "Up to {max} symptoms at once. Remove one to add another.", + "hidden": "hidden", + "unnamed": "Symptom", + "daysOne": "on 1 day", + "daysFew": "mean of {count} days", + "daysOther": "mean of {count} days" + } }, "lifeEvents": { "title": "Life event", diff --git a/messages/es.json b/messages/es.json index b35004a26..b20934c4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Valor de laboratorio, tal como se midió", "legendLabOutside": "Fuera del intervalo de referencia", - "legendLabRange": "Intervalo de referencia" + "legendLabRange": "Intervalo de referencia", + "legendSymptom": "Línea de síntoma: la intensidad más alta de cada día registrado (0 a 10), promediada sobre los días registrados", + "symptoms": { + "menuTitle": "Síntomas", + "full": "Hasta {max} síntomas a la vez. Quita uno para añadir otro.", + "hidden": "oculto", + "unnamed": "Síntoma", + "daysOne": "en 1 día", + "daysFew": "media de {count} días", + "daysOther": "media de {count} días" + } }, "lifeEvents": { "title": "Acontecimiento vital", diff --git a/messages/fr.json b/messages/fr.json index ea6b664bb..0e6fa9cc6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Valeur de laboratoire, telle que mesurée", "legendLabOutside": "Hors de l’intervalle de référence", - "legendLabRange": "Intervalle de référence" + "legendLabRange": "Intervalle de référence", + "legendSymptom": "Ligne de symptôme : intensité la plus forte de chaque jour noté (0 à 10), en moyenne sur les jours notés", + "symptoms": { + "menuTitle": "Symptômes", + "full": "Jusqu’à {max} symptômes à la fois. Retirez-en un pour en ajouter un autre.", + "hidden": "masqué", + "unnamed": "Symptôme", + "daysOne": "sur 1 jour", + "daysFew": "moyenne sur {count} jours", + "daysOther": "moyenne sur {count} jours" + } }, "lifeEvents": { "title": "Événement de vie", diff --git a/messages/it.json b/messages/it.json index f077ab4f2..f0c0734a4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Valore di laboratorio, così come misurato", "legendLabOutside": "Fuori dall’intervallo di riferimento", - "legendLabRange": "Intervallo di riferimento" + "legendLabRange": "Intervallo di riferimento", + "legendSymptom": "Linea del sintomo: l’intensità più alta di ogni giorno registrato (da 0 a 10), in media sui giorni registrati", + "symptoms": { + "menuTitle": "Sintomi", + "full": "Fino a {max} sintomi alla volta. Rimuovine uno per aggiungerne un altro.", + "hidden": "nascosto", + "unnamed": "Sintomo", + "daysOne": "in 1 giorno", + "daysFew": "media di {count} giorni", + "daysOther": "media di {count} giorni" + } }, "lifeEvents": { "title": "Evento di vita", diff --git a/messages/ko.json b/messages/ko.json index 05df2c868..f8dfaefe1 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "측정된 그대로의 검사 수치", "legendLabOutside": "참고 범위 밖", - "legendLabRange": "참고 범위" + "legendLabRange": "참고 범위", + "legendSymptom": "증상 선: 기록한 날마다의 가장 높은 강도(0~10)를 기록한 날 기준으로 평균한 값", + "symptoms": { + "menuTitle": "증상", + "full": "증상은 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 증상을 추가하려면 하나를 제거하세요.", + "hidden": "숨김", + "unnamed": "증상", + "daysOne": "1일", + "daysFew": "{count}일 평균", + "daysOther": "{count}일 평균" + } }, "lifeEvents": { "title": "삶의 사건", diff --git a/messages/nl.json b/messages/nl.json index 6b34b6ecf..462f15c05 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Labwaarde, zoals gemeten", "legendLabOutside": "Buiten het referentiebereik", - "legendLabRange": "Referentiebereik" + "legendLabRange": "Referentiebereik", + "legendSymptom": "Symptoomlijn: de hoogste intensiteit van elke vastgelegde dag (0 tot 10), gemiddeld over de vastgelegde dagen", + "symptoms": { + "menuTitle": "Symptomen", + "full": "Maximaal {max} symptomen tegelijk. Verwijder er een om een ander toe te voegen.", + "hidden": "verborgen", + "unnamed": "Symptoom", + "daysOne": "op 1 dag", + "daysFew": "gemiddelde van {count} dagen", + "daysOther": "gemiddelde van {count} dagen" + } }, "lifeEvents": { "title": "Levensgebeurtenis", diff --git a/messages/pl.json b/messages/pl.json index ac0f835c3..f83b58363 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Wynik badania, tak jak zmierzono", "legendLabOutside": "Poza zakresem referencyjnym", - "legendLabRange": "Zakres referencyjny" + "legendLabRange": "Zakres referencyjny", + "legendSymptom": "Linia objawu: najwyższe natężenie każdego zapisanego dnia (od 0 do 10), uśrednione po zapisanych dniach", + "symptoms": { + "menuTitle": "Objawy", + "full": "Naraz można pokazać do {max} objawów. Usuń jeden, aby dodać inny.", + "hidden": "ukryty", + "unnamed": "Objaw", + "daysOne": "w 1 dniu", + "daysFew": "średnia z {count} dni", + "daysOther": "średnia z {count} dni" + } }, "lifeEvents": { "title": "Wydarzenie życiowe", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 192b8408d..aebff1a85 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Valor de exame, como foi medido", "legendLabOutside": "Fora da faixa de referência", - "legendLabRange": "Faixa de referência" + "legendLabRange": "Faixa de referência", + "legendSymptom": "Linha do sintoma: a maior intensidade de cada dia registrado (de 0 a 10), em média sobre os dias registrados", + "symptoms": { + "menuTitle": "Sintomas", + "full": "Até {max} sintomas ao mesmo tempo. Remova um para adicionar outro.", + "hidden": "oculto", + "unnamed": "Sintoma", + "daysOne": "em 1 dia", + "daysFew": "média de {count} dias", + "daysOther": "média de {count} dias" + } }, "lifeEvents": { "title": "Evento de vida", diff --git a/messages/ru.json b/messages/ru.json index 4cbc65302..250c825d4 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -13944,7 +13944,17 @@ }, "legendLabValue": "Результат анализа, как измерено", "legendLabOutside": "Вне референсного диапазона", - "legendLabRange": "Референсный диапазон" + "legendLabRange": "Референсный диапазон", + "legendSymptom": "Линия симптома: наибольшая сила за каждый отмеченный день (от 0 до 10), усреднённая по отмеченным дням", + "symptoms": { + "menuTitle": "Симптомы", + "full": "До {max} симптомов одновременно. Уберите один, чтобы добавить другой.", + "hidden": "скрыт", + "unnamed": "Симптом", + "daysOne": "за 1 день", + "daysFew": "среднее за {count} дня", + "daysOther": "среднее за {count} дней" + } }, "lifeEvents": { "title": "Событие жизни", diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index 8adf3ee06..a697dac4b 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -70,6 +70,7 @@ export const GET = apiHandler(async (request: Request) => { items: timeline.lanes.reduce((n, l) => n + l.items.length, 0), series: timeline.series.length, labSeries: timeline.labSeries.length, + symptomSeries: timeline.symptomSeries.length, notable: timeline.notable.length, }, }); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 8c6e441fb..2b33f846b 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -286,6 +286,8 @@ function everyKind(): TimelineResponse { }, ], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [], }; // The fixture itself must reach every kind, or the guard proves less. diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 70762d50f..07550407c 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -286,6 +286,8 @@ export function fullTimeline(): TimelineResponse { availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [{ date: "2026-01-04", kind: "extremeHigh" }], }; } diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 6d241dfca..5c1b11477 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -854,3 +854,46 @@ describe("label room (v1.42 final)", () => { } }); }); + +describe("layoutSeries, a symptom line (#981)", () => { + const window = { from: "2026-01-01", to: "2026-06-30" }; + const scale = createScale(window, 164, 1200); + const lay = (points: Array<{ t: string; mean: number; count: number }>) => + layoutSeries( + [{ key: "symptom:sd1", unit: null, points }], + 0, + window, + scale, + "month", + )[0]; + + it("sits on the fixed 0 to 10 scale, whatever the window holds", () => { + const quiet = lay([ + { t: "2026-01-01", mean: 2, count: 4 }, + { t: "2026-02-01", mean: 3, count: 1 }, + ]); + const loud = lay([ + { t: "2026-01-01", mean: 2, count: 4 }, + { t: "2026-02-01", mean: 9, count: 5 }, + ]); + // The same mean lands on the same height in both rows. + expect(quiet.points[0].y).toBe(loud.points[0].y); + // 0 and 10 are inside the row, 5 halfway between them. + const mid = lay([{ t: "2026-01-01", mean: 5, count: 3 }]).points[0].y; + const top = lay([{ t: "2026-01-01", mean: 10, count: 3 }]).points[0].y; + const bottom = lay([{ t: "2026-01-01", mean: 0, count: 3 }]).points[0].y; + expect(top).toBeLessThan(mid); + expect(mid).toBeLessThan(bottom); + expect(mid).toBeCloseTo((top + bottom) / 2, 5); + expect(top).toBeGreaterThan(quiet.top); + expect(bottom).toBeLessThan(quiet.top + quiet.height); + }); + + it("draws a mean resting on fewer than three days hollow", () => { + const line = lay([ + { t: "2026-01-01", mean: 4, count: 2 }, + { t: "2026-02-01", mean: 4, count: 3 }, + ]); + expect(line.points.map((p) => p.thin)).toEqual([true, false]); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx index 25e2cf1b4..222b1e9b9 100644 --- a/src/components/timeline/__tests__/timeline-labs.browser.test.tsx +++ b/src/components/timeline/__tests__/timeline-labs.browser.test.tsx @@ -138,6 +138,8 @@ function timeline(values: readonly string[]): TimelineResponse { unit: "ng/mL", }, ], + symptomSeries: [], + availableSymptoms: [], notable: [], }; } diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx index 59222150b..0adfbb319 100644 --- a/src/components/timeline/__tests__/timeline-rework.test.tsx +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -149,6 +149,8 @@ function twoMedications(): TimelineResponse { availableSeries: [], labSeries: [], availableLabs: [], + symptomSeries: [], + availableSymptoms: [], notable: [], }; } diff --git a/src/components/timeline/__tests__/timeline-symptoms.browser.test.tsx b/src/components/timeline/__tests__/timeline-symptoms.browser.test.tsx new file mode 100644 index 000000000..232d176f9 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-symptoms.browser.test.tsx @@ -0,0 +1,244 @@ +/** + * The person's own symptoms on the timeline page (#981), in a real browser: + * the page as mounted, its reads answered by a stubbed network. + * + * - A stored choice that names a symptom reaches the request, and the + * symptom is drawn as a value line on the fixed 0 to 10 scale, a point + * resting on fewer than three days drawn hollow. + * - The values menu offers the record's logged symptoms, a hidden one + * marked as such; choosing one adds it to the stored list and to the + * next request. + * - With the illness module off, no symptom is offered or requested. + * - A point reads on its own by keyboard: the bucket, the value out of 10 + * and the days behind it. + * + * All data invented. + */ +import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; + +import type { TimelineResponse } from "@/lib/day/contract"; + +import { + stubFetch, + type FetchStub, +} from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; +import { setSearch } from "../../../../tests/browser/url"; +import { TimelineView } from "../timeline-view"; + +const VALUES_KEY = "healthlog.timeline.values"; + +const HEADACHE = "symptom:sd-headache"; +const NAUSEA = "symptom:sd-nausea"; + +function timeline(values: readonly string[]): TimelineResponse { + const line = ( + key: string, + label: string, + isActive: boolean, + points: Array<{ t: string; mean: number; count: number }>, + ) => ({ + key, + definitionId: key.slice("symptom:".length), + label, + icon: null, + isActive, + scale: { min: 0 as const, max: 10 as const }, + points, + }); + const lines = [ + line(HEADACHE, "Headache", true, [ + { t: "2025-10-01", mean: 6.5, count: 4 }, + { t: "2026-01-01", mean: 3, count: 1 }, + ]), + line(NAUSEA, "Nausea", false, [{ t: "2025-04-01", mean: 2, count: 2 }]), + ]; + return { + zoom: "all", + range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" }, + bucket: "quarter", + lanes: [ + { + key: "illness", + items: [ + { + id: "episode-1", + kind: "episode", + start: "2025-10-12", + end: "2025-10-20", + open: false, + precision: "DAY", + startKnown: true, + label: "Flu", + sub: null, + href: null, + group: null, + }, + ], + }, + ], + standing: [], + series: [], + availableSeries: ["WEIGHT"], + labSeries: [], + availableLabs: [], + symptomSeries: lines.filter((l) => values.includes(l.key)), + availableSymptoms: lines.map( + ({ key, definitionId, label, icon, isActive }) => ({ + key, + definitionId, + label, + icon, + isActive, + }), + ), + notable: [], + }; +} + +/** The `values` a request named, as a list. */ +function valuesOf(path: string): string[] { + const raw = new URL(path, window.location.origin).searchParams.get("values"); + return raw ? raw.split(",") : []; +} + +function stubTimeline(): FetchStub { + return stubFetch() + .on("GET", "/api/timeline", (request) => ({ + data: timeline(valuesOf(request.path)), + })) + .on("GET", "/api/timeline/readiness", { + data: { verdict: "carries", since: "2024-01-01", lanes: [] }, + }) + .on("GET", "/api/life-events", { data: { events: [] } }); +} + +async function mount( + stored: string[] | null, + modules: Record = { timeline: true, illness: true }, +) { + if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored)); + setSearch("?zoom=all"); + const net = stubTimeline(); + const { screen } = await renderWithApp(, { + user: { timezone: "Europe/Berlin", modules }, + }); + return { net, screen }; +} + +function lastTimelineValues(net: FetchStub): string[] { + const calls = net.calls("GET", "/api/timeline"); + return valuesOf(calls[calls.length - 1]?.path ?? ""); +} + +describe("symptom lines on the timeline page", () => { + it("asks for a stored symptom and draws it as a value line on the fixed scale", async () => { + await page.viewport(1280, 900); + const { net } = await mount([HEADACHE]); + const points = () => + document.querySelectorAll( + `[data-series="${HEADACHE}"] [data-slot="timeline-series-point"]`, + ); + await expect.poll(() => points().length).toBe(2); + expect(lastTimelineValues(net)).toEqual([HEADACHE]); + // The mean of four days is a full point, the one of a single day hollow. + const [autumn, winter] = [...points()]; + expect(autumn.dataset.count).toBe("4"); + expect(autumn.dataset.thin).toBe("false"); + expect(winter.dataset.thin).toBe("true"); + // Fixed scale: 6.5 sits higher than 3, and both well inside the row, + // where a self-fitted row would push the two towards its edges. + const y = (el: SVGCircleElement) => Number(el.getAttribute("cy")); + expect(y(autumn)).toBeLessThan(y(winter)); + const span = y(winter) - y(autumn); + // 3.5 of 11 scale units across a 44 px row: about 14 px. + expect(span).toBeGreaterThan(10); + expect(span).toBeLessThan(20); + // The latest value reads out of 10, and the legend says what a point is. + expect( + document.querySelector(`g[data-series="${HEADACHE}"]`)?.textContent, + ).toContain("3/10"); + expect( + document.querySelector('[data-slot="timeline-legend-symptom"]'), + ).not.toBeNull(); + }); + + it("reads a point on its own with the days behind it", async () => { + await page.viewport(1280, 900); + await mount([HEADACHE]); + const target = (t: string) => + document.querySelector( + `[data-slot="timeline-point-target"][data-series="${HEADACHE}"][data-t="${t}"]`, + ); + await expect.poll(() => target("2025-10-01") !== null).toBe(true); + const label = target("2025-10-01")!.getAttribute("aria-label") ?? ""; + expect(label).toContain("Headache"); + expect(label).toContain("6.5/10"); + expect(label).toContain("mean of 4 days"); + expect(target("2026-01-01")!.getAttribute("aria-label")).toContain( + "on 1 day", + ); + }); + + it("offers the record's symptoms, marks a hidden one, and a choice persists", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT"]); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + await trigger.click(); + await expect.element(screen.getByText("Symptoms")).toBeVisible(); + await expect + .element(screen.getByRole("menuitemcheckbox", { name: /Nausea.*hidden/ })) + .toBeVisible(); + const headache = screen.getByRole("menuitemcheckbox", { + name: "Headache", + }); + await expect.element(headache).toHaveAttribute("aria-checked", "false"); + await headache.click(); + await expect.element(headache).toHaveAttribute("aria-checked", "true"); + expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([ + "WEIGHT", + HEADACHE, + ]); + await expect + .poll(() => lastTimelineValues(net)) + .toEqual(["WEIGHT", HEADACHE]); + }); + + it("neither offers nor asks for a symptom with the illness module off", async () => { + await page.viewport(1280, 900); + const { net, screen } = await mount(["WEIGHT", HEADACHE], { + timeline: true, + illness: false, + }); + const trigger = screen.getByRole("button", { name: /Values \(1\)/ }); + await expect.element(trigger).toBeVisible(); + expect(lastTimelineValues(net)).toEqual(["WEIGHT"]); + await trigger.click(); + await expect + .element(screen.getByRole("menuitemcheckbox", { name: "Weight" })) + .toBeVisible(); + expect( + document.querySelector('[data-slot="timeline-values-symptoms"]'), + ).toBe(null); + }); + + it("fits a phone and names the symptom's means in the chronicle", async () => { + await page.viewport(390, 844); + const { screen } = await mount([HEADACHE]); + await expect + .element(screen.getByRole("button", { name: "Values (1)" })) + .toBeVisible(); + await expect + .poll( + () => + document.querySelectorAll( + `[data-slot="timeline-mean-part"][data-series="${HEADACHE}"]`, + ).length, + ) + .toBeGreaterThan(0); + const root = document.documentElement; + expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth); + }); +}); diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 282fe79eb..04fe83f8a 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -43,6 +43,7 @@ import { import { useItemWords } from "./item-words"; import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines"; import { itemsInPeriod } from "./timeline-geometry"; +import { useSeriesCountText } from "./use-series-value-format"; import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart"; import { bucketValues, @@ -78,7 +79,8 @@ export function SelectionBar({ onOpenDay, onEditLifeEvent, }: SelectionBarProps) { - const { t, tCount, locale } = useTranslations(); + const { t, locale } = useTranslations(); + const countText = useSeriesCountText(); const words = useItemWords(); const intl = resolveIntlLocale(locale); const bucket = timeline.bucket; @@ -218,7 +220,7 @@ export function SelectionBar({ label: seriesLabel, bloodPressure: t("timeline.values.bloodPressure"), noValue: t("timeline.values.noValue"), - readings: (n) => tCount("timeline.values.readings", n), + readings: countText, })} seriesColor={seriesColor} /> diff --git a/src/components/timeline/series-format.ts b/src/components/timeline/series-format.ts index 728c22a91..83ed55549 100644 --- a/src/components/timeline/series-format.ts +++ b/src/components/timeline/series-format.ts @@ -179,7 +179,8 @@ export function labelledMeanParts( label: (key: string) => string; bloodPressure: string; noValue: string; - readings: (count: number) => string; + /** The readings (a symptom: the days) behind a series' mean. */ + readings: (key: string, count: number) => string; }, ): MeanPart[] { const sys = means.find( @@ -196,7 +197,7 @@ export function labelledMeanParts( parts.push({ key: m.key, text: `${words.bloodPressure} ${pressure(sys, dia!, format)}`, - detail: words.readings(sys.count ?? 0), + detail: words.readings(sys.key, sys.count ?? 0), }); continue; } @@ -206,8 +207,34 @@ export function labelledMeanParts( m.mean === null ? `${words.label(m.key)} ${words.noValue}` : `${words.label(m.key)} ${formatSeriesValue(m.key, m.mean, m.unit, format)}`, - ...(m.mean === null ? {} : { detail: words.readings(m.count ?? 0) }), + ...(m.mean === null + ? {} + : { detail: words.readings(m.key, m.count ?? 0) }), }); } return parts; } + +/** + * The timeline with its symptom lines (#981) folded into the value lines. + * A symptom line is a bucket mean like any value line, so the chart, the + * selection bar, the tables and the chronicle draw and word it as one: on + * its fixed scale (`layoutSeries`), with the days behind each mean + * (`useSeriesCountText`). The server's points are kept as they are. + */ +export function withSymptomLines< + T extends Pick, +>(timeline: T): T { + if (timeline.symptomSeries.length === 0) return timeline; + return { + ...timeline, + series: [ + ...timeline.series, + ...timeline.symptomSeries.map((s) => ({ + key: s.key, + unit: null, + points: s.points, + })), + ], + }; +} diff --git a/src/components/timeline/series-geometry.ts b/src/components/timeline/series-geometry.ts index 2974adbd4..624f4b4d9 100644 --- a/src/components/timeline/series-geometry.ts +++ b/src/components/timeline/series-geometry.ts @@ -3,11 +3,13 @@ * geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks * them under the lanes; each row is laid out here on its own scale. */ -import type { - TimelineBucket, - TimelineLabPoint, - TimelineLabSeries, - TimelineSeries, +import { + TIMELINE_SYMPTOM_SCALE, + timelineSymptomDefinitionId, + type TimelineBucket, + type TimelineLabPoint, + type TimelineLabSeries, + type TimelineSeries, } from "@/lib/day/contract"; import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates"; @@ -70,7 +72,8 @@ export const MAX_BRIDGED_GAP: Readonly> = { }; /** - * One neutral line per series, scaled to its own range inside the window. + * One neutral line per series, scaled to its own range inside the window (a + * symptom's to the fixed intensity scale, `TIMELINE_SYMPTOM_SCALE`). * A point sits in the middle of its bucket, and every bucket with a reading * gets its point, so one month with no neighbours stays visible. Missing * buckets stay missing: a short gap is bridged by a dashed stroke @@ -104,9 +107,17 @@ export function layoutSeries( points: [], }; } - let lo = Math.min(...inside.map((p) => p.mean)); - let hi = Math.max(...inside.map((p) => p.mean)); - const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); + // A symptom line sits on the intensity scale whatever the window + // holds (#981): a 3 reads as a 3, not as the top of a quiet stretch. + const fixed = + timelineSymptomDefinitionId(s.key) !== null + ? TIMELINE_SYMPTOM_SCALE + : null; + let lo = fixed ? fixed.min : Math.min(...inside.map((p) => p.mean)); + let hi = fixed ? fixed.max : Math.max(...inside.map((p) => p.mean)); + const pad = fixed + ? (hi - lo) * 0.05 + : Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5); lo -= pad; hi += pad; const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo)); diff --git a/src/components/timeline/series-point-tips.tsx b/src/components/timeline/series-point-tips.tsx index eb467261a..8bbe679bd 100644 --- a/src/components/timeline/series-point-tips.tsx +++ b/src/components/timeline/series-point-tips.tsx @@ -53,6 +53,7 @@ import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines"; import { formatSeriesValue, type SeriesValueFormat } from "./series-format"; import { formatDayMonthYear } from "./timeline-dates"; import type { TimelineLayout } from "./timeline-geometry"; +import { useSeriesCountText } from "./use-series-value-format"; /** Hit target across, in px: bigger than the dot, a comfortable aim. */ export const POINT_TARGET = 24; @@ -401,7 +402,8 @@ function useTipLines({ fmt: SeriesValueFormat; bucketLabel: (t: string) => string; }): Array<{ key: string; points: TipPoint[] }> { - const { t: tr, tCount, locale } = useTranslations(); + const { t: tr, locale } = useTranslations(); + const countText = useSeriesCountText(); const intl = resolveIntlLocale(locale); const verdict = useLabVerdict(); const labValueText = useLabValueText(); @@ -420,13 +422,13 @@ function useTipLines({ x: p.x, y: p.y, day: dayOf(p.t), - ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`, + ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${countText(s.key, p.count)}`, label: bucketLabel(p.t), row: { name: seriesLabel(s.key), value: formatSeriesValue(s.key, p.mean, s.unit, fmt), color: seriesColor(s.key), - delta: tCount("timeline.values.readings", p.count), + delta: countText(s.key, p.count), }, report: null, })), diff --git a/src/components/timeline/timeline-legend.tsx b/src/components/timeline/timeline-legend.tsx index ef1b57ad8..927d7ffc4 100644 --- a/src/components/timeline/timeline-legend.tsx +++ b/src/components/timeline/timeline-legend.tsx @@ -19,11 +19,17 @@ import { LabExcludedNote } from "./lab-lines"; export function TimelineLegend({ bucket, hasSeries, + hasSymptoms = false, labSeries = [], label = (key) => key, }: { bucket: TimelineBucket; hasSeries: boolean; + /** + * A symptom line is drawn (#981): what its points are is said once, since + * its mean is of day peaks over logged days, not of readings. + */ + hasSymptoms?: boolean; /** * The lab lines drawn: their three marks are explained, and what a line * left out is said here, where the marks are (#1299). @@ -113,6 +119,14 @@ export function TimelineLegend({ )} + {hasSymptoms && ( + + {t("timeline.legendSymptom")} + + )} {hasLabs && ( <>