Merge branch 'feat/v1471-981' into release/v1.47.1

This commit is contained in:
Marc-André Bombeck committed 2026-11-07 00:41:43 +01:00
commit a605a7223f
39 files changed
+1871 -132

No files matched your search

+134 -15
View File
@@ -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:<biomarkerId>` (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:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (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:<biomarkerId>` (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:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (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:<definitionId>`, 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:<definitionId>`, 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:
+83
View File
@@ -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,
}) => {
+63 -1
View File
@@ -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<TimelineMockLog> {
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
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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": "삶의 사건",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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": "Событие жизни",
+1
View File
@@ -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,
},
});
@@ -286,6 +286,8 @@ function everyKind(): TimelineResponse {
},
],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [],
};
// The fixture itself must reach every kind, or the guard proves less.
@@ -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" }],
};
}
@@ -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]);
});
});
@@ -138,6 +138,8 @@ function timeline(values: readonly string[]): TimelineResponse {
unit: "ng/mL",
},
],
symptomSeries: [],
availableSymptoms: [],
notable: [],
};
}
@@ -149,6 +149,8 @@ function twoMedications(): TimelineResponse {
availableSeries: [],
labSeries: [],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [],
};
}
@@ -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<string, boolean> = { timeline: true, illness: true },
) {
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
setSearch("?zoom=all");
const net = stubTimeline();
const { screen } = await renderWithApp(<TimelineView />, {
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<SVGCircleElement>(
`[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<HTMLButtonElement>(
`[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);
});
});
+4 -2
View File
@@ -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}
/>
+30 -3
View File
@@ -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<TimelineResponse, "series" | "symptomSeries">,
>(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,
})),
],
};
}
+20 -9
View File
@@ -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<Record<TimelineBucket, number>> = {
};
/**
* 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));
@@ -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,
})),
@@ -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({
</span>
</>
)}
{hasSymptoms && (
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-symptom"
>
{t("timeline.legendSymptom")}
</span>
)}
{hasLabs && (
<>
<span
+94 -8
View File
@@ -12,6 +12,9 @@
* list sorts them into; a lab line is chosen and stored like any other, up
* to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are
* held back with a line that says why, and a chosen one can always go.
* Between the two it offers the person's own symptoms (#981) that have been
* logged, hidden ones included and marked as such, on the same terms up to
* `TIMELINE_SYMPTOM_SERIES_MAX`.
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -28,7 +31,9 @@ import {
} from "@/components/ui/dropdown-menu";
import {
TIMELINE_LAB_SERIES_MAX,
TIMELINE_SYMPTOM_SERIES_MAX,
timelineLabMarkerId,
timelineSymptomDefinitionId,
type TimelineLaneKey,
type TimelineResponse,
} from "@/lib/day/contract";
@@ -43,8 +48,15 @@ export interface LabOptionGroup {
keys: readonly string[];
}
/** A symptom the menu offers: its key, and whether it is hidden from entry. */
export interface SymptomOption {
key: string;
hidden: boolean;
}
export function ValueSeriesMenu({
options,
symptoms = [],
labGroups = [],
selected,
label,
@@ -53,6 +65,8 @@ export function ValueSeriesMenu({
iconOnly = false,
}: {
options: readonly string[];
/** The record's logged symptoms, in the record's own order. */
symptoms?: readonly SymptomOption[];
/** The record's lab markers, grouped as the labs list groups them. */
labGroups?: readonly LabOptionGroup[];
selected: readonly string[];
@@ -67,13 +81,20 @@ export function ValueSeriesMenu({
const labsFull =
selected.filter((key) => timelineLabMarkerId(key) !== null).length >=
TIMELINE_LAB_SERIES_MAX;
const option = (key: string) => {
const symptomsFull =
selected.filter((key) => timelineSymptomDefinitionId(key) !== null)
.length >= TIMELINE_SYMPTOM_SERIES_MAX;
const option = (key: string, hint?: string) => {
const checked = selected.includes(key);
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
disabled={labsFull && !checked && timelineLabMarkerId(key) !== null}
disabled={
!checked &&
((labsFull && timelineLabMarkerId(key) !== null) ||
(symptomsFull && timelineSymptomDefinitionId(key) !== null))
}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
@@ -92,6 +113,9 @@ export function ValueSeriesMenu({
aria-hidden="true"
/>
{label(key)}
{hint ? (
<span className="text-muted-foreground ml-auto text-xs">{hint}</span>
) : null}
</DropdownMenuCheckboxItem>
);
};
@@ -121,7 +145,33 @@ export function ValueSeriesMenu({
data-slot="timeline-values-menu"
>
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
{options.map(option)}
{options.map((key) => option(key))}
{symptoms.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel data-slot="timeline-values-symptoms">
{t("timeline.symptoms.menuTitle")}
</DropdownMenuLabel>
{symptomsFull && (
<p
data-slot="timeline-values-symptoms-full"
className="text-muted-foreground px-2 pb-1 text-xs"
>
{t("timeline.symptoms.full", {
max: TIMELINE_SYMPTOM_SERIES_MAX,
})}
</p>
)}
<DropdownMenuGroup data-slot="timeline-values-symptom-group">
{symptoms.map((s) =>
option(
s.key,
s.hidden ? t("timeline.symptoms.hidden") : undefined,
),
)}
</DropdownMenuGroup>
</>
)}
{labGroups.length > 0 && (
<>
<DropdownMenuSeparator />
@@ -146,7 +196,7 @@ export function ValueSeriesMenu({
{group.panel ?? t("timeline.labs.otherPanel")}
</DropdownMenuLabel>
)}
{group.keys.map(option)}
{group.keys.map((key) => option(key))}
</DropdownMenuGroup>
))}
</>
@@ -251,10 +301,26 @@ export function labMarkerNames(
);
}
/** Each symptom's name by its key, from what the server sent. */
export function symptomNames(
timeline:
Pick<TimelineResponse, "availableSymptoms" | "symptomSeries"> | undefined,
): Map<string, string> {
const out = new Map<string, string>();
for (const s of [
...(timeline?.availableSymptoms ?? []),
...(timeline?.symptomSeries ?? []),
]) {
if (s.label !== null) out.set(s.key, s.label);
}
return out;
}
/**
* The values menu as the page holds it: every series the record has, the
* chosen ones kept even when they have nothing yet, in `rank`'s order and
* then the record's; then the record's lab markers by group. It counts and
* then the record's; then the record's logged symptoms in its own order;
* then the record's lab markers by group. It counts and
* toggles the lines it can name, so a stored marker the record no longer
* has drops out with the next change.
*/
@@ -264,6 +330,7 @@ export function TimelineValuesMenu({
allowed,
rank,
labsOn,
symptomsOn,
label,
color,
onChange,
@@ -274,6 +341,7 @@ export function TimelineValuesMenu({
allowed: (key: string) => boolean;
rank: readonly string[];
labsOn: boolean;
symptomsOn: boolean;
label: (key: string) => string;
color: (key: string) => string;
onChange: (next: string[]) => void;
@@ -283,7 +351,11 @@ export function TimelineValuesMenu({
const options = [
...new Set([
...(timeline?.availableSeries ?? []).filter(allowed),
...values.filter((key) => timelineLabMarkerId(key) === null),
...values.filter(
(key) =>
timelineLabMarkerId(key) === null &&
timelineSymptomDefinitionId(key) === null,
),
]),
].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
const markers = new Map(
@@ -291,15 +363,29 @@ export function TimelineValuesMenu({
(m) => [m.key, m],
),
);
// A chosen symptom is offered even when the list no longer holds it (a
// line sent for it names it), so it can be unticked.
const symptomMap = new Map(
[
...(timeline?.availableSymptoms ?? []),
...(timeline?.symptomSeries ?? []),
].map((s) => [s.key, { key: s.key, hidden: !s.isActive }]),
);
const symptoms = symptomsOn ? [...symptomMap.values()] : [];
const labGroups = labsOn
? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
: [];
return (
<ValueSeriesMenu
options={options}
symptoms={symptoms}
labGroups={labGroups}
selected={values.filter(
(key) => timelineLabMarkerId(key) === null || markers.has(key),
selected={values.filter((key) =>
timelineLabMarkerId(key) !== null
? markers.has(key)
: timelineSymptomDefinitionId(key) !== null
? symptomMap.has(key)
: true,
)}
label={label}
color={color}
+4 -2
View File
@@ -25,6 +25,7 @@ import {
formatBucket,
} from "./timeline-dates";
import { isSpan } from "./timeline-geometry";
import { useSeriesCountText } from "./use-series-value-format";
/**
* The chart as a table, for a screen reader: every entry with its lane and
@@ -116,7 +117,8 @@ export function SeriesTable({
fmt: SeriesValueFormat;
intl: string;
}) {
const { t, tCount } = useTranslations();
const { t } = useTranslations();
const countText = useSeriesCountText();
const { bucket, series } = timeline;
const inWindow = (start: string) =>
start <= window.to && bucketAfter(start, bucket) > window.from;
@@ -163,7 +165,7 @@ export function SeriesTable({
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${countText(s.key, point.count)}`
: t("timeline.values.noValue")}
</td>
);
+53 -17
View File
@@ -17,6 +17,9 @@
* Lab markers the record has values for are offered beside the measurements
* (#1299): a chosen marker is stored and requested like any other line, as
* `lab:<biomarkerId>`, and drawn as its single readings, never as means.
* The person's own symptoms (#981) are offered the same way, as
* `symptom:<definitionId>`, and drawn as value lines on the fixed intensity
* scale: each point the mean of the day peaks the server averaged.
*
* At 768 px and up the chart; below it the chronicle, which is also the
* list form of the chart. The readiness card sits on top while a lane is
@@ -37,10 +40,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
capTimelineLabKeys,
capTimelineSeriesKeys,
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
timelineLabMarkerId,
timelineSymptomDefinitionId,
type LifeEventDTO,
type TimelineLaneKey,
type TimelineResponse,
@@ -88,7 +92,9 @@ import {
LayersMenu,
TimelineValuesMenu,
labMarkerNames,
symptomNames,
} from "./timeline-menus";
import { withSymptomLines } from "./series-format";
import {
isBoolean,
isStringArray,
@@ -151,7 +157,8 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
data.lanes.every((l) => l.items.length === 0) &&
data.standing.length === 0 &&
data.series.every((s) => s.points.length === 0) &&
data.labSeries.every((s) => s.points.length === 0)
data.labSeries.every((s) => s.points.length === 0) &&
data.symptomSeries.every((s) => s.points.length === 0)
);
}
@@ -178,6 +185,10 @@ function useValueLines(
// A lab line belongs to the labs module; whether the record has the
// marker is the server's to say.
if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
// A symptom line rides the illness module, as symptoms do everywhere.
if (timelineSymptomDefinitionId(key) !== null) {
return modules?.illness !== false;
}
if (!KNOWN_SERIES.has(key)) return false;
const owner: ModuleKey | null | undefined =
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
@@ -185,10 +196,10 @@ function useValueLines(
},
[modules],
);
// Lab markers past the cap are not asked for (the server would leave
// them out); the menu holds further ones back.
// Lab markers and symptoms past their caps are not asked for (the server
// would leave them out); the menu holds further ones back.
const values = useMemo(
() => capTimelineLabKeys(storedValues.filter(allowed)),
() => capTimelineSeriesKeys(storedValues.filter(allowed)),
[storedValues, allowed],
);
// One colour per chosen line, shared by the chart, the selection bar, the
@@ -202,6 +213,39 @@ function useValueLines(
return { values, allowed, setStoredValues, seriesColor };
}
/**
* The timeline as the page draws it: symptom lines (#981) are drawn and
* worded as value lines from here on.
*/
function useWithSymptomLines(
raw: TimelineResponse | undefined,
): TimelineResponse | undefined {
return useMemo(() => (raw ? withSymptomLines(raw) : undefined), [raw]);
}
/**
* Each value line's name. Blood pressure reads as its own name here; the
* list labels ("Sys", "Dia") lean on a column header the chart does not
* have. A lab marker reads by its own name, as on the labs list, and a
* symptom by the name the person gave it.
*/
function useSeriesLabel(
data: TimelineResponse | undefined,
): (key: string) => string {
const { t } = useTranslations();
const labNames = labMarkerNames(data);
const symptomLabels = symptomNames(data);
return (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: timelineSymptomDefinitionId(key) !== null
? (symptomLabels.get(key) ?? t("timeline.symptoms.unnamed"))
: (labNames.get(key) ??
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
}
export function TimelineView() {
const { t } = useTranslations();
const { user } = useAuth();
@@ -318,7 +362,7 @@ export function TimelineView() {
}
}
const data = timeline.data;
const data = useWithSymptomLines(timeline.data);
// A pick outside a newly chosen range no longer counts as one.
const chosen =
selected !== null &&
@@ -362,17 +406,7 @@ export function TimelineView() {
setLifeEventOpen(true);
}
const labNames = labMarkerNames(data);
// Blood pressure reads as its own name here; the list labels ("Sys",
// "Dia") lean on a column header the chart does not have. A lab marker
// reads by its own name, as on the labs list.
const seriesLabel = (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: (labNames.get(key) ??
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
const seriesLabel = useSeriesLabel(data);
const shownWindow = data
? windowFor(
@@ -405,6 +439,7 @@ export function TimelineView() {
allowed={allowed}
rank={SERIES_RANK}
labsOn={modules?.labs !== false}
symptomsOn={modules?.illness !== false}
label={seriesLabel}
color={seriesColor}
onChange={setStoredValues}
@@ -603,6 +638,7 @@ export function TimelineView() {
<TimelineLegend
bucket={data.bucket}
hasSeries={data.series.length > 0}
hasSymptoms={data.symptomSeries.length > 0}
labSeries={data.labSeries}
label={seriesLabel}
/>
@@ -1,9 +1,13 @@
"use client";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useDayValueFormat } from "@/components/day/use-day-value-format";
import { useFormatters } from "@/lib/i18n/context";
import {
TIMELINE_SYMPTOM_SCALE,
timelineSymptomDefinitionId,
} from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
@@ -14,18 +18,26 @@ const MOOD_SERIES_KEY = "MOOD";
* The timeline's value format: the day view's formatter for every
* measurement series (unit preference, whole numbers for pressure and
* pulse, sleep as a duration), one plain decimal for mood, which has no
* measurement type behind it.
* measurement type behind it, and at most one decimal out of the scale's top
* for a symptom ("6.5/10", "3/10", #981).
*/
export function useSeriesValueFormat(): SeriesValueFormat {
const fmt = useFormatters();
const { number, unitFor } = useDayValueFormat();
return useMemo(() => {
const plain = plainSeriesFormat(fmt);
// At most one decimal, none for a whole intensity: "3/10", "6.5/10".
const symptomNumber = (value: number) => {
const rounded = Math.round(value * 10) / 10;
return fmt.number(rounded, Number.isInteger(rounded) ? 0 : 1);
};
return {
number: (key, value, unit) =>
key === MOOD_SERIES_KEY || unit === null
? plain.number(key, value, unit)
: number({ type: key, unit }, value),
timelineSymptomDefinitionId(key) !== null
? `${symptomNumber(value)}/${TIMELINE_SYMPTOM_SCALE.max}`
: key === MOOD_SERIES_KEY || unit === null
? plain.number(key, value, unit)
: number({ type: key, unit }, value),
unit: (key, unit) =>
key === MOOD_SERIES_KEY || unit === null
? plain.unit(key, unit)
@@ -33,3 +45,19 @@ export function useSeriesValueFormat(): SeriesValueFormat {
};
}, [fmt, number, unitFor]);
}
/**
* What stands behind one point of a value line: "mean of 4 readings" for a
* measurement or mood, "on 3 days" for a symptom, whose count is the days
* it was logged (#981).
*/
export function useSeriesCountText(): (key: string, count: number) => string {
const { tCount } = useTranslations();
return useCallback(
(key, count) =>
timelineSymptomDefinitionId(key) !== null
? tCount("timeline.symptoms.days", count)
: tCount("timeline.values.readings", count),
[tCount],
);
}
+51 -10
View File
@@ -428,6 +428,10 @@ export type TimelineSeries = TimelineResponse["series"][number];
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
export type TimelineLabPoint = TimelineLabSeries["points"][number];
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
export type TimelineSymptomSeries = TimelineResponse["symptomSeries"][number];
export type TimelineSymptomPoint = TimelineSymptomSeries["points"][number];
export type TimelineSymptomOption =
TimelineResponse["availableSymptoms"][number];
/**
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
@@ -450,18 +454,55 @@ export function timelineLabMarkerId(key: string): string | null {
}
/**
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left
* out, everything else in its place. The server applies it to what it is
* asked for and the page to what it asks, so a stored list longer than the
* cap (another device, an older build) loads its first markers instead of
* failing the page.
* A person-defined symptom's line is named in `values` as
* `symptom:<definitionId>` (#981), beside the lab markers and on the same
* terms: checked for its shape only, capped, and left out without an error
* when the record does not have it.
*/
export function capTimelineLabKeys(keys: readonly string[]): string[] {
export const TIMELINE_SYMPTOM_KEY_PREFIX = "symptom:";
/** The most symptoms one request draws. Definition ids are not a closed set. */
export const TIMELINE_SYMPTOM_SERIES_MAX = 24;
/**
* The scale a symptom line is drawn on: the intensity a symptom is logged
* with, 0 to 10, whatever the window holds, so two symptoms (and one symptom
* in two windows) read against the same marks.
*/
export const TIMELINE_SYMPTOM_SCALE = { min: 0, max: 10 } as const;
/** The definition id a `values` entry names, or null when it names none. */
export function timelineSymptomDefinitionId(key: string): string | null {
if (!key.startsWith(TIMELINE_SYMPTOM_KEY_PREFIX)) return null;
const id = key.slice(TIMELINE_SYMPTOM_KEY_PREFIX.length);
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
}
/** The `values` entry for a symptom definition. */
export function timelineSymptomKey(definitionId: string): string {
return `${TIMELINE_SYMPTOM_KEY_PREFIX}${definitionId}`;
}
/**
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` and
* every symptom past the first `TIMELINE_SYMPTOM_SERIES_MAX` left out,
* everything else in its place. The server applies it to what it is asked
* for and the page to what it asks, so a stored list longer than a cap
* (another device, an older build) loads its first lines instead of failing
* the page.
*/
export function capTimelineSeriesKeys(keys: readonly string[]): string[] {
let labs = 0;
return keys.filter(
(key) =>
timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX,
);
let symptoms = 0;
return keys.filter((key) => {
if (timelineLabMarkerId(key) !== null) {
return ++labs <= TIMELINE_LAB_SERIES_MAX;
}
if (timelineSymptomDefinitionId(key) !== null) {
return ++symptoms <= TIMELINE_SYMPTOM_SERIES_MAX;
}
return true;
});
}
/** The `values` entry for a lab marker. */
+12
View File
@@ -0,0 +1,12 @@
/**
* The local calendar date every day-view and timeline schema speaks in. Its
* own module so the schema files beside `./wire-schemas.ts` can share it.
*/
import { z } from "zod/v4";
import { isCalendarDateKey } from "@/lib/tz/date-only";
export const dateKeySchema = z
.string()
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
.describe("A local calendar date, `YYYY-MM-DD`.");
+75
View File
@@ -0,0 +1,75 @@
/**
* The person-defined symptom lines of `GET /api/timeline` (#981): one
* symptom's points, bucket means of its day peaks over the days it was
* logged, and the symptoms the record offers. Server and OpenAPI side only,
* like `./wire-schemas.ts`, which places them in the timeline's response.
*/
import { z } from "zod/v4";
import { TIMELINE_SYMPTOM_SCALE } from "./contract";
import { dateKeySchema } from "./date-key-schema";
const timelineSymptomPoint = z
.object({
t: dateKeySchema.describe("The bucket's first day."),
mean: z
.number()
.min(TIMELINE_SYMPTOM_SCALE.min)
.max(TIMELINE_SYMPTOM_SCALE.max)
.describe(
"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: z
.number()
.int()
.min(1)
.describe(
"Days the symptom was logged in the bucket, the days behind the mean. Clients draw a point from fewer than three as thin.",
),
})
.meta({ id: "TimelineSymptomPoint" });
export const timelineSymptomSeries = z
.object({
key: z.string().describe("`symptom:<definitionId>`, as named in `values`."),
definitionId: z.string(),
label: z
.string()
.nullable()
.describe(
"The person's own name for the symptom. Null when it cannot be read; the client words a stand-in.",
),
icon: z
.string()
.nullable()
.describe("The symptom's icon name; null for the default glyph."),
isActive: z
.boolean()
.describe(
"False for a hidden symptom: no longer offered for entry, its history kept and drawn.",
),
scale: z
.object({
min: z.literal(TIMELINE_SYMPTOM_SCALE.min),
max: z.literal(TIMELINE_SYMPTOM_SCALE.max),
})
.describe(
"The fixed scale the line is drawn on, the intensity scale, whatever the window holds.",
),
points: z
.array(timelineSymptomPoint)
.describe(
"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.",
),
})
.meta({ id: "TimelineSymptomSeries" });
export const timelineSymptomOption = z
.object({
key: z.string().describe("`symptom:<definitionId>`, to name in `values`."),
definitionId: z.string(),
label: z.string().nullable(),
icon: z.string().nullable(),
isActive: z.boolean(),
})
.meta({ id: "TimelineSymptomOption" });
+18 -7
View File
@@ -9,7 +9,7 @@
*/
import { z } from "zod/v4";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import {
DAY_EVENT_KINDS,
@@ -34,12 +34,13 @@ import {
} from "./contract";
import { isoDateTime } from "@/lib/validations/datetime";
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
import { dateKeySchema } from "./date-key-schema";
import {
timelineSymptomOption,
timelineSymptomSeries,
} from "./timeline-symptom-schemas";
export const dateKeySchema = z
.string()
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
.describe("A local calendar date, `YYYY-MM-DD`.");
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
const instant = isoDateTime();
@@ -401,7 +402,7 @@ export const timelineQuerySchema = withWindowRules(
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (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.",
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, a lab marker as `lab:<biomarkerId>` (see `availableLabs`), or a person-defined symptom as `symptom:<definitionId>` (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.",
),
}),
);
@@ -616,6 +617,16 @@ export const timelineResponseSchema = z
.describe(
"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: z
.array(timelineSymptomSeries)
.describe(
"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: z
.array(timelineSymptomOption)
.describe(
"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: z.array(
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
),
+1 -1
View File
@@ -15,8 +15,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless";
import { dateKeySchema } from "@/lib/day/date-key-schema";
import {
dateKeySchema,
labSeriesQuerySchema,
timelineLabSeries,
} from "@/lib/day/wire-schemas";
+1 -1
View File
@@ -129,7 +129,7 @@ export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["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.",
"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, 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.",
requestParams: { query: timelineQuerySchema },
responses: {
"200": {
@@ -0,0 +1,205 @@
/**
* Person-defined symptoms as lines on the timeline (#981): the day value,
* the bucket mean over logged days, and the `values` entries that name one.
*
* A day's value is its highest intensity; a bucket's point is the mean of
* its logged days' values, with the number of those days; a day without an
* entry is not a zero. The day is the record's local day, so a late-evening
* entry and an entry on a clock-change day land where the person lived them.
* All data invented.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
capTimelineSeriesKeys,
TIMELINE_LAB_SERIES_MAX,
TIMELINE_SYMPTOM_SERIES_MAX,
timelineSymptomDefinitionId,
timelineSymptomKey,
} from "@/lib/day/contract";
import { parseSeriesKeys } from "../load-timeline";
import {
foldSymptomSeries,
symptomDayPeaks,
type SymptomOccurrence,
} from "../symptom-series";
const at = (iso: string, intensity: number): SymptomOccurrence => ({
occurredAt: new Date(iso),
intensity,
});
const BERLIN = { from: "2026-10-01", to: "2026-11-30", tz: "Europe/Berlin" };
describe("symptomDayPeaks", () => {
it("takes the highest intensity of each local day", () => {
const peaks = symptomDayPeaks(
[
at("2026-10-05T07:00:00.000Z", 3),
at("2026-10-05T15:00:00.000Z", 7),
at("2026-10-05T18:00:00.000Z", 5),
at("2026-10-06T09:00:00.000Z", 0),
],
BERLIN,
);
expect([...peaks]).toEqual([
["2026-10-05", 7],
["2026-10-06", 0],
]);
});
it("puts a late-evening entry on the local day, not the UTC one", () => {
// 22:30 UTC on 25 Oct is 23:30 in Berlin, an hour after the clocks went
// back; 23:30 UTC is already 26 Oct there.
const peaks = symptomDayPeaks(
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
BERLIN,
);
expect([...peaks]).toEqual([
["2026-10-25", 4],
["2026-10-26", 6],
]);
// West of UTC the cut runs the other way: 03:30 UTC on 9 March is still
// 8 March in New York, the evening the clocks went forward.
const ny = symptomDayPeaks([at("2026-03-09T03:30:00.000Z", 8)], {
from: "2026-03-01",
to: "2026-03-31",
tz: "America/New_York",
});
expect([...ny]).toEqual([["2026-03-08", 8]]);
});
it("holds a 23-hour clock-change day whole", () => {
// 29 March 2026 in Berlin runs from 23:00 UTC on the 28th to 22:00 UTC.
const peaks = symptomDayPeaks(
[
at("2026-03-28T23:05:00.000Z", 2),
at("2026-03-29T21:55:00.000Z", 5),
at("2026-03-29T22:05:00.000Z", 9),
],
{ from: "2026-03-01", to: "2026-03-31", tz: "Europe/Berlin" },
);
expect([...peaks]).toEqual([
["2026-03-29", 5],
["2026-03-30", 9],
]);
});
it("leaves out entries outside the window", () => {
const peaks = symptomDayPeaks(
[at("2026-09-30T12:00:00.000Z", 9), at("2026-12-01T12:00:00.000Z", 9)],
BERLIN,
);
expect(peaks.size).toBe(0);
});
});
describe("foldSymptomSeries", () => {
it("averages the day peaks over the logged days only, never counting a silent day as zero", () => {
const points = foldSymptomSeries(
[
// Week of 5 Oct: two logged days, peaks 7 and 4.
at("2026-10-05T07:00:00.000Z", 3),
at("2026-10-05T15:00:00.000Z", 7),
at("2026-10-08T15:00:00.000Z", 4),
// Week of 19 Oct: one day.
at("2026-10-21T10:00:00.000Z", 2),
],
BERLIN,
"week",
);
expect(points).toEqual([
{ t: "2026-10-05", mean: 5.5, count: 2 },
{ t: "2026-10-19", mean: 2, count: 1 },
]);
});
it("sends no point for a bucket without an entry", () => {
const points = foldSymptomSeries(
[at("2026-10-02T10:00:00.000Z", 6), at("2026-11-20T10:00:00.000Z", 3)],
BERLIN,
"week",
);
expect(points.map((p) => p.t)).toEqual(["2026-09-28", "2026-11-16"]);
});
it("buckets a late-evening entry by its local day, across a week boundary and the clock change", () => {
// Sunday 25 Oct 23:30 in Berlin closes the week of 19 Oct; half an hour
// later it is Monday and the next week.
const points = foldSymptomSeries(
[at("2026-10-25T22:30:00.000Z", 4), at("2026-10-25T23:30:00.000Z", 6)],
BERLIN,
"week",
);
expect(points).toEqual([
{ t: "2026-10-19", mean: 4, count: 1 },
{ t: "2026-10-26", mean: 6, count: 1 },
]);
});
it("follows the month, quarter and day buckets", () => {
const rows = [
at("2026-10-05T10:00:00.000Z", 8),
at("2026-10-05T12:00:00.000Z", 2),
at("2026-10-06T10:00:00.000Z", 3),
at("2026-11-02T10:00:00.000Z", 4),
];
expect(foldSymptomSeries(rows, BERLIN, "month")).toEqual([
{ t: "2026-10-01", mean: 5.5, count: 2 },
{ t: "2026-11-01", mean: 4, count: 1 },
]);
expect(foldSymptomSeries(rows, BERLIN, "quarter")).toEqual([
{ t: "2026-10-01", mean: 5, count: 3 },
]);
expect(foldSymptomSeries(rows, BERLIN, "day")).toEqual([
{ t: "2026-10-05", mean: 8, count: 1 },
{ t: "2026-10-06", mean: 3, count: 1 },
{ t: "2026-11-02", mean: 4, count: 1 },
]);
});
it("keeps a logged zero as a value", () => {
expect(
foldSymptomSeries([at("2026-10-05T10:00:00.000Z", 0)], BERLIN, "month"),
).toEqual([{ t: "2026-10-01", mean: 0, count: 1 }]);
});
});
describe("symptom keys in `values`", () => {
it("round-trips a definition id and refuses a malformed one", () => {
expect(timelineSymptomKey("sd1")).toBe("symptom:sd1");
expect(timelineSymptomDefinitionId("symptom:sd1")).toBe("sd1");
expect(timelineSymptomDefinitionId("symptom:")).toBeNull();
expect(timelineSymptomDefinitionId("symptom:../x")).toBeNull();
expect(timelineSymptomDefinitionId("lab:sd1")).toBeNull();
});
it("rides beside measurement names and lab markers, and a malformed one refuses", () => {
expect(
parseSeriesKeys("WEIGHT,symptom:sd1,lab:bm1,symptom:sd1,MOOD"),
).toEqual(["WEIGHT", "symptom:sd1", "lab:bm1", "MOOD"]);
expect(parseSeriesKeys("symptom:../etc")).toBeNull();
});
it("caps symptoms and lab markers each on their own", () => {
const symptoms = Array.from(
{ length: TIMELINE_SYMPTOM_SERIES_MAX + 3 },
(_, i) => `symptom:s${i}`,
);
const labs = Array.from(
{ length: TIMELINE_LAB_SERIES_MAX + 2 },
(_, i) => `lab:m${i}`,
);
const capped = capTimelineSeriesKeys(["WEIGHT", ...symptoms, ...labs]);
expect(capped.filter((k) => k.startsWith("symptom:"))).toEqual(
symptoms.slice(0, TIMELINE_SYMPTOM_SERIES_MAX),
);
expect(capped.filter((k) => k.startsWith("lab:"))).toEqual(
labs.slice(0, TIMELINE_LAB_SERIES_MAX),
);
expect(capped[0]).toBe("WEIGHT");
});
});
+72 -37
View File
@@ -17,10 +17,11 @@ import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums";
import type { MeasurementType } from "@/generated/prisma/enums";
import {
capTimelineLabKeys,
capTimelineSeriesKeys,
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LANE_KEYS,
timelineLabMarkerId,
timelineSymptomDefinitionId,
type TimelineBucket,
type TimelineItem,
type TimelineQuery,
@@ -43,6 +44,11 @@ import {
loadLabSeries,
} from "@/lib/timeline/lab-series";
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
import {
firstSymptomDay,
loadAvailableSymptoms,
loadSymptomSeries,
} from "@/lib/timeline/symptom-series";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
@@ -141,9 +147,11 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
* grows past the names that exist, and each series is its own row under the
* lanes. A lab marker (`lab:<biomarkerId>`, #1299) is checked for its shape
* only: whether the record has it is the loader's question, and the answer
* to a stale one is no line rather than an error. Marker ids are not a
* closed set, so their count is capped: the first
* `TIMELINE_LAB_SERIES_MAX` are kept and the rest left out. A refusal would
* to a stale one is no line rather than an error. A person-defined symptom
* (`symptom:<definitionId>`, #981) is checked the same way. Marker and
* definition ids are not a closed set, so their counts are capped: the first
* `TIMELINE_LAB_SERIES_MAX` markers and `TIMELINE_SYMPTOM_SERIES_MAX`
* symptoms are kept and the rest left out. A refusal would
* be stored with the choice and fail the page on every visit, with the menu
* that could undo it never drawn.
*/
@@ -159,9 +167,10 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
];
for (const key of keys) {
if (timelineLabMarkerId(key) !== null) continue;
if (timelineSymptomDefinitionId(key) !== null) continue;
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return capTimelineLabKeys(keys);
return capTimelineSeriesKeys(keys);
}
/**
@@ -250,7 +259,13 @@ export async function loadTimeline(args: {
laneVisible(key, access),
);
const measurementsVisible = access.domainVisible("measurements");
const [reads, firstReading] = await Promise.all([
const labIds = args.seriesKeys
.map(timelineLabMarkerId)
.filter((id): id is string => id !== null);
const symptomIds = args.seriesKeys
.map(timelineSymptomDefinitionId)
.filter((id): id is string => id !== null);
const [reads, firstReading, firstSymptom] = await Promise.all([
Promise.all(visibleLanes.map((key) => LANE_READERS[key](frame))),
measurementsVisible
? prisma.measurement.findFirst({
@@ -259,10 +274,13 @@ export async function loadTimeline(args: {
select: { measuredAt: true },
})
: Promise.resolve(null),
// A chosen symptom has no lane of its own to carry its first day.
firstSymptomDay({ recordId, ids: symptomIds, tz, access }),
]);
const starts = reads.flatMap((r) => r.items.map((i) => i.start));
if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz));
if (firstSymptom) starts.push(firstSymptom);
const dataFrom = starts.length > 0 ? starts.sort()[0] : null;
const { from, to } = resolveTimelineWindow(query, today, dataFrom);
@@ -277,11 +295,9 @@ export async function loadTimeline(args: {
standing.push(...reads[i].standing);
});
const labIds = args.seriesKeys
.map(timelineLabMarkerId)
.filter((id): id is string => id !== null);
const seriesKeys = args.seriesKeys.filter((key) =>
timelineLabMarkerId(key) !== null
timelineLabMarkerId(key) !== null ||
timelineSymptomDefinitionId(key) !== null
? false
: key === MOOD_SERIES_KEY
? access.modules.mood !== false && access.domainVisible("mind")
@@ -296,33 +312,50 @@ export async function loadTimeline(args: {
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
: from;
const bucket = timelineBucket(query.zoom, from, to);
const [series, notable, availableSeries, labSeries, availableLabs] =
await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
loadAvailableLabMarkers({ recordId, access }),
]);
const [
series,
notable,
availableSeries,
labSeries,
availableLabs,
symptomSeries,
availableSymptoms,
] = await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
loadAvailableLabMarkers({ recordId, access }),
loadSymptomSeries({
recordId,
ids: symptomIds,
from,
to,
tz,
bucket,
access,
}),
loadAvailableSymptoms({ recordId, access }),
]);
return {
zoom: query.zoom,
@@ -334,6 +367,8 @@ export async function loadTimeline(args: {
availableSeries,
labSeries,
availableLabs,
symptomSeries,
availableSymptoms,
notable: notable.map(({ date, kind }) => ({ date, kind })),
};
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Person-defined symptoms as lines on the timeline (#981).
*
* A symptom is logged as occurrences, each with an intensity from 0 to 10.
* A day's value is the highest intensity logged on it: three entries of a
* headache on one afternoon are one bad day, not three. The day is the
* record's local day of `occurredAt`, the same cut the day view and every
* lane make, so a point opens the days that list it.
*
* The line follows the zoom's bucket, as a measurement line does: a point is
* the mean of the bucket's day values over the days the symptom was logged,
* and carries how many days that was, so the chart can mark a mean resting
* on one or two days. A day without an entry is not a zero: absence says
* nothing was written down, not that nothing was felt. A bucket without an
* entry is not sent.
*
* Symptoms ride the illness module and the `illness` sharing domain, as the
* symptom routes, the day view's `symptoms` section and the illness lane do,
* so the lines and the list of symptoms are read under the illness lane's
* gate (`laneVisible("illness", ...)`). Labels are decrypted here and never
* logged.
*/
import {
TIMELINE_SYMPTOM_SCALE,
timelineSymptomKey,
type TimelineBucket,
type TimelineSymptomOption,
type TimelineSymptomPoint,
type TimelineSymptomSeries,
} from "@/lib/day/contract";
import type { DayCell } from "@/lib/day/daily-stats";
import { prisma } from "@/lib/db";
import { decryptSymptomText } from "@/lib/symptoms/server";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { laneVisible, type TimelineAccess } from "./lanes";
import { foldDays } from "./series";
/** One logged occurrence, as the fold reads it. */
export interface SymptomOccurrence {
occurredAt: Date;
intensity: number;
}
/**
* The peak intensity of every local day in `[from, to]` that has an entry.
* Occurrences outside the window are left out.
*/
export function symptomDayPeaks(
rows: readonly SymptomOccurrence[],
window: { from: string; to: string; tz: string },
): Map<string, number> {
const peaks = new Map<string, number>();
for (const row of rows) {
const day = userDayKey(row.occurredAt, window.tz);
if (day < window.from || day > window.to) continue;
const prev = peaks.get(day);
if (prev === undefined || row.intensity > prev)
peaks.set(day, row.intensity);
}
return peaks;
}
/**
* A symptom's points over `[from, to]` in `bucket`: the mean of each
* bucket's day peaks over its logged days, and the number of those days.
*/
export function foldSymptomSeries(
rows: readonly SymptomOccurrence[],
window: { from: string; to: string; tz: string },
bucket: TimelineBucket,
): TimelineSymptomPoint[] {
const days = new Map<string, DayCell>();
for (const [day, peak] of symptomDayPeaks(rows, window)) {
// Each logged day weighs one, so `count` sums to the logged days.
days.set(day, { value: peak, count: 1 });
}
return foldDays(days, bucket);
}
const DEFINITION_SELECT = {
id: true,
labelEncrypted: true,
icon: true,
isActive: true,
} as const;
/**
* The lines of the symptoms `ids` names, in that order, over `[from, to]`.
* Every read is narrowed to the record: an id of another record's symptom
* finds nothing and is left out, as is one deleted since it was chosen. A
* symptom found but not logged in the window keeps its (empty) line, so the
* page can still name and untick it.
*/
export async function loadSymptomSeries(args: {
recordId: string;
ids: readonly string[];
from: string;
to: string;
tz: string;
bucket: TimelineBucket;
access: TimelineAccess;
}): Promise<TimelineSymptomSeries[]> {
const { recordId, ids, from, to, tz, bucket, access } = args;
if (ids.length === 0 || !laneVisible("illness", access)) return [];
const definitions = await prisma.symptomDefinition.findMany({
where: { userId: recordId, id: { in: [...ids] } },
select: DEFINITION_SELECT,
});
if (definitions.length === 0) return [];
const rows = await prisma.symptomEvent.findMany({
where: {
userId: recordId,
definitionId: { in: definitions.map((d) => d.id) },
occurredAt: {
gte: startOfLocalDayKey(from, tz),
lt: startOfLocalDayKey(shiftDateKey(to, 1), tz),
},
},
select: { definitionId: true, occurredAt: true, intensity: true },
});
const byDefinition = new Map<string, SymptomOccurrence[]>();
for (const row of rows) {
const list = byDefinition.get(row.definitionId) ?? [];
list.push(row);
byDefinition.set(row.definitionId, list);
}
const found = new Map(definitions.map((d) => [d.id, d]));
return ids.flatMap((id) => {
const definition = found.get(id);
if (!definition) return [];
return [
{
key: timelineSymptomKey(definition.id),
definitionId: definition.id,
label: decryptSymptomText(definition.labelEncrypted, "label"),
icon: definition.icon,
isActive: definition.isActive,
scale: { ...TIMELINE_SYMPTOM_SCALE },
points: foldSymptomSeries(
byDefinition.get(id) ?? [],
{ from, to, tz },
bucket,
),
},
];
});
}
/**
* The earliest local day any of the symptoms `ids` names was logged, or
* null. An `all` window reaches back to it, so a chosen symptom logged
* before anything else in the record is drawn whole.
*/
export async function firstSymptomDay(args: {
recordId: string;
ids: readonly string[];
tz: string;
access: TimelineAccess;
}): Promise<string | null> {
const { recordId, ids, tz, access } = args;
if (ids.length === 0 || !laneVisible("illness", access)) return null;
const first = await prisma.symptomEvent.findFirst({
where: { userId: recordId, definitionId: { in: [...ids] } },
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
});
return first ? userDayKey(first.occurredAt, tz) : null;
}
/**
* Every symptom the record has logged at least once, whatever the window,
* in the record's own order. A hidden symptom is offered too: hiding takes
* it out of the quick entry, and its history is kept, so it can still be
* looked at here.
*/
export async function loadAvailableSymptoms(args: {
recordId: string;
access: TimelineAccess;
}): Promise<TimelineSymptomOption[]> {
const { recordId, access } = args;
if (!laneVisible("illness", access)) return [];
const definitions = await prisma.symptomDefinition.findMany({
where: { userId: recordId, events: { some: {} } },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
select: DEFINITION_SELECT,
});
return definitions.map((d) => ({
key: timelineSymptomKey(d.id),
definitionId: d.id,
label: decryptSymptomText(d.labelEncrypted, "label"),
icon: d.icon,
isActive: d.isActive,
}));
}
@@ -0,0 +1,296 @@
/**
* Person-defined symptoms as lines on the timeline (#981), driven through
* `GET /api/timeline` against real Postgres.
*
* What the unit tests cannot show: that the record's own logged symptoms
* are offered (hidden ones included, never-logged ones not) and nobody
* else's, that a definition id from another record finds nothing, that a
* day is the record's local day and its value the day's highest intensity,
* that an `all` window reaches back to a chosen symptom's first entry, that
* a switched-off illness module reads nothing, and that a whole-record
* grant shares the lines. Labels are decrypted server-side. All data
* invented.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import type { TimelineResponse } from "@/lib/day/contract";
import { shiftDateKey } from "@/lib/tz/format";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
let counter = 0;
const DAY_MS = 86_400_000;
/** The calendar day `n` days before today (UTC), so data stays in range. */
function daysAgo(n: number): string {
return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10);
}
const MODULES = { timeline: true, illness: true };
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `tl-sym-${suffix}`,
email: `tl-sym-${suffix}@example.test`,
displayName: `TL ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
locale: "en",
modulePreferencesJson: MODULES,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function shareWholeRecord(ownerId: string, delegateId: string) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegateId,
access: "READ",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
const session = await signIn(delegateId);
await switchSessionTo(session.id, ownerId);
}
async function getTimeline(query: string): Promise<Response> {
const { GET } = await import("@/app/api/timeline/route");
return (GET as Handler)(
new NextRequest(`http://localhost/api/timeline?${query}`, {
method: "GET",
}),
{ params: Promise.resolve({}) },
);
}
async function data(response: Response): Promise<TimelineResponse> {
expect(response.status).toBe(200);
return ((await response.json()) as { data: TimelineResponse }).data;
}
/** Days of the seeded headache, as the record's local zone cuts them. */
const DAYS = {
first: () => daysAgo(30),
// 23:30 UTC is already the next day in Berlin, summer or winter.
lateNight: () => shiftDateKey(daysAgo(20), 1),
twice: () => daysAgo(10),
};
/**
* A record with a headache logged on three days (one of them twice, one
* late at night), a hidden nausea logged once long ago, and a dizziness
* that was defined but never logged.
*/
async function seedSymptoms(userId: string) {
const db = getPrismaClient();
const define = (label: string, sortOrder: number, isActive = true) =>
db.symptomDefinition.create({
data: {
userId,
labelEncrypted: encryptToBytes(label),
icon: "brain",
sortOrder,
isActive,
},
});
const headache = await define("Headache", 0);
const nausea = await define("Nausea", 1, false);
const dizziness = await define("Dizziness", 2);
const log = (definitionId: string, iso: string, intensity: number) =>
db.symptomEvent.create({
data: { userId, definitionId, occurredAt: new Date(iso), intensity },
});
await log(headache.id, `${daysAgo(30)}T12:00:00.000Z`, 4);
await log(headache.id, `${daysAgo(20)}T23:30:00.000Z`, 7);
await log(headache.id, `${daysAgo(10)}T08:00:00.000Z`, 2);
await log(headache.id, `${daysAgo(10)}T15:00:00.000Z`, 9);
// Long before anything else in the record.
await log(nausea.id, `${daysAgo(900)}T12:00:00.000Z`, 3);
return { headache, nausea, dizziness };
}
/** A 40-day range: under six weeks, so every point is one day. */
const DAY_WINDOW = () => `zoom=range&from=${daysAgo(39)}&to=${daysAgo(0)}`;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("symptom lines on the timeline", () => {
it("offers the record's logged symptoms and draws a chosen one by local day and day peak", async () => {
const owner = await makeUser("owner");
const { headache, nausea } = await seedSymptoms(owner.id);
await signIn(owner.id);
const timeline = await data(
await getTimeline(`${DAY_WINDOW()}&values=WEIGHT,symptom:${headache.id}`),
);
expect(timeline.bucket).toBe("day");
// Never-logged dizziness is not offered; hidden nausea is, marked.
expect(timeline.availableSymptoms).toEqual([
{
key: `symptom:${headache.id}`,
definitionId: headache.id,
label: "Headache",
icon: "brain",
isActive: true,
},
{
key: `symptom:${nausea.id}`,
definitionId: nausea.id,
label: "Nausea",
icon: "brain",
isActive: false,
},
]);
expect(timeline.symptomSeries).toHaveLength(1);
const [line] = timeline.symptomSeries;
expect(line).toMatchObject({
key: `symptom:${headache.id}`,
definitionId: headache.id,
label: "Headache",
isActive: true,
scale: { min: 0, max: 10 },
});
expect(line.points).toEqual([
{ t: DAYS.first(), mean: 4, count: 1 },
{ t: DAYS.lateNight(), mean: 7, count: 1 },
// Logged twice: the day's value is its highest intensity.
{ t: DAYS.twice(), mean: 9, count: 1 },
]);
// A symptom does not touch the measurement lines.
expect(timeline.series).toEqual([]);
});
it("averages day peaks over the logged days in a coarser bucket", async () => {
const owner = await makeUser("owner");
const { headache } = await seedSymptoms(owner.id);
await signIn(owner.id);
// Across 200 days the bucket is a month.
const months = await data(
await getTimeline(
`zoom=range&from=${daysAgo(199)}&to=${daysAgo(0)}&values=symptom:${headache.id}`,
),
);
expect(months.bucket).toBe("month");
const points = months.symptomSeries[0].points;
// Three logged days in all, never a silent day counted as zero.
expect(points.reduce((n, p) => n + p.count, 0)).toBe(3);
// Each mean is of its month's day peaks (rounded to two decimals).
const total = points.reduce((sum, p) => sum + p.mean * p.count, 0);
expect(total).toBeCloseTo(4 + 7 + 9, 1);
});
it("reaches an `all` window back to a chosen symptom's first entry", async () => {
const owner = await makeUser("owner");
const { nausea, headache } = await seedSymptoms(owner.id);
await signIn(owner.id);
const without = await data(
await getTimeline(`zoom=all&values=symptom:${headache.id}`),
);
expect(without.range.dataFrom).toBe(DAYS.first());
const withNausea = await data(
await getTimeline(`zoom=all&values=symptom:${nausea.id}`),
);
expect(withNausea.range.dataFrom).toBe(daysAgo(900));
expect(withNausea.symptomSeries[0].points.map((p) => p.count)).toEqual([1]);
});
it("finds nothing under another record's definition id, and does not refuse", async () => {
const owner = await makeUser("owner");
const other = await makeUser("other");
const { headache: theirs } = await seedSymptoms(other.id);
await signIn(owner.id);
const timeline = await data(
await getTimeline(`zoom=all&values=symptom:${theirs.id},symptom:gone123`),
);
expect(timeline.symptomSeries).toEqual([]);
expect(timeline.availableSymptoms).toEqual([]);
// Another record's first entry does not move this record's window.
expect(timeline.range.dataFrom).toBeNull();
// A malformed id is refused.
expect((await getTimeline(`values=symptom:..%2Fx`)).status).toBe(422);
});
it("draws and offers nothing with the illness module off", async () => {
const owner = await makeUser("owner");
const { headache } = await seedSymptoms(owner.id);
await getPrismaClient().user.update({
where: { id: owner.id },
data: { modulePreferencesJson: { ...MODULES, illness: false } },
});
await signIn(owner.id);
const timeline = await data(
await getTimeline(`zoom=all&values=symptom:${headache.id}`),
);
expect(timeline.symptomSeries).toEqual([]);
expect(timeline.availableSymptoms).toEqual([]);
expect(timeline.range.dataFrom).toBeNull();
});
it("shares the lines on a whole-record grant", async () => {
const owner = await makeUser("owner");
const { headache } = await seedSymptoms(owner.id);
const whole = await makeUser("whole");
await shareWholeRecord(owner.id, whole.id);
const shared = await data(
await getTimeline(`${DAY_WINDOW()}&values=symptom:${headache.id}`),
);
expect(shared.symptomSeries[0].points).toHaveLength(3);
expect(shared.availableSymptoms.map((s) => s.label)).toEqual([
"Headache",
"Nausea",
]);
});
});