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 && ( <>