From 32258bc5abc76f53f61e61c0dfd138ba171dfcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 14:27:02 +0200 Subject: [PATCH] feat: add the timeline page, its readiness inventory and life-event entry The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone. --- e2e/setup/global-setup.ts | 47 + e2e/timeline.spec.ts | 229 +++++ e2e/utils/mock-timeline.ts | 702 ++++++++++++++ messages/de.json | 168 +++- messages/en.json | 168 +++- messages/es.json | 168 +++- messages/fr.json | 168 +++- messages/it.json | 168 +++- messages/ko.json | 168 +++- messages/pl.json | 168 +++- playwright.config.ts | 5 + ...ate-order-explicit-formatter-guard.test.ts | 4 + .../destructive-control-guard.test.ts | 9 + src/__tests__/i18n-reverse-coverage.test.ts | 2 - .../success-affordance-guard.test.ts | 3 + src/app/timeline/page.tsx | 38 + .../__tests__/dashboard-header.test.tsx | 3 + src/components/dashboard/dashboard-header.tsx | 8 + .../dashboard/quick-entry-sheets.tsx | 31 +- src/components/layout/capture-picker.tsx | 32 +- src/components/settings/modules-section.tsx | 11 + .../__tests__/chronicle-model.test.ts | 167 ++++ .../__tests__/readiness-and-draft.test.ts | 154 +++ .../__tests__/timeline-components.test.tsx | 163 ++++ .../timeline/__tests__/timeline-fixture.ts | 329 +++++++ .../__tests__/timeline-geometry.test.ts | 421 ++++++++ .../timeline/__tests__/value-options.test.ts | 25 + src/components/timeline/chronicle-model.ts | 303 ++++++ src/components/timeline/life-event-draft.ts | 146 +++ .../timeline/life-event-form.lazy.tsx | 26 + src/components/timeline/life-event-form.tsx | 479 +++++++++ src/components/timeline/life-event-sheet.tsx | 49 + src/components/timeline/readiness-card.tsx | 105 ++ .../timeline/readiness-inventory.tsx | 209 ++++ src/components/timeline/readiness-model.ts | 76 ++ .../timeline/readiness-sheet.lazy.tsx | 16 + src/components/timeline/readiness-sheet.tsx | 94 ++ src/components/timeline/segmented.tsx | 85 ++ src/components/timeline/selection-bar.tsx | 156 +++ src/components/timeline/series-format.ts | 68 ++ src/components/timeline/timeline-chart.tsx | 767 +++++++++++++++ .../timeline/timeline-chronicle.tsx | 352 +++++++ src/components/timeline/timeline-dates.ts | 161 ++++ src/components/timeline/timeline-geometry.ts | 911 ++++++++++++++++++ src/components/timeline/timeline-menus.tsx | 136 +++ src/components/timeline/timeline-view.tsx | 480 +++++++++ .../timeline/use-device-preference.ts | 56 ++ src/components/timeline/use-timeline.ts | 95 ++ .../__tests__/surface-module-registry.test.ts | 1 + src/lib/modules/surface.ts | 1 + 50 files changed, 8277 insertions(+), 54 deletions(-) create mode 100644 e2e/timeline.spec.ts create mode 100644 e2e/utils/mock-timeline.ts create mode 100644 src/app/timeline/page.tsx create mode 100644 src/components/timeline/__tests__/chronicle-model.test.ts create mode 100644 src/components/timeline/__tests__/readiness-and-draft.test.ts create mode 100644 src/components/timeline/__tests__/timeline-components.test.tsx create mode 100644 src/components/timeline/__tests__/timeline-fixture.ts create mode 100644 src/components/timeline/__tests__/timeline-geometry.test.ts create mode 100644 src/components/timeline/__tests__/value-options.test.ts create mode 100644 src/components/timeline/chronicle-model.ts create mode 100644 src/components/timeline/life-event-draft.ts create mode 100644 src/components/timeline/life-event-form.lazy.tsx create mode 100644 src/components/timeline/life-event-form.tsx create mode 100644 src/components/timeline/life-event-sheet.tsx create mode 100644 src/components/timeline/readiness-card.tsx create mode 100644 src/components/timeline/readiness-inventory.tsx create mode 100644 src/components/timeline/readiness-model.ts create mode 100644 src/components/timeline/readiness-sheet.lazy.tsx create mode 100644 src/components/timeline/readiness-sheet.tsx create mode 100644 src/components/timeline/segmented.tsx create mode 100644 src/components/timeline/selection-bar.tsx create mode 100644 src/components/timeline/series-format.ts create mode 100644 src/components/timeline/timeline-chart.tsx create mode 100644 src/components/timeline/timeline-chronicle.tsx create mode 100644 src/components/timeline/timeline-dates.ts create mode 100644 src/components/timeline/timeline-geometry.ts create mode 100644 src/components/timeline/timeline-menus.tsx create mode 100644 src/components/timeline/timeline-view.tsx create mode 100644 src/components/timeline/use-device-preference.ts create mode 100644 src/components/timeline/use-timeline.ts diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 6f28780bb..2b0b6d675 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -229,6 +229,26 @@ export const E2E_AI_OPTIONAL = { role: "USER", } as const; +/** + * The timeline journey's own account (`timeline.spec.ts`). + * + * It switches the opt-in timeline module on and off, which changes the + * navigation, the add menus and Settings for the whole account; on the shared + * account a parallel spec would see them move underneath it. The module + * starts off here, the way every account starts. + */ +export const E2E_TIMELINE = { + email: "e2e-timeline@healthlog.test", + username: "e2e-timeline", + password: "Tl6!Wq3zRm8vXn2K", + role: "USER", +} as const; + +export const TIMELINE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateTimeline.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -906,6 +926,30 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The timeline journey's account. The module starts off; the journey + // switches it through the real Settings switch. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL`, + [ + cuid(), + E2E_TIMELINE.username, + E2E_TIMELINE.email, + await hashPassword(E2E_TIMELINE.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1509,6 +1553,9 @@ export default async function globalSetup(config: FullConfig): Promise { // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); + // The timeline journey's jar — see `E2E_TIMELINE`. + await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts new file mode 100644 index 000000000..14723d0b6 --- /dev/null +++ b/e2e/timeline.spec.ts @@ -0,0 +1,229 @@ +/** + * The timeline journey (v1.42, #613), on its own account (`E2E_TIMELINE`). + * + * 1. Off by default: no navigation entry, and a deep link to /timeline + * lands on the module notice, not the page. + * 2. Switching it on in Settings opens the readiness inventory; "Open + * timeline" goes there and the navigation entry appears. + * 3. The timeline hands off to the day: "Open 3 Jan." and Enter on the + * chart both put `?day=` in the URL; an incoming `?day=` selects that + * day. + * 4. A life event is entered through the sheet and posted in the + * contract's shape. + * 5. On a phone the chronicle replaces the chart, says its empty months + * out loud, and a row opens its day. + * + * The timeline's server routes are mocked with the contract's shapes + * (`utils/mock-timeline.ts`); the module switch is the real one. Every + * assertion is addressed to a stable `data-*` attribute. The journey moves + * its account's module switch, so it runs serially and in one project. + */ +import type { Page } from "@playwright/test"; + +import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { mockTimeline } from "./utils/mock-timeline"; + +test.use({ storageState: TIMELINE_STORAGE_STATE_PATH }); +test.describe.configure({ mode: "serial" }); + +/** Today in the zone the browser and the account run in. */ +const TODAY = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}).format(new Date()); + +async function setTimelineModule(page: Page, on: boolean): Promise { + const status = await page.evaluate(async (on) => { + const res = await fetch("/api/auth/me/modules", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ timeline: on }), + }); + return res.status; + }, on); + expect(status).toBe(200); +} + +const navEntry = '[data-tour-id="nav-timeline"]'; + +test("is off by default: no navigation entry, a deep link meets the notice", async ({ + page, +}) => { + await page.goto("/settings/modules"); + await setTimelineModule(page, false); + await page.goto("/timeline"); + await expect( + page.locator( + '[data-slot="module-disabled-notice"][data-module="timeline"]', + ), + ).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-page"]')).toHaveCount(0); + await expect(page.locator(navEntry)).toHaveCount(0); +}); + +test("switching it on opens the readiness inventory, which leads to the timeline", async ({ + page, +}) => { + await mockTimeline(page, "ready", TODAY); + await page.goto("/settings/modules"); + const toggle = page.locator("#module-toggle-timeline"); + await expect(toggle).toHaveAttribute("aria-checked", "false", { + timeout: 20_000, + }); + await toggle.click(); + + const sheet = page.locator('[data-slot="timeline-readiness-sheet"]'); + await expect(sheet).toBeVisible(); + await expect( + page.locator( + '[data-slot="timeline-readiness-verdict"][data-verdict="carries"]', + ), + ).toBeVisible(); + await expect( + page.locator('[data-slot="timeline-readiness-lane"][data-status="thin"]'), + ).toHaveCount(1); + await expect( + page.locator( + '[data-slot="timeline-readiness-gap"][data-gap="lifeEventsEmpty"]', + ), + ).toHaveAttribute("href", "/timeline?add=lifeEvent"); + + await page.locator('[data-slot="timeline-readiness-open"]').click(); + await expect(page).toHaveURL(/\/timeline$/); + await expect(page.locator('[data-slot="timeline-page"]')).toBeVisible(); + await expect(page.locator(navEntry).first()).toBeAttached(); + // The card carries the open gaps onto the page until it is closed. + await expect( + page.locator('[data-slot="timeline-readiness-card"]'), + ).toBeVisible(); + await page.locator('[data-slot="timeline-readiness-card-dismiss"]').click(); + await expect( + page.locator('[data-slot="timeline-readiness-card"]'), + ).toHaveCount(0); +}); + +test("the timeline hands the selected day to the day view through ?day=", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + + const chart = page.locator('[data-slot="timeline-chart"]'); + await expect(chart).toBeVisible({ timeout: 20_000 }); + // Empty lanes are not drawn: the record has no labs. + await expect(page.locator('svg [data-lane="labs"]')).toHaveCount(0); + await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1); + await expect( + page.locator('[data-slot="timeline-legend-causality"]'), + ).toBeVisible(); + // At most three value lines are asked for. + const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? []; + expect(asked.length).toBeGreaterThan(0); + expect(asked.length).toBeLessThanOrEqual(3); + + // The keyboard: one step back and Enter opens that day. + const before = await page + .locator('[data-slot="timeline-selection"]') + .getAttribute("data-date"); + await chart.focus(); + await page.keyboard.press("ArrowLeft"); + const after = await page + .locator('[data-slot="timeline-selection"]') + .getAttribute("data-date"); + expect(after).not.toBe(before); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`)); + + // "Open " in the selection bar does the same for the selected day. + await page.goto("/timeline?day=2026-01-03"); + const open = page.locator('[data-slot="timeline-open-day"]'); + await expect(open).toHaveAttribute("data-date", "2026-01-03", { + timeout: 20_000, + }); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true"); + await expect( + page.locator('[data-slot="timeline-selection-chip"][data-item="ill-5"]'), + ).toBeVisible(); + await page.goto("/timeline"); + await expect(chart).toBeVisible({ timeout: 20_000 }); + await page.locator('[data-slot="timeline-zoom"] [data-value="year"]').click(); + await open.click(); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); +}); + +async function setTimelineModuleOn(page: Page) { + await page.goto("/settings/modules"); + await setTimelineModule(page, true); +} + +test("a life event is entered through the sheet, in the contract's shape", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline?add=lifeEvent"); + const form = page.locator('[data-slot="life-event-form"]'); + await expect(form).toBeVisible({ timeout: 20_000 }); + + // Saving an empty form says what is missing and sends nothing. + await page.locator('[data-slot="life-event-save"]').click(); + await expect(form.locator('[aria-invalid="true"]').first()).toBeVisible(); + expect(log.lifeEventPosts).toHaveLength(0); + + await page + .locator('[data-slot="life-event-title"]') + .fill("Umzug nach Bochum"); + await page + .locator('[data-slot="life-event-category"] [data-value="HOME"]') + .click(); + await page + .locator('[data-slot="life-event-precision"] [data-value="MONTH"]') + .click(); + await expect(page.locator('[data-slot="life-event-privacy"]')).toBeVisible(); + await page.locator('[data-slot="life-event-save"]').click(); + + await expect(form).toHaveCount(0); + expect(log.lifeEventPosts).toHaveLength(1); + const posted = log.lifeEventPosts[0] as Record; + expect(posted).toMatchObject({ + title: "Umzug nach Bochum", + category: "HOME", + precision: "MONTH", + endDate: null, + note: null, + }); + expect(posted.startDate).toMatch(/^\d{4}-\d{2}-01$/); + // The parameter left the URL with the sheet. + await expect(page).toHaveURL(/\/timeline$/); +}); + +test("on a phone the chronicle replaces the chart and opens a day per row", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/timeline"); + + const chronicle = page.locator('[data-slot="timeline-chronicle"]'); + await expect(chronicle).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeHidden(); + await expect(page.locator('[data-slot="timeline-standing"]')).toBeVisible(); + await expect( + page.locator('[data-slot="timeline-chronicle-gap"]').first(), + ).toBeVisible(); + + await page + .locator( + '[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button', + ) + .first() + .click(); + await expect(page).toHaveURL(/[?&]day=2026-01-03/); +}); diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts new file mode 100644 index 000000000..0159c1a69 --- /dev/null +++ b/e2e/utils/mock-timeline.ts @@ -0,0 +1,702 @@ +import type { Page, Route } from "@playwright/test"; + +/** + * Route mocks for the timeline (v1.42, #613): `GET /api/timeline`, + * `GET /api/timeline/readiness` and `/api/life-events`, answered in the + * shapes of `src/lib/day/contract.ts` from an invented record. Two records: + * `full` (years of history in every lane) and `ready` (a thin record that + * starts in May 2024, a medication without a start date, no life events, no + * vaccinations), matching the two states the design was approved in. + * + * Every value here is made up. + */ + +type Item = { + id: string; + kind: string; + start: string; + end: string | null; + open: boolean; + precision: "DAY" | "MONTH" | "YEAR"; + startKnown: boolean; + label: string; + sub: string | null; + href: string | null; +}; + +function item(over: Partial & Pick): Item { + return { + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: over.id, + sub: null, + href: null, + ...over, + }; +} + +/** A small deterministic generator, so every run draws the same lines. */ +function rng(seed: number) { + let s = seed; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +function monthly( + from: string, + to: string, + value: (i: number, monthKey: string) => number, +) { + const out: Array<{ t: string; mean: number }> = []; + let [y, m] = from.split("-").map(Number); + const [ty, tm] = to.split("-").map(Number); + let i = 0; + while (y < ty || (y === ty && m <= tm)) { + const key = `${y}-${String(m).padStart(2, "0")}-01`; + out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 }); + i++; + m++; + if (m > 12) { + m = 1; + y++; + } + } + return out; +} + +function series(dataFrom: string, today: string) { + const r = rng(11); + const months = monthly(dataFrom, today, () => 0).length; + return [ + { + key: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + granularity: "month", + points: monthly( + dataFrom, + today, + (i) => 147 - (18 * i) / months + (r() - 0.5) * 4, + ), + }, + { + key: "BLOOD_PRESSURE_DIA", + unit: "mmHg", + granularity: "month", + points: monthly( + dataFrom, + today, + (i) => 92 - (10 * i) / months + (r() - 0.5) * 3, + ), + }, + { + key: "WEIGHT", + unit: "kg", + granularity: "month", + points: monthly( + dataFrom, + today, + (i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6, + ), + }, + { + key: "RESTING_HEART_RATE", + unit: "bpm", + granularity: "month", + points: monthly(dataFrom, today, (i, k) => { + const month = Number(k.slice(5, 7)); + return ( + 68 - + (5 * i) / months + + Math.sin((month / 12) * 6.28) * 1.2 + + (r() - 0.5) * 1.2 + ); + }), + }, + ]; +} + +export function fullTimeline(today: string) { + const vaccinations = [ + "2020-06-10", + "2021-05-20", + "2021-07-01", + "2021-12-10", + "2022-10-15", + "2023-10-20", + "2024-03-20", + "2024-04-25", + "2024-10-18", + "2025-10-10", + ]; + const visits = [ + "2019-03-10", + "2020-03-05", + "2021-09-14", + "2022-09-20", + "2023-09-12", + "2024-09-17", + "2025-09-18", + "2026-01-03", + "2026-06-22", + ]; + const documents = [ + "2019-03-12", + "2020-03-06", + "2021-09-15", + "2022-09-21", + "2022-11-09", + "2023-02-20", + "2023-09-13", + "2024-09-18", + "2025-03-14", + "2025-09-19", + "2026-01-03", + "2026-06-23", + ]; + return { + zoom: "all", + range: { + from: "2019-01-01", + to: `${today.slice(0, 4)}-12-31`, + dataFrom: "2019-01-01", + }, + lanes: [ + { + key: "life", + items: [ + item({ + id: "le-1", + kind: "lifeEvent", + start: "2021-05-14", + label: "Geburt Tochter", + }), + item({ + id: "le-2", + kind: "lifeEvent", + start: "2023-09-01", + precision: "MONTH", + label: "Umzug", + }), + item({ + id: "le-3", + kind: "lifeEvent", + start: "2024-10-01", + label: "Neue Stelle", + }), + ], + }, + { + key: "illness", + items: [ + item({ + id: "ill-c", + kind: "chronic", + start: "2019-03-10", + open: true, + label: "Bluthochdruck", + sub: "chronisch", + href: "/illness", + }), + item({ + id: "ill-1", + kind: "episode", + start: "2020-09-01", + end: "2020-10-20", + label: "Schulter", + }), + item({ + id: "ill-2", + kind: "episode", + start: "2022-03-08", + end: "2022-03-24", + label: "COVID-19", + }), + item({ + id: "ill-3", + kind: "episode", + start: "2022-11-02", + end: "2023-02-15", + label: "Bandscheibe L4/L5", + }), + item({ + id: "ill-4", + kind: "episode", + start: "2024-08-10", + end: "2024-08-16", + label: "Magen-Darm", + }), + item({ + id: "ill-5", + kind: "episode", + start: "2025-12-31", + end: "2026-01-08", + label: "Erkältung", + }), + ], + }, + { + key: "allergies", + items: [ + item({ + id: "al-1", + kind: "allergy", + start: "2018-01-01", + open: true, + label: "Gräserpollen", + sub: "seit 2008", + }), + item({ + id: "al-2", + kind: "allergy", + start: "2020-07-15", + open: true, + label: "Penicillin", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med-1", + kind: "medication", + start: "2019-04-02", + open: true, + label: "Ramipril", + sub: "2,5 mg", + }), + item({ + id: "dose-1", + kind: "doseChange", + start: "2020-08-12", + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "med-2", + kind: "medication", + start: "2022-11-03", + end: "2022-12-10", + label: "Ibuprofen", + }), + item({ + id: "c-1", + kind: "course", + start: "2023-11-01", + end: "2024-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-2", + kind: "course", + start: "2024-11-01", + end: "2025-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-3", + kind: "course", + start: "2025-11-01", + end: "2026-03-31", + label: "Vitamin D (Winter)", + }), + ], + }, + { + key: "vaccinations", + items: vaccinations.map((d, i) => + item({ + id: `vac-${i}`, + kind: "vaccination", + start: d, + label: "Impfung", + }), + ), + }, + { + key: "visits", + items: [ + ...visits.map((d, i) => + item({ + id: `v-${i}`, + kind: "visit", + start: d, + label: "Hausarztpraxis", + }), + ), + item({ + id: "p-1", + kind: "procedure", + start: "2022-11-08", + label: "MRT Lendenwirbelsäule", + }), + item({ + id: "p-2", + kind: "procedure", + start: "2025-03-12", + label: "Darmspiegelung", + }), + ], + }, + { + key: "documents", + items: documents.map((d, i) => + item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }), + ), + }, + ], + standing: [], + series: series("2019-01-01", today), + notable: [{ date: "2026-01-04", kind: "extremeHigh" }], + }; +} + +export function readyTimeline(today: string) { + return { + zoom: "all", + range: { + from: "2024-01-01", + to: `${today.slice(0, 4)}-12-31`, + dataFrom: "2024-05-01", + }, + lanes: [ + { + key: "illness", + items: [ + item({ + id: "ill-c", + kind: "chronic", + start: "2019-03-10", + open: true, + label: "Bluthochdruck", + sub: "chronisch", + }), + item({ + id: "ill-4", + kind: "episode", + start: "2024-08-10", + end: "2024-08-16", + label: "Magen-Darm", + }), + item({ + id: "ill-5", + kind: "episode", + start: "2025-12-31", + end: "2026-01-08", + label: "Erkältung", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med-1", + kind: "medication", + start: "2024-05-01", + open: true, + startKnown: false, + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "c-2", + kind: "course", + start: "2024-11-01", + end: "2025-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-3", + kind: "course", + start: "2025-11-01", + end: "2026-03-31", + label: "Vitamin D (Winter)", + }), + ], + }, + { + key: "visits", + items: [ + item({ + id: "v-1", + kind: "visit", + start: "2024-09-17", + label: "Hausarztpraxis", + }), + item({ + id: "p-2", + kind: "procedure", + start: "2025-03-12", + label: "Darmspiegelung", + }), + item({ + id: "v-2", + kind: "visit", + start: "2025-09-18", + label: "Hausarztpraxis", + }), + item({ + id: "v-3", + kind: "visit", + start: "2026-01-03", + label: "Hausarztpraxis", + }), + item({ + id: "v-4", + kind: "visit", + start: "2026-06-22", + label: "Hausarztpraxis", + }), + ], + }, + { + key: "documents", + items: [ + "2024-09-18", + "2025-03-14", + "2025-09-19", + "2026-01-03", + "2026-06-23", + ].map((d, i) => + item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }), + ), + }, + ], + standing: [], + series: series("2024-05-01", today).filter( + (s) => s.key !== "RESTING_HEART_RATE", + ), + notable: [], + }; +} + +export function fullReadiness() { + return { + verdict: "carries", + since: "2019-03-01", + lanes: [ + { + key: "values", + status: "carries", + count: 3, + detail: { key: "values", params: { count: 3, since: "2019-03-01" } }, + gaps: [], + }, + { + key: "illness", + status: "carries", + count: 6, + detail: { key: "illness", params: { count: 6, chronic: 1 } }, + gaps: [], + }, + { + key: "visits", + status: "carries", + count: 11, + detail: { key: "visits", params: { count: 11, procedures: 2 } }, + gaps: [], + }, + { + key: "documents", + status: "carries", + count: 14, + detail: { key: "documents", params: { count: 14 } }, + gaps: [], + }, + { + key: "labs", + status: "carries", + count: 5, + detail: { key: "labs", params: { count: 5 } }, + gaps: [], + }, + { + key: "medications", + status: "thin", + count: 5, + detail: { key: "medications", params: { count: 5, missingStart: 3 } }, + gaps: [ + { key: "medicationsWithoutStart", count: 3, href: "/medications" }, + ], + }, + { + key: "vaccinations", + status: "empty", + count: 0, + detail: { key: "empty", params: {} }, + gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }], + }, + { + key: "life", + status: "empty", + count: 0, + detail: null, + gaps: [ + { key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }, + ], + }; +} + +export function readyReadiness() { + return { + verdict: "carries", + since: "2024-05-01", + lanes: [ + { + key: "values", + status: "carries", + count: 2, + detail: { key: "values", params: { count: 2, since: "2024-05-01" } }, + gaps: [], + }, + { + key: "illness", + status: "carries", + count: 3, + detail: { key: "illness", params: { count: 3, chronic: 1 } }, + gaps: [], + }, + { + key: "visits", + status: "carries", + count: 5, + detail: { key: "visits", params: { count: 5, procedures: 1 } }, + gaps: [], + }, + { + key: "documents", + status: "carries", + count: 5, + detail: { key: "documents", params: { count: 5 } }, + gaps: [], + }, + { + key: "medications", + status: "thin", + count: 3, + detail: { key: "medications", params: { count: 3, missingStart: 3 } }, + gaps: [ + { key: "medicationsWithoutStart", count: 3, href: "/medications" }, + ], + }, + { + key: "life", + status: "empty", + count: 0, + detail: null, + gaps: [ + { key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }, + { + key: "vaccinations", + status: "empty", + count: 0, + detail: { key: "empty", params: {} }, + gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }], + }, + ], + }; +} + +const LIFE_EVENTS = [ + { + id: "le-1", + category: "FAMILY", + startDate: "2021-05-14", + endDate: null, + precision: "DAY", + title: "Geburt Tochter", + note: null, + }, + { + id: "le-2", + category: "HOME", + startDate: "2023-09-01", + endDate: null, + precision: "MONTH", + title: "Umzug", + note: null, + }, + { + id: "le-3", + category: "WORK", + startDate: "2024-10-01", + endDate: null, + precision: "DAY", + title: "Neue Stelle", + note: null, + }, +].map((e) => ({ + ...e, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +})); + +function json(route: Route, status: number, data: unknown) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +export interface TimelineMockLog { + timelineQueries: URLSearchParams[]; + lifeEventPosts: unknown[]; +} + +/** + * Answer the timeline routes from the `full` or `ready` record. Returns a + * log of what the page asked for and posted, for assertions. + */ +export async function mockTimeline( + page: Page, + state: "full" | "ready", + today: string, +): Promise { + const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; + await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => + json(route, 200, state === "full" ? fullReadiness() : readyReadiness()), + ); + await page.route(/\/api\/timeline(\?|$)/, (route) => { + const url = new URL(route.request().url()); + log.timelineQueries.push(url.searchParams); + const body = state === "full" ? fullTimeline(today) : readyTimeline(today); + const values = (url.searchParams.get("values") ?? "") + .split(",") + .filter(Boolean); + return json(route, 200, { + ...body, + zoom: url.searchParams.get("zoom") ?? "all", + series: body.series.filter((s) => values.includes(s.key)), + }); + }); + await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => { + const method = route.request().method(); + if (method === "GET") { + return json(route, 200, { events: state === "full" ? LIFE_EVENTS : [] }); + } + if (method === "POST") { + const posted = route.request().postDataJSON() as Record; + log.lifeEventPosts.push(posted); + return json(route, 201, { + id: "le-new", + endDate: null, + note: null, + ...posted, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + }); + } + return json(route, 200, { deleted: true }); + }); + return log; +} diff --git a/messages/de.json b/messages/de.json index 5e38ba61e..d583011af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -427,7 +427,9 @@ "workout": "Workout", "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat", "symptom": "Symptom", - "symptomDescription": "Eines deiner eigenen Symptome, mit Stärke" + "symptomDescription": "Eines deiner eigenen Symptome, mit Stärke", + "lifeEventDescription": "Umzug, Geburt, neue Stelle", + "lifeEvent": "Lebensereignis" }, "cycle": "Zyklus", "labs": "Laborwerte", @@ -578,6 +580,7 @@ "quickAddMood": "Stimmung erfassen", "quickAddMedicationIntake": "Einnahme erfassen", "quickAddWorkout": "Workout erfassen", + "quickAddLifeEvent": "Lebensereignis erfassen", "customizeDashboard": "Dashboard anpassen", "medicationIntakeQuickAdd": { "sheetTitle": "Medikamenteneinnahme erfassen", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Zeitleiste", - "subtitle": "Jahre auf einen Blick.", - "legend": "Nebeneinander gezeigt, nicht als Ursache und Wirkung.", + "subtitle": "Was wann war, neben deinen Werten.", + "legend": "Untereinander heißt gleichzeitig, nicht ursächlich.", "standing": "Dauerhaft", "startMissing": "Startdatum fehlt", "openDay": "{date} öffnen", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Werte", "mood": "Stimmung", - "environment": "Umwelt" + "environment": "Umwelt", + "life": "Lebensereignisse" + }, + "meter": "{carrying} von {total} Spuren tragen", + "carriesDetail": "{carrying} von {total} Spuren reichen für einen Überblick ab {since}.", + "loadFailed": "Die Bestandsaufnahme konnte nicht geladen werden.", + "card": { + "title": "Was die Zeitleiste schon trägt", + "summary": "Sie reicht bis {since} zurück, {carrying} von {total} Spuren tragen schon.", + "dismiss": "Ausblenden" + }, + "detail": { + "valuesOne": "{count} Messgröße seit {since}", + "valuesFew": "{count} Messgrößen seit {since}", + "valuesOther": "{count} Messgrößen seit {since}", + "illnessOne": "{count} Episode, davon {chronic} chronisch", + "illnessFew": "{count} Episoden, davon {chronic} chronisch", + "illnessOther": "{count} Episoden, davon {chronic} chronisch", + "allergiesOne": "{count} Allergie", + "allergiesFew": "{count} Allergien", + "allergiesOther": "{count} Allergien", + "medicationsOne": "{count} Medikament, {missingStart} ohne Startdatum", + "medicationsFew": "{count} Medikamente, {missingStart} ohne Startdatum", + "medicationsOther": "{count} Medikamente, {missingStart} ohne Startdatum", + "medicationsDatedOne": "{count} Medikament mit Startdatum", + "medicationsDatedFew": "{count} Medikamente mit Startdatum", + "medicationsDatedOther": "{count} Medikamente mit Startdatum", + "vaccinationsOne": "{count} Impfung", + "vaccinationsFew": "{count} Impfungen", + "vaccinationsOther": "{count} Impfungen", + "visitsOne": "{count} Besuch · {procedures} mit Eingriff", + "visitsFew": "{count} Besuche · {procedures} mit Eingriff", + "visitsOther": "{count} Besuche · {procedures} mit Eingriff", + "visitsPlannedOne": "{count} Besuch, nur geplant", + "visitsPlannedFew": "{count} Besuche, nur geplant", + "visitsPlannedOther": "{count} Besuche, nur geplant", + "labsOne": "{count} Befundtag", + "labsFew": "{count} Befundtage", + "labsOther": "{count} Befundtage", + "documentsOne": "{count} Dokument mit Datum", + "documentsFew": "{count} Dokumente mit Datum", + "documentsOther": "{count} Dokumente mit Datum", + "lifeOne": "{count} Lebensereignis", + "lifeFew": "{count} Lebensereignisse", + "lifeOther": "{count} Lebensereignisse", + "cycleOne": "{count} Zyklus", + "cycleFew": "{count} Zyklen", + "cycleOther": "{count} Zyklen", + "moodOne": "{count} Eintrag seit {since}", + "moodFew": "{count} Einträge seit {since}", + "moodOther": "{count} Einträge seit {since}", + "environmentOne": "{count} Tag mit Umweltdaten", + "environmentFew": "{count} Tage mit Umweltdaten", + "environmentOther": "{count} Tage mit Umweltdaten", + "empty": "Noch keine" + }, + "gap": { + "valuesEmpty": "Quellen verbinden", + "illnessEmpty": "Episode anlegen", + "medicationsWithoutStart": "Ergänzen", + "visitsEmpty": "Besuch nachtragen", + "documentsUndated": "Ansehen", + "labsEmpty": "Befund erfassen", + "vaccinationsEmpty": "Impfpass erfassen", + "lifeEventsEmpty": "Hinzufügen" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} Medikament ohne Startdatum", + "medicationsWithoutStartFew": "{count} Medikamente ohne Startdatum", + "medicationsWithoutStartOther": "{count} Medikamente ohne Startdatum", + "documentsUndatedOne": "{count} Dokument ohne Datum", + "documentsUndatedFew": "{count} Dokumente ohne Datum", + "documentsUndatedOther": "{count} Dokumente ohne Datum", + "valuesEmpty": "Noch keine Werte über längere Zeit", + "illnessEmpty": "Noch keine Erkrankungen mit Beginn", + "visitsEmpty": "Noch keine erledigten Arztbesuche", + "labsEmpty": "Noch keine Laborbefunde", + "vaccinationsEmpty": "Noch keine Impfungen", + "lifeEventsEmpty": "Noch keine Lebensereignisse" } - } + }, + "legendSpan": "Zeitraum", + "legendEvent": "Ereignis", + "legendMean": "Monatsmittel", + "today": "Heute", + "noDataYet": "Noch keine Daten", + "layers": "Ebenen", + "layersLanes": "Spuren", + "zoomLabel": "Zeitraum der Ansicht", + "chartLabel": "Zeitleiste von {from} bis {to}", + "keyboardHint": "Pfeiltasten verschieben die Auswahl, Pos1 und Ende springen an die Enden, Eingabe öffnet den Tag.", + "loadFailed": "Die Zeitleiste konnte nicht geladen werden.", + "empty": { + "title": "Noch nichts auf der Zeitleiste", + "description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier." + }, + "values": { + "menuLabel": "Bis zu drei Werte", + "none": "Keine Werte", + "noneInWindow": "Keine Werte in diesem Zeitraum", + "series": { + "BLOOD_PRESSURE_SYS": "Blutdruck sys.", + "BLOOD_PRESSURE_DIA": "Blutdruck dia." + } + }, + "table": { + "caption": "Einträge der Zeitleiste", + "lane": "Spur", + "entry": "Eintrag", + "start": "Beginn", + "end": "Ende" + }, + "selection": { + "empty": "Keine Einträge in diesem Monat", + "ongoing": "laufend", + "range": "{from} bis {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Monate", + "years": "Jahre", + "groupingLabel": "Gruppierung", + "gap": "{from} bis {to} · keine Einträge", + "gapSingle": "{period} · keine Einträge", + "ended": "{label} vorbei", + "afterDaysOne": "nach einem Tag", + "afterDaysFew": "nach {count} Tagen", + "afterDaysOther": "nach {count} Tagen", + "notable": { + "extremeHigh": "Höchster Wert seit Langem", + "extremeLow": "Niedrigster Wert seit Langem", + "firstValue": "Erster Wert einer Messgröße", + "gap": "Längere Lücke in den Werten" + }, + "notableMeta": "Tag öffnen für Einzelheiten" + }, + "standingAllergiesOne": "{count} Allergie", + "standingAllergiesFew": "{count} Allergien", + "standingAllergiesOther": "{count} Allergien" }, "lifeEvents": { "title": "Lebensereignis", "add": "Lebensereignis", "subtitle": "Ein Anker für die Zeitleiste, kein Messwert.", "privacy": "Titel und Notiz liegen verschlüsselt. Sie gehen an keine KI.", - "empty": "Noch keine Lebensereignisse", "emptyHint": "Umzug, Geburt oder eine neue Stelle geben den Jahren einen Anker.", "save": "Speichern", "cancel": "Abbrechen", @@ -12279,7 +12417,8 @@ "endDate": "Bis", "note": "Notiz", "optionalTag": "optional", - "notePlaceholder": "Etwas, das du später wissen willst" + "notePlaceholder": "Etwas, das du später wissen willst", + "removeEnd": "Enddatum entfernen" }, "category": { "FAMILY": "Familie", @@ -12292,6 +12431,21 @@ "DAY": "Tag", "MONTH": "Monat", "YEAR": "Jahr" + }, + "edit": "Lebensereignis bearbeiten", + "errors": { + "titleRequired": "Gib dem Ereignis einen Namen.", + "titleTooLong": "Höchstens 120 Zeichen.", + "categoryRequired": "Wähle eine Art.", + "dateRequired": "Gib ein gültiges Datum an.", + "endBeforeStart": "Das Ende liegt vor dem Beginn.", + "noteTooLong": "Höchstens 2000 Zeichen." + }, + "saveFailed": "Speichern hat nicht geklappt.", + "deleteFailed": "Löschen hat nicht geklappt.", + "deleteConfirm": { + "title": "Lebensereignis löschen?", + "body": "Titel und Notiz sind danach nicht mehr da." } } } diff --git a/messages/en.json b/messages/en.json index d5c9e4027..a383846ec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -427,7 +427,9 @@ "workout": "Workout", "workoutDescription": "A session your devices did not record", "symptom": "Symptom", - "symptomDescription": "One of your own symptoms, with how strong" + "symptomDescription": "One of your own symptoms, with how strong", + "lifeEventDescription": "A move, a birth, a new job", + "lifeEvent": "Life event" }, "cycle": "Cycle", "labs": "Labs", @@ -578,6 +580,7 @@ "quickAddMood": "Log mood", "quickAddMedicationIntake": "Log medication intake", "quickAddWorkout": "Log workout", + "quickAddLifeEvent": "Log life event", "customizeDashboard": "Customize dashboard", "medicationIntakeQuickAdd": { "sheetTitle": "Log medication intake", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Timeline", - "subtitle": "Years at a glance.", - "legend": "Shown side by side, not as cause and effect.", + "subtitle": "What happened when, beside your values.", + "legend": "Stacked means at the same time, not cause and effect.", "standing": "Ongoing", "startMissing": "Start date missing", "openDay": "Open {date}", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Values", "mood": "Mood", - "environment": "Environment" + "environment": "Environment", + "life": "Life events" + }, + "meter": "{carrying} of {total} lanes carry", + "carriesDetail": "{carrying} of {total} lanes give an overview from {since}.", + "loadFailed": "The inventory could not be loaded.", + "card": { + "title": "What the timeline already carries", + "summary": "It reaches back to {since}; {carrying} of {total} lanes already carry.", + "dismiss": "Hide" + }, + "detail": { + "valuesOne": "{count} measure since {since}", + "valuesFew": "{count} measures since {since}", + "valuesOther": "{count} measures since {since}", + "illnessOne": "{count} episode, {chronic} of them chronic", + "illnessFew": "{count} episodes, {chronic} of them chronic", + "illnessOther": "{count} episodes, {chronic} of them chronic", + "allergiesOne": "{count} allergy", + "allergiesFew": "{count} allergies", + "allergiesOther": "{count} allergies", + "medicationsOne": "{count} medication, {missingStart} without a start date", + "medicationsFew": "{count} medications, {missingStart} without a start date", + "medicationsOther": "{count} medications, {missingStart} without a start date", + "medicationsDatedOne": "{count} medication with a start date", + "medicationsDatedFew": "{count} medications with a start date", + "medicationsDatedOther": "{count} medications with a start date", + "vaccinationsOne": "{count} vaccination", + "vaccinationsFew": "{count} vaccinations", + "vaccinationsOther": "{count} vaccinations", + "visitsOne": "{count} visit · {procedures} with a procedure", + "visitsFew": "{count} visits · {procedures} with a procedure", + "visitsOther": "{count} visits · {procedures} with a procedure", + "visitsPlannedOne": "{count} visit, planned only", + "visitsPlannedFew": "{count} visits, planned only", + "visitsPlannedOther": "{count} visits, planned only", + "labsOne": "{count} day with results", + "labsFew": "{count} days with results", + "labsOther": "{count} days with results", + "documentsOne": "{count} dated document", + "documentsFew": "{count} dated documents", + "documentsOther": "{count} dated documents", + "lifeOne": "{count} life event", + "lifeFew": "{count} life events", + "lifeOther": "{count} life events", + "cycleOne": "{count} cycle", + "cycleFew": "{count} cycles", + "cycleOther": "{count} cycles", + "moodOne": "{count} entry since {since}", + "moodFew": "{count} entries since {since}", + "moodOther": "{count} entries since {since}", + "environmentOne": "{count} day with environment data", + "environmentFew": "{count} days with environment data", + "environmentOther": "{count} days with environment data", + "empty": "None yet" + }, + "gap": { + "valuesEmpty": "Connect sources", + "illnessEmpty": "Add an episode", + "medicationsWithoutStart": "Complete", + "visitsEmpty": "Add a visit", + "documentsUndated": "Review", + "labsEmpty": "Add a result", + "vaccinationsEmpty": "Add vaccinations", + "lifeEventsEmpty": "Add" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} medication without a start date", + "medicationsWithoutStartFew": "{count} medications without a start date", + "medicationsWithoutStartOther": "{count} medications without a start date", + "documentsUndatedOne": "{count} document without a date", + "documentsUndatedFew": "{count} documents without a date", + "documentsUndatedOther": "{count} documents without a date", + "valuesEmpty": "No values over a longer period yet", + "illnessEmpty": "No conditions with a start date yet", + "visitsEmpty": "No completed doctor visits yet", + "labsEmpty": "No lab results yet", + "vaccinationsEmpty": "No vaccinations yet", + "lifeEventsEmpty": "No life events yet" } - } + }, + "legendSpan": "Period", + "legendEvent": "Event", + "legendMean": "Monthly mean", + "today": "Today", + "noDataYet": "No data yet", + "layers": "Layers", + "layersLanes": "Lanes", + "zoomLabel": "View range", + "chartLabel": "Timeline from {from} to {to}", + "keyboardHint": "Arrow keys move the selection, Home and End jump to either end, Enter opens the day.", + "loadFailed": "The timeline could not be loaded.", + "empty": { + "title": "Nothing on the timeline yet", + "description": "Dated entries appear here as soon as there are some." + }, + "values": { + "menuLabel": "Up to three values", + "none": "No values", + "noneInWindow": "No values in this range", + "series": { + "BLOOD_PRESSURE_SYS": "Blood pressure sys.", + "BLOOD_PRESSURE_DIA": "Blood pressure dia." + } + }, + "table": { + "caption": "Timeline entries", + "lane": "Lane", + "entry": "Entry", + "start": "Start", + "end": "End" + }, + "selection": { + "empty": "No entries this month", + "ongoing": "ongoing", + "range": "{from} to {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Months", + "years": "Years", + "groupingLabel": "Grouping", + "gap": "{from} to {to} · no entries", + "gapSingle": "{period} · no entries", + "ended": "{label} ended", + "afterDaysOne": "after 1 day", + "afterDaysFew": "after {count} days", + "afterDaysOther": "after {count} days", + "notable": { + "extremeHigh": "Highest value in a long while", + "extremeLow": "Lowest value in a long while", + "firstValue": "First value of a measure", + "gap": "A longer gap in the values" + }, + "notableMeta": "Open the day for details" + }, + "standingAllergiesOne": "{count} allergy", + "standingAllergiesFew": "{count} allergies", + "standingAllergiesOther": "{count} allergies" }, "lifeEvents": { "title": "Life event", "add": "Life event", "subtitle": "An anchor for the timeline, not a reading.", "privacy": "Title and note are stored encrypted. They go to no AI.", - "empty": "No life events yet", "emptyHint": "A move, a birth or a new job gives the years an anchor.", "save": "Save", "cancel": "Cancel", @@ -12279,7 +12417,8 @@ "endDate": "Until", "note": "Note", "optionalTag": "optional", - "notePlaceholder": "Something you want to know later" + "notePlaceholder": "Something you want to know later", + "removeEnd": "Remove end date" }, "category": { "FAMILY": "Family", @@ -12292,6 +12431,21 @@ "DAY": "Day", "MONTH": "Month", "YEAR": "Year" + }, + "edit": "Edit life event", + "errors": { + "titleRequired": "Give the event a name.", + "titleTooLong": "At most 120 characters.", + "categoryRequired": "Choose a kind.", + "dateRequired": "Enter a valid date.", + "endBeforeStart": "The end is before the start.", + "noteTooLong": "At most 2000 characters." + }, + "saveFailed": "Saving did not work.", + "deleteFailed": "Deleting did not work.", + "deleteConfirm": { + "title": "Delete this life event?", + "body": "Its title and note will be gone." } } } diff --git a/messages/es.json b/messages/es.json index 95594a427..8db5a98e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -427,7 +427,9 @@ "workout": "Entrenamiento", "workoutDescription": "Una sesión que tus dispositivos no registraron", "symptom": "Síntoma", - "symptomDescription": "Uno de tus propios síntomas, con su intensidad" + "symptomDescription": "Uno de tus propios síntomas, con su intensidad", + "lifeEventDescription": "Una mudanza, un nacimiento, un nuevo trabajo", + "lifeEvent": "Acontecimiento vital" }, "cycle": "Ciclo", "labs": "Análisis", @@ -578,6 +580,7 @@ "quickAddMood": "Registrar estado de ánimo", "quickAddMedicationIntake": "Registrar toma", "quickAddWorkout": "Registrar entrenamiento", + "quickAddLifeEvent": "Registrar acontecimiento vital", "customizeDashboard": "Personalizar panel", "medicationIntakeQuickAdd": { "sheetTitle": "Registrar toma de medicación", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Cronología", - "subtitle": "Años de un vistazo.", - "legend": "Mostrado uno junto a otro, no como causa y efecto.", + "subtitle": "Qué pasó y cuándo, junto a tus valores.", + "legend": "Uno debajo de otro significa al mismo tiempo, no causa y efecto.", "standing": "Permanente", "startMissing": "Falta la fecha de inicio", "openDay": "Abrir {date}", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Valores", "mood": "Estado de ánimo", - "environment": "Entorno" + "environment": "Entorno", + "life": "Acontecimientos vitales" + }, + "meter": "{carrying} de {total} pistas tienen contenido", + "carriesDetail": "{carrying} de {total} pistas dan una visión general desde {since}.", + "loadFailed": "No se pudo cargar el inventario.", + "card": { + "title": "Lo que la cronología ya muestra", + "summary": "Llega hasta {since}; {carrying} de {total} pistas ya tienen contenido.", + "dismiss": "Ocultar" + }, + "detail": { + "valuesOne": "{count} medida desde {since}", + "valuesFew": "{count} medidas desde {since}", + "valuesOther": "{count} medidas desde {since}", + "illnessOne": "{count} episodio; crónicos: {chronic}", + "illnessFew": "{count} episodios; crónicos: {chronic}", + "illnessOther": "{count} episodios; crónicos: {chronic}", + "allergiesOne": "{count} alergia", + "allergiesFew": "{count} alergias", + "allergiesOther": "{count} alergias", + "medicationsOne": "{count} medicamento; sin fecha de inicio: {missingStart}", + "medicationsFew": "{count} medicamentos; sin fecha de inicio: {missingStart}", + "medicationsOther": "{count} medicamentos; sin fecha de inicio: {missingStart}", + "medicationsDatedOne": "{count} medicamento con fecha de inicio", + "medicationsDatedFew": "{count} medicamentos con fecha de inicio", + "medicationsDatedOther": "{count} medicamentos con fecha de inicio", + "vaccinationsOne": "{count} vacuna", + "vaccinationsFew": "{count} vacunas", + "vaccinationsOther": "{count} vacunas", + "visitsOne": "{count} visita · con intervención: {procedures}", + "visitsFew": "{count} visitas · con intervención: {procedures}", + "visitsOther": "{count} visitas · con intervención: {procedures}", + "visitsPlannedOne": "{count} visita, solo planificada", + "visitsPlannedFew": "{count} visitas, solo planificadas", + "visitsPlannedOther": "{count} visitas, solo planificadas", + "labsOne": "{count} día con resultados", + "labsFew": "{count} días con resultados", + "labsOther": "{count} días con resultados", + "documentsOne": "{count} documento con fecha", + "documentsFew": "{count} documentos con fecha", + "documentsOther": "{count} documentos con fecha", + "lifeOne": "{count} acontecimiento vital", + "lifeFew": "{count} acontecimientos vitales", + "lifeOther": "{count} acontecimientos vitales", + "cycleOne": "{count} ciclo", + "cycleFew": "{count} ciclos", + "cycleOther": "{count} ciclos", + "moodOne": "{count} entrada desde {since}", + "moodFew": "{count} entradas desde {since}", + "moodOther": "{count} entradas desde {since}", + "environmentOne": "{count} día con datos ambientales", + "environmentFew": "{count} días con datos ambientales", + "environmentOther": "{count} días con datos ambientales", + "empty": "Aún ninguno" + }, + "gap": { + "valuesEmpty": "Conectar fuentes", + "illnessEmpty": "Añadir episodio", + "medicationsWithoutStart": "Completar", + "visitsEmpty": "Añadir visita", + "documentsUndated": "Revisar", + "labsEmpty": "Añadir resultado", + "vaccinationsEmpty": "Añadir vacunas", + "lifeEventsEmpty": "Añadir" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} medicamento sin fecha de inicio", + "medicationsWithoutStartFew": "{count} medicamentos sin fecha de inicio", + "medicationsWithoutStartOther": "{count} medicamentos sin fecha de inicio", + "documentsUndatedOne": "{count} documento sin fecha", + "documentsUndatedFew": "{count} documentos sin fecha", + "documentsUndatedOther": "{count} documentos sin fecha", + "valuesEmpty": "Aún no hay valores durante un periodo largo", + "illnessEmpty": "Aún no hay enfermedades con fecha de inicio", + "visitsEmpty": "Aún no hay visitas médicas realizadas", + "labsEmpty": "Aún no hay resultados de laboratorio", + "vaccinationsEmpty": "Aún no hay vacunas", + "lifeEventsEmpty": "Aún no hay acontecimientos vitales" } - } + }, + "legendSpan": "Periodo", + "legendEvent": "Evento", + "legendMean": "Media mensual", + "today": "Hoy", + "noDataYet": "Aún sin datos", + "layers": "Capas", + "layersLanes": "Pistas", + "zoomLabel": "Rango de la vista", + "chartLabel": "Cronología de {from} a {to}", + "keyboardHint": "Las flechas mueven la selección, Inicio y Fin saltan a los extremos, Intro abre el día.", + "loadFailed": "No se pudo cargar la cronología.", + "empty": { + "title": "Aún no hay nada en la cronología", + "description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna." + }, + "values": { + "menuLabel": "Hasta tres valores", + "none": "Sin valores", + "noneInWindow": "Sin valores en este periodo", + "series": { + "BLOOD_PRESSURE_SYS": "Presión sistólica", + "BLOOD_PRESSURE_DIA": "Presión diastólica" + } + }, + "table": { + "caption": "Entradas de la cronología", + "lane": "Pista", + "entry": "Entrada", + "start": "Inicio", + "end": "Fin" + }, + "selection": { + "empty": "Sin entradas este mes", + "ongoing": "en curso", + "range": "{from} a {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Meses", + "years": "Años", + "groupingLabel": "Agrupación", + "gap": "{from} a {to} · sin entradas", + "gapSingle": "{period} · sin entradas", + "ended": "Fin: {label}", + "afterDaysOne": "después de 1 día", + "afterDaysFew": "después de {count} días", + "afterDaysOther": "después de {count} días", + "notable": { + "extremeHigh": "El valor más alto en mucho tiempo", + "extremeLow": "El valor más bajo en mucho tiempo", + "firstValue": "Primer valor de una medida", + "gap": "Un hueco largo en los valores" + }, + "notableMeta": "Abre el día para ver detalles" + }, + "standingAllergiesOne": "{count} alergia", + "standingAllergiesFew": "{count} alergias", + "standingAllergiesOther": "{count} alergias" }, "lifeEvents": { "title": "Acontecimiento vital", "add": "Acontecimiento vital", "subtitle": "Un ancla para la cronología, no una medición.", "privacy": "El título y la nota se guardan cifrados. No se envían a ninguna IA.", - "empty": "Aún no hay acontecimientos vitales", "emptyHint": "Una mudanza, un nacimiento o un nuevo trabajo dan un ancla a los años.", "save": "Guardar", "cancel": "Cancelar", @@ -12279,7 +12417,8 @@ "endDate": "Hasta", "note": "Nota", "optionalTag": "opcional", - "notePlaceholder": "Algo que quieras saber más adelante" + "notePlaceholder": "Algo que quieras saber más adelante", + "removeEnd": "Quitar fecha de fin" }, "category": { "FAMILY": "Familia", @@ -12292,6 +12431,21 @@ "DAY": "Día", "MONTH": "Mes", "YEAR": "Año" + }, + "edit": "Editar acontecimiento vital", + "errors": { + "titleRequired": "Ponle un nombre al acontecimiento.", + "titleTooLong": "Máximo 120 caracteres.", + "categoryRequired": "Elige un tipo.", + "dateRequired": "Introduce una fecha válida.", + "endBeforeStart": "El final es anterior al inicio.", + "noteTooLong": "Máximo 2000 caracteres." + }, + "saveFailed": "No se pudo guardar.", + "deleteFailed": "No se pudo eliminar.", + "deleteConfirm": { + "title": "¿Eliminar este acontecimiento vital?", + "body": "Su título y su nota desaparecerán." } } } diff --git a/messages/fr.json b/messages/fr.json index 9a25437e0..1a3f71bd1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -427,7 +427,9 @@ "workout": "Entraînement", "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée", "symptom": "Symptôme", - "symptomDescription": "L'un de tes propres symptômes, avec son intensité" + "symptomDescription": "L'un de tes propres symptômes, avec son intensité", + "lifeEventDescription": "Un déménagement, une naissance, un nouvel emploi", + "lifeEvent": "Événement de vie" }, "cycle": "Cycle", "labs": "Analyses", @@ -578,6 +580,7 @@ "quickAddMood": "Saisir une humeur", "quickAddMedicationIntake": "Saisir une prise", "quickAddWorkout": "Saisir un entraînement", + "quickAddLifeEvent": "Enregistrer un événement de vie", "customizeDashboard": "Personnaliser le tableau de bord", "medicationIntakeQuickAdd": { "sheetTitle": "Saisir une prise de médicament", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Chronologie", - "subtitle": "Des années d’un coup d’œil.", - "legend": "Présentés côte à côte, pas comme cause et effet.", + "subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.", + "legend": "Superposé signifie simultané, pas cause et effet.", "standing": "Permanent", "startMissing": "Date de début manquante", "openDay": "Ouvrir le {date}", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Valeurs", "mood": "Humeur", - "environment": "Environnement" + "environment": "Environnement", + "life": "Événements de vie" + }, + "meter": "{carrying} pistes sur {total} sont remplies", + "carriesDetail": "{carrying} pistes sur {total} donnent une vue d’ensemble depuis {since}.", + "loadFailed": "L’inventaire n’a pas pu être chargé.", + "card": { + "title": "Ce que la chronologie contient déjà", + "summary": "Elle remonte à {since} ; {carrying} pistes sur {total} sont déjà remplies.", + "dismiss": "Masquer" + }, + "detail": { + "valuesOne": "{count} mesure depuis {since}", + "valuesFew": "{count} mesures depuis {since}", + "valuesOther": "{count} mesures depuis {since}", + "illnessOne": "{count} épisode ; chroniques : {chronic}", + "illnessFew": "{count} épisodes ; chroniques : {chronic}", + "illnessOther": "{count} épisodes ; chroniques : {chronic}", + "allergiesOne": "{count} allergie", + "allergiesFew": "{count} allergies", + "allergiesOther": "{count} allergies", + "medicationsOne": "{count} médicament ; sans date de début : {missingStart}", + "medicationsFew": "{count} médicaments ; sans date de début : {missingStart}", + "medicationsOther": "{count} médicaments ; sans date de début : {missingStart}", + "medicationsDatedOne": "{count} médicament avec date de début", + "medicationsDatedFew": "{count} médicaments avec date de début", + "medicationsDatedOther": "{count} médicaments avec date de début", + "vaccinationsOne": "{count} vaccin", + "vaccinationsFew": "{count} vaccins", + "vaccinationsOther": "{count} vaccins", + "visitsOne": "{count} consultation · avec intervention : {procedures}", + "visitsFew": "{count} consultations · avec intervention : {procedures}", + "visitsOther": "{count} consultations · avec intervention : {procedures}", + "visitsPlannedOne": "{count} consultation, seulement prévue", + "visitsPlannedFew": "{count} consultations, seulement prévues", + "visitsPlannedOther": "{count} consultations, seulement prévues", + "labsOne": "{count} jour avec résultats", + "labsFew": "{count} jours avec résultats", + "labsOther": "{count} jours avec résultats", + "documentsOne": "{count} document daté", + "documentsFew": "{count} documents datés", + "documentsOther": "{count} documents datés", + "lifeOne": "{count} événement de vie", + "lifeFew": "{count} événements de vie", + "lifeOther": "{count} événements de vie", + "cycleOne": "{count} cycle", + "cycleFew": "{count} cycles", + "cycleOther": "{count} cycles", + "moodOne": "{count} entrée depuis {since}", + "moodFew": "{count} entrées depuis {since}", + "moodOther": "{count} entrées depuis {since}", + "environmentOne": "{count} jour avec données d’environnement", + "environmentFew": "{count} jours avec données d’environnement", + "environmentOther": "{count} jours avec données d’environnement", + "empty": "Rien pour l’instant" + }, + "gap": { + "valuesEmpty": "Connecter des sources", + "illnessEmpty": "Ajouter un épisode", + "medicationsWithoutStart": "Compléter", + "visitsEmpty": "Ajouter une visite", + "documentsUndated": "Vérifier", + "labsEmpty": "Ajouter un résultat", + "vaccinationsEmpty": "Ajouter des vaccins", + "lifeEventsEmpty": "Ajouter" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} médicament sans date de début", + "medicationsWithoutStartFew": "{count} médicaments sans date de début", + "medicationsWithoutStartOther": "{count} médicaments sans date de début", + "documentsUndatedOne": "{count} document sans date", + "documentsUndatedFew": "{count} documents sans date", + "documentsUndatedOther": "{count} documents sans date", + "valuesEmpty": "Pas encore de valeurs sur une longue période", + "illnessEmpty": "Pas encore de maladies avec une date de début", + "visitsEmpty": "Pas encore de consultations effectuées", + "labsEmpty": "Pas encore de résultats d’analyses", + "vaccinationsEmpty": "Pas encore de vaccins", + "lifeEventsEmpty": "Aucun événement de vie pour l’instant" } - } + }, + "legendSpan": "Période", + "legendEvent": "Événement", + "legendMean": "Moyenne mensuelle", + "today": "Aujourd’hui", + "noDataYet": "Pas encore de données", + "layers": "Calques", + "layersLanes": "Pistes", + "zoomLabel": "Période affichée", + "chartLabel": "Chronologie de {from} à {to}", + "keyboardHint": "Les flèches déplacent la sélection, Début et Fin vont aux extrémités, Entrée ouvre le jour.", + "loadFailed": "La chronologie n’a pas pu être chargée.", + "empty": { + "title": "Rien dans la chronologie pour l’instant", + "description": "Les entrées datées apparaîtront ici dès qu’il y en aura." + }, + "values": { + "menuLabel": "Jusqu’à trois valeurs", + "none": "Aucune valeur", + "noneInWindow": "Aucune valeur sur cette période", + "series": { + "BLOOD_PRESSURE_SYS": "Tension systolique", + "BLOOD_PRESSURE_DIA": "Tension diastolique" + } + }, + "table": { + "caption": "Entrées de la chronologie", + "lane": "Piste", + "entry": "Entrée", + "start": "Début", + "end": "Fin" + }, + "selection": { + "empty": "Aucune entrée ce mois-ci", + "ongoing": "en cours", + "range": "{from} au {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Mois", + "years": "Années", + "groupingLabel": "Regroupement", + "gap": "{from} à {to} · aucune entrée", + "gapSingle": "{period} · aucune entrée", + "ended": "Fin : {label}", + "afterDaysOne": "après 1 jour", + "afterDaysFew": "après {count} jours", + "afterDaysOther": "après {count} jours", + "notable": { + "extremeHigh": "Valeur la plus haute depuis longtemps", + "extremeLow": "Valeur la plus basse depuis longtemps", + "firstValue": "Première valeur d’une mesure", + "gap": "Une longue interruption des valeurs" + }, + "notableMeta": "Ouvrez le jour pour les détails" + }, + "standingAllergiesOne": "{count} allergie", + "standingAllergiesFew": "{count} allergies", + "standingAllergiesOther": "{count} allergies" }, "lifeEvents": { "title": "Événement de vie", "add": "Événement de vie", "subtitle": "Un repère pour la chronologie, pas une mesure.", "privacy": "Le titre et la note sont chiffrés. Ils ne sont envoyés à aucune IA.", - "empty": "Aucun événement de vie pour l’instant", "emptyHint": "Un déménagement, une naissance ou un nouvel emploi donnent un repère aux années.", "save": "Enregistrer", "cancel": "Annuler", @@ -12279,7 +12417,8 @@ "endDate": "Jusqu’au", "note": "Note", "optionalTag": "facultatif", - "notePlaceholder": "Quelque chose que vous voudrez savoir plus tard" + "notePlaceholder": "Quelque chose que vous voudrez savoir plus tard", + "removeEnd": "Retirer la date de fin" }, "category": { "FAMILY": "Famille", @@ -12292,6 +12431,21 @@ "DAY": "Jour", "MONTH": "Mois", "YEAR": "Année" + }, + "edit": "Modifier l’événement de vie", + "errors": { + "titleRequired": "Donnez un nom à l’événement.", + "titleTooLong": "120 caractères au maximum.", + "categoryRequired": "Choisissez un type.", + "dateRequired": "Indiquez une date valide.", + "endBeforeStart": "La fin précède le début.", + "noteTooLong": "2000 caractères au maximum." + }, + "saveFailed": "L’enregistrement a échoué.", + "deleteFailed": "La suppression a échoué.", + "deleteConfirm": { + "title": "Supprimer cet événement de vie ?", + "body": "Son titre et sa note disparaîtront." } } } diff --git a/messages/it.json b/messages/it.json index d8e68c0c9..d64bff097 100644 --- a/messages/it.json +++ b/messages/it.json @@ -427,7 +427,9 @@ "workout": "Allenamento", "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato", "symptom": "Sintomo", - "symptomDescription": "Uno dei tuoi sintomi, con la sua intensità" + "symptomDescription": "Uno dei tuoi sintomi, con la sua intensità", + "lifeEventDescription": "Un trasloco, una nascita, un nuovo lavoro", + "lifeEvent": "Evento di vita" }, "cycle": "Ciclo", "labs": "Esami", @@ -578,6 +580,7 @@ "quickAddMood": "Registra umore", "quickAddMedicationIntake": "Registra assunzione", "quickAddWorkout": "Registra allenamento", + "quickAddLifeEvent": "Registra evento di vita", "customizeDashboard": "Personalizza dashboard", "medicationIntakeQuickAdd": { "sheetTitle": "Registra assunzione del farmaco", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Cronologia", - "subtitle": "Anni a colpo d’occhio.", - "legend": "Mostrati uno accanto all’altro, non come causa ed effetto.", + "subtitle": "Cosa è successo e quando, accanto ai tuoi valori.", + "legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.", "standing": "Permanente", "startMissing": "Data di inizio mancante", "openDay": "Apri {date}", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Valori", "mood": "Umore", - "environment": "Ambiente" + "environment": "Ambiente", + "life": "Eventi di vita" + }, + "meter": "{carrying} corsie su {total} hanno contenuto", + "carriesDetail": "{carrying} corsie su {total} danno una panoramica da {since}.", + "loadFailed": "Impossibile caricare l’inventario.", + "card": { + "title": "Cosa contiene già la cronologia", + "summary": "Risale fino a {since}; {carrying} corsie su {total} hanno già contenuto.", + "dismiss": "Nascondi" + }, + "detail": { + "valuesOne": "{count} misura da {since}", + "valuesFew": "{count} misure da {since}", + "valuesOther": "{count} misure da {since}", + "illnessOne": "{count} episodio; cronici: {chronic}", + "illnessFew": "{count} episodi; cronici: {chronic}", + "illnessOther": "{count} episodi; cronici: {chronic}", + "allergiesOne": "{count} allergia", + "allergiesFew": "{count} allergie", + "allergiesOther": "{count} allergie", + "medicationsOne": "{count} farmaco; senza data di inizio: {missingStart}", + "medicationsFew": "{count} farmaci; senza data di inizio: {missingStart}", + "medicationsOther": "{count} farmaci; senza data di inizio: {missingStart}", + "medicationsDatedOne": "{count} farmaco con data di inizio", + "medicationsDatedFew": "{count} farmaci con data di inizio", + "medicationsDatedOther": "{count} farmaci con data di inizio", + "vaccinationsOne": "{count} vaccinazione", + "vaccinationsFew": "{count} vaccinazioni", + "vaccinationsOther": "{count} vaccinazioni", + "visitsOne": "{count} visita · con intervento: {procedures}", + "visitsFew": "{count} visite · con intervento: {procedures}", + "visitsOther": "{count} visite · con intervento: {procedures}", + "visitsPlannedOne": "{count} visita, solo pianificata", + "visitsPlannedFew": "{count} visite, solo pianificate", + "visitsPlannedOther": "{count} visite, solo pianificate", + "labsOne": "{count} giorno con referti", + "labsFew": "{count} giorni con referti", + "labsOther": "{count} giorni con referti", + "documentsOne": "{count} documento con data", + "documentsFew": "{count} documenti con data", + "documentsOther": "{count} documenti con data", + "lifeOne": "{count} evento di vita", + "lifeFew": "{count} eventi di vita", + "lifeOther": "{count} eventi di vita", + "cycleOne": "{count} ciclo", + "cycleFew": "{count} cicli", + "cycleOther": "{count} cicli", + "moodOne": "{count} voce da {since}", + "moodFew": "{count} voci da {since}", + "moodOther": "{count} voci da {since}", + "environmentOne": "{count} giorno con dati ambientali", + "environmentFew": "{count} giorni con dati ambientali", + "environmentOther": "{count} giorni con dati ambientali", + "empty": "Ancora nulla" + }, + "gap": { + "valuesEmpty": "Collega le fonti", + "illnessEmpty": "Aggiungi episodio", + "medicationsWithoutStart": "Completa", + "visitsEmpty": "Aggiungi visita", + "documentsUndated": "Controlla", + "labsEmpty": "Aggiungi referto", + "vaccinationsEmpty": "Aggiungi vaccinazioni", + "lifeEventsEmpty": "Aggiungi" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} farmaco senza data di inizio", + "medicationsWithoutStartFew": "{count} farmaci senza data di inizio", + "medicationsWithoutStartOther": "{count} farmaci senza data di inizio", + "documentsUndatedOne": "{count} documento senza data", + "documentsUndatedFew": "{count} documenti senza data", + "documentsUndatedOther": "{count} documenti senza data", + "valuesEmpty": "Ancora nessun valore su un periodo lungo", + "illnessEmpty": "Ancora nessuna malattia con data di inizio", + "visitsEmpty": "Ancora nessuna visita medica svolta", + "labsEmpty": "Ancora nessun referto di laboratorio", + "vaccinationsEmpty": "Ancora nessuna vaccinazione", + "lifeEventsEmpty": "Ancora nessun evento di vita" } - } + }, + "legendSpan": "Periodo", + "legendEvent": "Evento", + "legendMean": "Media mensile", + "today": "Oggi", + "noDataYet": "Ancora nessun dato", + "layers": "Livelli", + "layersLanes": "Corsie", + "zoomLabel": "Intervallo visualizzato", + "chartLabel": "Cronologia da {from} a {to}", + "keyboardHint": "Le frecce spostano la selezione, Inizio e Fine vanno agli estremi, Invio apre il giorno.", + "loadFailed": "Impossibile caricare la cronologia.", + "empty": { + "title": "Ancora niente nella cronologia", + "description": "Le voci con data compariranno qui appena ce ne saranno." + }, + "values": { + "menuLabel": "Fino a tre valori", + "none": "Nessun valore", + "noneInWindow": "Nessun valore in questo periodo", + "series": { + "BLOOD_PRESSURE_SYS": "Pressione sistolica", + "BLOOD_PRESSURE_DIA": "Pressione diastolica" + } + }, + "table": { + "caption": "Voci della cronologia", + "lane": "Corsia", + "entry": "Voce", + "start": "Inizio", + "end": "Fine" + }, + "selection": { + "empty": "Nessuna voce in questo mese", + "ongoing": "in corso", + "range": "dal {from} al {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Mesi", + "years": "Anni", + "groupingLabel": "Raggruppamento", + "gap": "Da {from} a {to} · nessuna voce", + "gapSingle": "{period} · nessuna voce", + "ended": "Fine: {label}", + "afterDaysOne": "dopo 1 giorno", + "afterDaysFew": "dopo {count} giorni", + "afterDaysOther": "dopo {count} giorni", + "notable": { + "extremeHigh": "Valore più alto da molto tempo", + "extremeLow": "Valore più basso da molto tempo", + "firstValue": "Primo valore di una misura", + "gap": "Una lunga pausa nei valori" + }, + "notableMeta": "Apri il giorno per i dettagli" + }, + "standingAllergiesOne": "{count} allergia", + "standingAllergiesFew": "{count} allergie", + "standingAllergiesOther": "{count} allergie" }, "lifeEvents": { "title": "Evento di vita", "add": "Evento di vita", "subtitle": "Un punto di riferimento per la cronologia, non una misurazione.", "privacy": "Titolo e nota sono salvati cifrati. Non vanno a nessuna IA.", - "empty": "Ancora nessun evento di vita", "emptyHint": "Un trasloco, una nascita o un nuovo lavoro danno agli anni un punto di riferimento.", "save": "Salva", "cancel": "Annulla", @@ -12279,7 +12417,8 @@ "endDate": "Fino al", "note": "Nota", "optionalTag": "facoltativo", - "notePlaceholder": "Qualcosa che vorrai sapere più avanti" + "notePlaceholder": "Qualcosa che vorrai sapere più avanti", + "removeEnd": "Rimuovi data di fine" }, "category": { "FAMILY": "Famiglia", @@ -12292,6 +12431,21 @@ "DAY": "Giorno", "MONTH": "Mese", "YEAR": "Anno" + }, + "edit": "Modifica evento di vita", + "errors": { + "titleRequired": "Dai un nome all’evento.", + "titleTooLong": "Al massimo 120 caratteri.", + "categoryRequired": "Scegli un tipo.", + "dateRequired": "Inserisci una data valida.", + "endBeforeStart": "La fine è prima dell’inizio.", + "noteTooLong": "Al massimo 2000 caratteri." + }, + "saveFailed": "Salvataggio non riuscito.", + "deleteFailed": "Eliminazione non riuscita.", + "deleteConfirm": { + "title": "Eliminare questo evento di vita?", + "body": "Titolo e nota non ci saranno più." } } } diff --git a/messages/ko.json b/messages/ko.json index 9b35bfea8..668767ab1 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -427,7 +427,9 @@ "workout": "운동", "workoutDescription": "기기가 기록하지 못한 운동", "symptom": "증상", - "symptomDescription": "직접 정한 증상과 강도" + "symptomDescription": "직접 정한 증상과 강도", + "lifeEventDescription": "이사, 출산, 새 직장", + "lifeEvent": "삶의 사건" }, "cycle": "생리 주기", "labs": "검사", @@ -578,6 +580,7 @@ "quickAddMood": "기분 기록", "quickAddMedicationIntake": "복용 기록", "quickAddWorkout": "운동 기록", + "quickAddLifeEvent": "삶의 사건 기록", "customizeDashboard": "대시보드 맞춤 설정", "medicationIntakeQuickAdd": { "sheetTitle": "복용 기록", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "타임라인", - "subtitle": "몇 년을 한눈에.", - "legend": "나란히 보여줄 뿐, 원인과 결과를 뜻하지 않아요.", + "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", + "legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.", "standing": "지속", "startMissing": "시작일 없음", "openDay": "{date} 열기", @@ -12254,16 +12257,151 @@ "lanes": { "values": "수치", "mood": "기분", - "environment": "환경" + "environment": "환경", + "life": "삶의 사건" + }, + "meter": "{total}개 중 {carrying}개 줄이 채워졌어요", + "carriesDetail": "{total}개 중 {carrying}개 줄로 {since}부터 한눈에 볼 수 있어요.", + "loadFailed": "현황을 불러오지 못했어요.", + "card": { + "title": "타임라인에 이미 담긴 것", + "summary": "{since}까지 거슬러 올라가며, {total}개 중 {carrying}개 줄이 이미 채워졌어요.", + "dismiss": "숨기기" + }, + "detail": { + "valuesOne": "{since}부터 측정 항목 {count}개", + "valuesFew": "{since}부터 측정 항목 {count}개", + "valuesOther": "{since}부터 측정 항목 {count}개", + "illnessOne": "에피소드 {count}개, 만성 {chronic}개", + "illnessFew": "에피소드 {count}개, 만성 {chronic}개", + "illnessOther": "에피소드 {count}개, 만성 {chronic}개", + "allergiesOne": "알레르기 {count}개", + "allergiesFew": "알레르기 {count}개", + "allergiesOther": "알레르기 {count}개", + "medicationsOne": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsFew": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsOther": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsDatedOne": "시작일 있는 약 {count}개", + "medicationsDatedFew": "시작일 있는 약 {count}개", + "medicationsDatedOther": "시작일 있는 약 {count}개", + "vaccinationsOne": "예방접종 {count}건", + "vaccinationsFew": "예방접종 {count}건", + "vaccinationsOther": "예방접종 {count}건", + "visitsOne": "진료 {count}건 · 시술 {procedures}건", + "visitsFew": "진료 {count}건 · 시술 {procedures}건", + "visitsOther": "진료 {count}건 · 시술 {procedures}건", + "visitsPlannedOne": "예정된 진료만 {count}건", + "visitsPlannedFew": "예정된 진료만 {count}건", + "visitsPlannedOther": "예정된 진료만 {count}건", + "labsOne": "결과가 있는 날 {count}일", + "labsFew": "결과가 있는 날 {count}일", + "labsOther": "결과가 있는 날 {count}일", + "documentsOne": "날짜 있는 문서 {count}개", + "documentsFew": "날짜 있는 문서 {count}개", + "documentsOther": "날짜 있는 문서 {count}개", + "lifeOne": "삶의 사건 {count}개", + "lifeFew": "삶의 사건 {count}개", + "lifeOther": "삶의 사건 {count}개", + "cycleOne": "주기 {count}개", + "cycleFew": "주기 {count}개", + "cycleOther": "주기 {count}개", + "moodOne": "{since}부터 기록 {count}개", + "moodFew": "{since}부터 기록 {count}개", + "moodOther": "{since}부터 기록 {count}개", + "environmentOne": "환경 데이터가 있는 날 {count}일", + "environmentFew": "환경 데이터가 있는 날 {count}일", + "environmentOther": "환경 데이터가 있는 날 {count}일", + "empty": "아직 없음" + }, + "gap": { + "valuesEmpty": "소스 연결", + "illnessEmpty": "에피소드 추가", + "medicationsWithoutStart": "채우기", + "visitsEmpty": "진료 추가", + "documentsUndated": "확인하기", + "labsEmpty": "결과 추가", + "vaccinationsEmpty": "예방접종 추가", + "lifeEventsEmpty": "추가" + }, + "gapLine": { + "medicationsWithoutStartOne": "시작일 없는 약 {count}개", + "medicationsWithoutStartFew": "시작일 없는 약 {count}개", + "medicationsWithoutStartOther": "시작일 없는 약 {count}개", + "documentsUndatedOne": "날짜 없는 문서 {count}개", + "documentsUndatedFew": "날짜 없는 문서 {count}개", + "documentsUndatedOther": "날짜 없는 문서 {count}개", + "valuesEmpty": "아직 오랜 기간의 수치가 없어요", + "illnessEmpty": "아직 시작일이 있는 질환이 없어요", + "visitsEmpty": "아직 완료된 진료가 없어요", + "labsEmpty": "아직 검사 결과가 없어요", + "vaccinationsEmpty": "아직 예방접종이 없어요", + "lifeEventsEmpty": "아직 삶의 사건이 없어요" } - } + }, + "legendSpan": "기간", + "legendEvent": "사건", + "legendMean": "월평균", + "today": "오늘", + "noDataYet": "아직 데이터 없음", + "layers": "레이어", + "layersLanes": "줄", + "zoomLabel": "보기 범위", + "chartLabel": "{from}부터 {to}까지의 타임라인", + "keyboardHint": "화살표 키로 선택을 옮기고, Home과 End로 양 끝으로 이동하며, Enter로 그날을 열어요.", + "loadFailed": "타임라인을 불러오지 못했어요.", + "empty": { + "title": "아직 타임라인에 아무것도 없어요", + "description": "날짜가 있는 기록이 생기면 여기에 나타나요." + }, + "values": { + "menuLabel": "최대 세 가지 수치", + "none": "수치 없음", + "noneInWindow": "이 기간에 수치 없음", + "series": { + "BLOOD_PRESSURE_SYS": "수축기 혈압", + "BLOOD_PRESSURE_DIA": "이완기 혈압" + } + }, + "table": { + "caption": "타임라인 기록", + "lane": "줄", + "entry": "기록", + "start": "시작", + "end": "끝" + }, + "selection": { + "empty": "이번 달 기록 없음", + "ongoing": "진행 중", + "range": "{from}~{to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "월", + "years": "연도", + "groupingLabel": "묶기", + "gap": "{from}~{to} · 기록 없음", + "gapSingle": "{period} · 기록 없음", + "ended": "{label} 끝남", + "afterDaysOne": "{count}일 후", + "afterDaysFew": "{count}일 후", + "afterDaysOther": "{count}일 후", + "notable": { + "extremeHigh": "오랜만의 최고값", + "extremeLow": "오랜만의 최저값", + "firstValue": "측정 항목의 첫 수치", + "gap": "수치에 긴 공백" + }, + "notableMeta": "자세한 내용은 그날을 열어 보세요" + }, + "standingAllergiesOne": "알레르기 {count}개", + "standingAllergiesFew": "알레르기 {count}개", + "standingAllergiesOther": "알레르기 {count}개" }, "lifeEvents": { "title": "삶의 사건", "add": "삶의 사건", "subtitle": "측정값이 아니라 타임라인의 기준점이에요.", "privacy": "제목과 메모는 암호화되어 저장돼요. 어떤 AI에도 보내지 않아요.", - "empty": "아직 삶의 사건이 없어요", "emptyHint": "이사, 출산, 새 직장 같은 일이 몇 년에 기준점을 만들어 줘요.", "save": "저장", "cancel": "취소", @@ -12279,7 +12417,8 @@ "endDate": "종료", "note": "메모", "optionalTag": "선택", - "notePlaceholder": "나중에 알고 싶은 내용" + "notePlaceholder": "나중에 알고 싶은 내용", + "removeEnd": "종료일 삭제" }, "category": { "FAMILY": "가족", @@ -12292,6 +12431,21 @@ "DAY": "일", "MONTH": "월", "YEAR": "연" + }, + "edit": "삶의 사건 편집", + "errors": { + "titleRequired": "사건의 이름을 적어 주세요.", + "titleTooLong": "최대 120자까지예요.", + "categoryRequired": "종류를 골라 주세요.", + "dateRequired": "올바른 날짜를 입력해 주세요.", + "endBeforeStart": "끝이 시작보다 앞서요.", + "noteTooLong": "최대 2000자까지예요." + }, + "saveFailed": "저장하지 못했어요.", + "deleteFailed": "삭제하지 못했어요.", + "deleteConfirm": { + "title": "이 삶의 사건을 삭제할까요?", + "body": "제목과 메모가 사라져요." } } } diff --git a/messages/pl.json b/messages/pl.json index fc3569b56..c739446a9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -427,7 +427,9 @@ "workout": "Trening", "workoutDescription": "Sesja, której nie zapisały twoje urządzenia", "symptom": "Objaw", - "symptomDescription": "Jeden z twoich objawów, z nasileniem" + "symptomDescription": "Jeden z twoich objawów, z nasileniem", + "lifeEventDescription": "Przeprowadzka, narodziny, nowa praca", + "lifeEvent": "Wydarzenie życiowe" }, "cycle": "Cykl", "labs": "Wyniki badań", @@ -578,6 +580,7 @@ "quickAddMood": "Zarejestruj nastrój", "quickAddMedicationIntake": "Zarejestruj przyjęcie", "quickAddWorkout": "Zarejestruj trening", + "quickAddLifeEvent": "Zapisz wydarzenie życiowe", "customizeDashboard": "Dostosuj pulpit", "medicationIntakeQuickAdd": { "sheetTitle": "Zarejestruj przyjęcie leku", @@ -12214,8 +12217,8 @@ }, "timeline": { "title": "Oś czasu", - "subtitle": "Lata w jednym widoku.", - "legend": "Pokazane obok siebie, nie jako przyczyna i skutek.", + "subtitle": "Co i kiedy się działo, obok Twoich wartości.", + "legend": "Jedno pod drugim oznacza ten sam czas, nie przyczynę i skutek.", "standing": "Stałe", "startMissing": "Brak daty rozpoczęcia", "openDay": "Otwórz {date}", @@ -12254,16 +12257,151 @@ "lanes": { "values": "Wartości", "mood": "Nastrój", - "environment": "Otoczenie" + "environment": "Otoczenie", + "life": "Wydarzenia życiowe" + }, + "meter": "{carrying} z {total} ścieżek ma treść", + "carriesDetail": "{carrying} z {total} ścieżek daje przegląd od {since}.", + "loadFailed": "Nie udało się wczytać przeglądu.", + "card": { + "title": "Co już zawiera oś czasu", + "summary": "Sięga do {since}; {carrying} z {total} ścieżek ma już treść.", + "dismiss": "Ukryj" + }, + "detail": { + "valuesOne": "{count} pomiar od {since}", + "valuesFew": "{count} pomiary od {since}", + "valuesOther": "{count} pomiarów od {since}", + "illnessOne": "{count} epizod; przewlekłe: {chronic}", + "illnessFew": "{count} epizody; przewlekłe: {chronic}", + "illnessOther": "{count} epizodów; przewlekłe: {chronic}", + "allergiesOne": "{count} alergia", + "allergiesFew": "{count} alergie", + "allergiesOther": "{count} alergii", + "medicationsOne": "{count} lek; bez daty rozpoczęcia: {missingStart}", + "medicationsFew": "{count} leki; bez daty rozpoczęcia: {missingStart}", + "medicationsOther": "{count} leków; bez daty rozpoczęcia: {missingStart}", + "medicationsDatedOne": "{count} lek z datą rozpoczęcia", + "medicationsDatedFew": "{count} leki z datą rozpoczęcia", + "medicationsDatedOther": "{count} leków z datą rozpoczęcia", + "vaccinationsOne": "{count} szczepienie", + "vaccinationsFew": "{count} szczepienia", + "vaccinationsOther": "{count} szczepień", + "visitsOne": "{count} wizyta · z zabiegiem: {procedures}", + "visitsFew": "{count} wizyty · z zabiegiem: {procedures}", + "visitsOther": "{count} wizyt · z zabiegiem: {procedures}", + "visitsPlannedOne": "{count} wizyta, tylko zaplanowana", + "visitsPlannedFew": "{count} wizyty, tylko zaplanowane", + "visitsPlannedOther": "{count} wizyt, tylko zaplanowane", + "labsOne": "{count} dzień z wynikami", + "labsFew": "{count} dni z wynikami", + "labsOther": "{count} dni z wynikami", + "documentsOne": "{count} dokument z datą", + "documentsFew": "{count} dokumenty z datą", + "documentsOther": "{count} dokumentów z datą", + "lifeOne": "{count} wydarzenie życiowe", + "lifeFew": "{count} wydarzenia życiowe", + "lifeOther": "{count} wydarzeń życiowych", + "cycleOne": "{count} cykl", + "cycleFew": "{count} cykle", + "cycleOther": "{count} cykli", + "moodOne": "{count} wpis od {since}", + "moodFew": "{count} wpisy od {since}", + "moodOther": "{count} wpisów od {since}", + "environmentOne": "{count} dzień z danymi o środowisku", + "environmentFew": "{count} dni z danymi o środowisku", + "environmentOther": "{count} dni z danymi o środowisku", + "empty": "Jeszcze brak" + }, + "gap": { + "valuesEmpty": "Połącz źródła", + "illnessEmpty": "Dodaj epizod", + "medicationsWithoutStart": "Uzupełnij", + "visitsEmpty": "Dodaj wizytę", + "documentsUndated": "Przejrzyj", + "labsEmpty": "Dodaj wynik", + "vaccinationsEmpty": "Dodaj szczepienia", + "lifeEventsEmpty": "Dodaj" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} lek bez daty rozpoczęcia", + "medicationsWithoutStartFew": "{count} leki bez daty rozpoczęcia", + "medicationsWithoutStartOther": "{count} leków bez daty rozpoczęcia", + "documentsUndatedOne": "{count} dokument bez daty", + "documentsUndatedFew": "{count} dokumenty bez daty", + "documentsUndatedOther": "{count} dokumentów bez daty", + "valuesEmpty": "Jeszcze brak wartości z dłuższego okresu", + "illnessEmpty": "Jeszcze brak chorób z datą początku", + "visitsEmpty": "Jeszcze brak odbytych wizyt", + "labsEmpty": "Jeszcze brak wyników badań", + "vaccinationsEmpty": "Jeszcze brak szczepień", + "lifeEventsEmpty": "Brak wydarzeń życiowych" } - } + }, + "legendSpan": "Okres", + "legendEvent": "Zdarzenie", + "legendMean": "Średnia miesięczna", + "today": "Dziś", + "noDataYet": "Jeszcze brak danych", + "layers": "Warstwy", + "layersLanes": "Ścieżki", + "zoomLabel": "Zakres widoku", + "chartLabel": "Oś czasu od {from} do {to}", + "keyboardHint": "Strzałki przesuwają wybór, Home i End przeskakują na krańce, Enter otwiera dzień.", + "loadFailed": "Nie udało się wczytać osi czasu.", + "empty": { + "title": "Na osi czasu jeszcze nic nie ma", + "description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą." + }, + "values": { + "menuLabel": "Do trzech wartości", + "none": "Brak wartości", + "noneInWindow": "Brak wartości w tym okresie", + "series": { + "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe" + } + }, + "table": { + "caption": "Wpisy na osi czasu", + "lane": "Ścieżka", + "entry": "Wpis", + "start": "Początek", + "end": "Koniec" + }, + "selection": { + "empty": "Brak wpisów w tym miesiącu", + "ongoing": "trwa", + "range": "od {from} do {to}", + "mean": "Ø {values}" + }, + "chronicle": { + "months": "Miesiące", + "years": "Lata", + "groupingLabel": "Grupowanie", + "gap": "Od {from} do {to} · brak wpisów", + "gapSingle": "{period} · brak wpisów", + "ended": "Koniec: {label}", + "afterDaysOne": "po 1 dniu", + "afterDaysFew": "po {count} dniach", + "afterDaysOther": "po {count} dniach", + "notable": { + "extremeHigh": "Najwyższa wartość od dawna", + "extremeLow": "Najniższa wartość od dawna", + "firstValue": "Pierwsza wartość pomiaru", + "gap": "Dłuższa przerwa w wartościach" + }, + "notableMeta": "Otwórz dzień, aby zobaczyć szczegóły" + }, + "standingAllergiesOne": "{count} alergia", + "standingAllergiesFew": "{count} alergie", + "standingAllergiesOther": "{count} alergii" }, "lifeEvents": { "title": "Wydarzenie życiowe", "add": "Wydarzenie życiowe", "subtitle": "Punkt odniesienia na osi czasu, nie pomiar.", "privacy": "Tytuł i notatka są zapisywane w postaci zaszyfrowanej. Nie trafiają do żadnej AI.", - "empty": "Brak wydarzeń życiowych", "emptyHint": "Przeprowadzka, narodziny dziecka czy nowa praca dają latom punkt odniesienia.", "save": "Zapisz", "cancel": "Anuluj", @@ -12279,7 +12417,8 @@ "endDate": "Do", "note": "Notatka", "optionalTag": "opcjonalnie", - "notePlaceholder": "Coś, co zechcesz wiedzieć później" + "notePlaceholder": "Coś, co zechcesz wiedzieć później", + "removeEnd": "Usuń datę zakończenia" }, "category": { "FAMILY": "Rodzina", @@ -12292,6 +12431,21 @@ "DAY": "Dzień", "MONTH": "Miesiąc", "YEAR": "Rok" + }, + "edit": "Edytuj wydarzenie życiowe", + "errors": { + "titleRequired": "Nadaj wydarzeniu nazwę.", + "titleTooLong": "Najwyżej 120 znaków.", + "categoryRequired": "Wybierz rodzaj.", + "dateRequired": "Podaj prawidłową datę.", + "endBeforeStart": "Koniec jest przed początkiem.", + "noteTooLong": "Najwyżej 2000 znaków." + }, + "saveFailed": "Nie udało się zapisać.", + "deleteFailed": "Nie udało się usunąć.", + "deleteConfirm": { + "title": "Usunąć to wydarzenie życiowe?", + "body": "Tytuł i notatka znikną." } } } diff --git a/playwright.config.ts b/playwright.config.ts index bf46cf31d..715b38b97 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -185,6 +185,11 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The timeline journey moves its account's timeline module on and + // off; two projects on that one account would flip it under each + // other. It checks the phone chronicle itself by resizing inside the + // desktop project. + "timeline.spec.ts", // The blood-glucose journey writes readings to the one shared account // and then reads GLOBAL verdicts off it — three rows in the list, one // tile per meal-time context — while also moving that account's diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index cf89630c0..a31307fcb 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -235,6 +235,10 @@ function scan(): Scan { * would make the key move when the preference did. */ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ + { + file: "src/components/timeline/timeline-dates.ts", + why: "`todayKeyIn` asks `en-CA` for today's `YYYY-MM-DD` key in the record's zone, to compare with the timeline's calendar keys. Never rendered; every label there is a month name.", + }, { file: "src/app/api/measurement-reminders/[id]/snooze/route.ts", why: "An ISO day probe split back into numbers to do day arithmetic on the postpone target. Never rendered.", diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a429ecb1a..46c0d9655 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -439,6 +439,15 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, + { + file: "components/timeline/use-timeline.ts", + destroys: "one life event with its encrypted title and note", + triggers: ["components/timeline/life-event-form.tsx"], + // The route soft-deletes; there is no restore route or surface yet. + recovery: "tombstoned-no-restore", + confirm: ["ConfirmButton"], + }, + // ── Cycle ─────────────────────────────────────────────────────────────── { file: "components/cycle/use-cycle.ts", diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index e6e818fdf..2bf3354de 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -80,8 +80,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ const RELEASE_PENDING_PREFIXES: Record = { "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", day: "v1.42 day view and its entry points (#613)", - timeline: "v1.42 timeline and its readiness inventory (#613)", - lifeEvents: "v1.42 life-event form and list (#613)", }; /** diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 13e60f025..b1333d4b0 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record< // the two status grids used to spell the tint at every call site and now // name a tone instead, so the marker lives here once. "src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 }, + // v1.42: the readiness inventory's "carries" status icon, one arm of the + // carries / thin / empty tone table, beside its spoken status label. + "src/components/timeline/readiness-inventory.tsx": { "text-success": 1 }, "src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 }, "src/components/admin/api-token-overview-section.tsx": { "text-success": 1 }, "src/components/admin/app-log-preview-section.tsx": { diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx new file mode 100644 index 000000000..c270fda25 --- /dev/null +++ b/src/app/timeline/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { useAuth } from "@/hooks/use-auth"; +import { TimelineView } from "@/components/timeline/timeline-view"; + +/** + * v1.42 (#613): the life timeline. + * + * The `timeline` module is opt-in, and its switch lives in Settings. With + * the module off the navigation entry is gone, and a deep link lands on the + * shell's module notice (`ModulePageGate`, through `nav:/timeline` in the + * surface map) instead of this page, which never mounts and so never asks + * the server, whose routes refuse on their own. An unauthenticated visitor + * is sent to login. + * + * The view reads `?day=` and `?add=` through `useSearchParams`, which needs + * a boundary on a statically rendered page. + */ +export default function TimelinePage() { + const { isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) router.push("/auth/login"); + }, [isLoading, isAuthenticated, router]); + + if (isLoading || !isAuthenticated) return ; + + return ( + }> + + + ); +} diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index ab0909ef2..62050ace4 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -155,6 +155,8 @@ describe(" — every entry follows its module, as the capture p { workouts: false }, { illness: false }, { mood: false, medications: false, workouts: false, illness: false }, + { timeline: false }, + { timeline: true }, ]; for (const modules of cases) { modulesRef.value = modules; @@ -173,6 +175,7 @@ describe(" — every entry follows its module, as the capture p mood: "Log mood", symptom: "Log symptom", workout: "Log workout", + lifeEvent: "Log life event", } as const; for (const kind of CAPTURE_KIND_ORDER) { expect( diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index c969eb495..cbadbda00 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -14,6 +14,7 @@ import { useMemo } from "react"; import Link from "next/link"; import { Activity, + Flag, Footprints, Pill, Plus, @@ -72,6 +73,7 @@ export function DashboardHeader({ const canAddMood = offered.includes("mood"); const canAddSymptom = offered.includes("symptom"); const canAddWorkout = offered.includes("workout"); + const canAddLifeEvent = offered.includes("lifeEvent"); const canAddAnything = offered.length > 0; // The pre-hero greeting derivation, kept hydration-safe: `user` comes @@ -219,6 +221,12 @@ export function DashboardHeader({ {t("dashboard.quickAddWorkout")} )} + {canAddLifeEvent && ( + onQuickEntry("lifeEvent")}> + + )} )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 3eaf1e12d..4131b7c2f 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -18,6 +18,7 @@ import { MoodForm, SymptomEntryForm, } from "@/components/dashboard/quick-entry-forms.lazy"; +import { LifeEventForm } from "@/components/timeline/life-event-form.lazy"; import { AlertDialog, AlertDialogAction, @@ -36,7 +37,13 @@ import { import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = - "measurement" | "mood" | "medicationIntake" | "symptom" | "workout" | null; + | "measurement" + | "mood" + | "medicationIntake" + | "symptom" + | "workout" + | "lifeEvent" + | null; /** * The section each sheet writes to; see the capture picker. `null` marks a @@ -51,6 +58,8 @@ const QUICK_ENTRY_DOMAIN: Readonly< mood: "mind", symptom: "illness", workout: null, + // v1.42 — life events are written in one's own record only. + lifeEvent: null, }; /** @@ -170,6 +179,8 @@ export function QuickEntrySheets({ const [symptomFooterEl, setSymptomFooterEl] = useState( null, ); + const [lifeEventFooterEl, setLifeEventFooterEl] = + useState(null); // v1.11.3 F3 — when an open quick-entry sheet is dismissed with // unsaved input, hold the close in this flag and surface a confirm // instead of nulling the dialog outright. Cleared once the user @@ -269,6 +280,24 @@ export function QuickEntrySheets({ )} + {/* v1.42 — a life event (the opt-in timeline module). Mounted only + while open, so every opening starts from an empty draft. */} + } + > + {openSheet === "lifeEvent" && ( + + )} + + {/* v1.11.3 F3 — confirm before discarding a partly-filled quick-entry form when the sheet is dismissed by an overlay tap, Escape, or a mobile swipe-down. Keeps the sheet open diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 139ead635..23b4a204b 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -1,7 +1,14 @@ "use client"; import { useState } from "react"; -import { Activity, Footprints, Pill, Stethoscope, Waves } from "lucide-react"; +import { + Activity, + Flag, + Footprints, + Pill, + Stethoscope, + Waves, +} from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -11,6 +18,7 @@ import { MoodForm, SymptomEntryForm, } from "@/components/dashboard/quick-entry-forms.lazy"; +import { LifeEventForm } from "@/components/timeline/life-event-form.lazy"; import { AlertDialog, AlertDialogAction, @@ -42,6 +50,8 @@ import { * - Workout → `` (the workouts page's "Log workout") * - Symptom → `` (one of the person's own symptoms, * v1.40; rides the illness module) + * - Life event → `` (v1.42; rides the opt-in timeline + * module, own record only) * * The picker reuses each existing capture component rather than * rebuilding parallel forms. @@ -58,7 +68,7 @@ import { */ export type CaptureKind = - "measurement" | "medication" | "mood" | "symptom" | "workout"; + "measurement" | "medication" | "mood" | "symptom" | "workout" | "lifeEvent"; /** * The section each capture surface writes to. @@ -83,6 +93,9 @@ const CAPTURE_KIND_DOMAIN: Readonly> = { // A workout goes through the batch ingest, which resolves the caller: // no grant reaches it, so it is offered in one's own record only. workout: null, + // v1.42 — life events are written by the record's owner only until their + // writes join the audited delegable list; a delegate is never offered one. + lifeEvent: null, }; /** The order the chooser lists them in. */ @@ -92,6 +105,7 @@ export const CAPTURE_KIND_ORDER: ReadonlyArray = [ "mood", "symptom", "workout", + "lifeEvent", ]; /** @@ -231,6 +245,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { description: t("nav.capture.workoutDescription"), icon: Footprints, }, + { + kind: "lifeEvent", + label: t("nav.capture.lifeEvent"), + description: t("nav.capture.lifeEventDescription"), + icon: Flag, + }, ]; const options = allOptions.filter((opt) => offered.includes(opt.kind)); @@ -240,6 +260,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { mood: t("mood.addEntry"), symptom: t("symptoms.entry.sheetTitle"), workout: t("insights.workouts.manual.sheetTitle"), + lifeEvent: t("lifeEvents.title"), }; // `openKind === null` keeps the form sheet closed (the title is unread // then); the mood label is the harmless default, matching the prior @@ -316,6 +337,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) { footerSlot={footerEl} /> )} + {openKind === "lifeEvent" && ( + + )} {openKind === "mood" && ( { // v1.32.22 (R5b) — instant-write disposition: a 409 means the module map @@ -283,6 +290,10 @@ export function ModulesSection() { ); })} + ); } diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts new file mode 100644 index 000000000..f0da55414 --- /dev/null +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -0,0 +1,167 @@ +/** + * v1.42 (#613) — the phone chronicle: newest first, grouped, empty stretches + * said out loud, open things once in the Ongoing block, closed periods as + * rails. + */ +import { describe, expect, it } from "vitest"; + +import { + buildChronicle, + chronicleEntries, + standingChips, + type ChronicleRow, +} from "../chronicle-model"; +import { TODAY, fullTimeline } from "./timeline-fixture"; + +function headers(rows: ChronicleRow[]) { + return rows + .filter((r) => r.type === "header") + .map((r) => (r.type === "header" ? r.group : "")); +} + +describe("chronicleEntries", () => { + it("lists points, period starts and period ends, newest first", () => { + const entries = chronicleEntries(fullTimeline(), TODAY); + const dates = entries.map((e) => e.date); + expect([...dates].sort().reverse()).toEqual(dates); + const cold = entries.filter( + (e) => e.kind === "item" && e.item.id === "ill-5", + ); + expect(cold.map((e) => (e.kind === "item" ? e.role : null))).toEqual([ + "end", + "start", + ]); + const end = cold[0]; + expect(end.kind === "item" && end.days).toBe(9); + }); + + it("never lists anything after today", () => { + const tl = fullTimeline(); + tl.lanes[0].items.push({ + id: "future", + kind: "lifeEvent", + start: "2027-01-01", + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: "x", + sub: null, + href: null, + }); + expect(chronicleEntries(tl, TODAY).some((e) => e.date > TODAY)).toBe(false); + }); + + it("carries the quiet notable line", () => { + const entries = chronicleEntries(fullTimeline(), TODAY); + expect(entries.find((e) => e.kind === "notable")).toMatchObject({ + date: "2026-01-04", + notable: "extremeHigh", + }); + }); +}); + +describe("buildChronicle", () => { + it("folds a run of empty months into one honest gap row", () => { + const rows = buildChronicle(fullTimeline(), TODAY, "month"); + // The last entry is the course ending on 31 March 2026: April to + // October 2026 hold nothing. + const gap = rows.find((r) => r.type === "gap" && r.to === "2026-10-01"); + expect(gap).toEqual({ + type: "gap", + from: "2026-04-01", + to: "2026-10-01", + rails: {}, + }); + expect(headers(rows)[0]).toBe("2026-03-01"); + }); + + it("keeps a course's rail through an empty stretch it spans", () => { + const rows = buildChronicle(fullTimeline(), TODAY, "month"); + // The 2024/25 winter course runs from November to March; nothing else + // is dated in December to February. + const gap = rows.find((r) => r.type === "gap" && r.from === "2024-12-01"); + expect(gap?.type === "gap" && gap.rails.medications).toBe("full"); + }); + + it("never shows two gap rows next to each other", () => { + const rows = buildChronicle(fullTimeline(), TODAY, "month"); + for (let i = 1; i < rows.length; i++) { + expect(rows[i].type === "gap" && rows[i - 1].type === "gap").toBe(false); + } + }); + + it("groups by year when asked", () => { + const rows = buildChronicle(fullTimeline(), TODAY, "year"); + expect(headers(rows)).toEqual([ + "2026-01-01", + "2025-01-01", + "2024-01-01", + "2023-01-01", + "2022-01-01", + "2021-01-01", + "2020-01-01", + "2019-01-01", + // The grass-pollen allergy began in 2018. + "2018-01-01", + ]); + }); + + it("draws a closed episode as a rail from its end row down to its start row", () => { + const rows = buildChronicle(fullTimeline(), TODAY, "month"); + const railOf = (id: string, role: string) => { + const row = rows.find( + (r) => + r.type === "entry" && + r.entry.kind === "item" && + r.entry.item.id === id && + r.entry.role === role, + ); + return row && row.type === "entry" ? row.rails.illness : undefined; + }; + expect(railOf("ill-5", "end")).toBe("end"); + expect(railOf("ill-5", "start")).toBe("start"); + // The visit on 3 January sits inside the cold. + const visit = rows.find( + (r) => + r.type === "entry" && + r.entry.kind === "item" && + r.entry.item.id === "v-4", + ); + expect(visit?.type === "entry" && visit.rails.illness).toBe("full"); + }); + + it("is empty for a record without anything dated", () => { + expect( + buildChronicle( + { + lanes: [], + notable: [], + range: { from: "2026-01-01", to: "2026-12-31", dataFrom: null }, + }, + TODAY, + "month", + ), + ).toEqual([]); + }); +}); + +describe("standingChips", () => { + it("collects open things once and folds several allergies into one chip", () => { + const chips = standingChips(fullTimeline()); + expect(chips.map((c) => c.id)).toEqual([ + "ill-chronic", + "med-1", + "allergies", + ]); + expect(chips.at(-1)).toMatchObject({ lane: "allergies", count: 2 }); + }); + + it("lists a standing item without a start", () => { + const tl = fullTimeline(); + tl.standing = [ + { lane: "illness", id: "pre", label: "Asthma", since: null, href: null }, + ]; + expect(standingChips(tl).map((c) => c.label)).toContain("Asthma"); + }); +}); diff --git a/src/components/timeline/__tests__/readiness-and-draft.test.ts b/src/components/timeline/__tests__/readiness-and-draft.test.ts new file mode 100644 index 000000000..221e280e6 --- /dev/null +++ b/src/components/timeline/__tests__/readiness-and-draft.test.ts @@ -0,0 +1,154 @@ +/** + * v1.42 (#613) — the readiness inventory's reading order and card rule, and + * the life-event form's checks and request bodies. + */ +import { describe, expect, it } from "vitest"; + +import type { LifeEventDTO } from "@/lib/day/contract"; + +import { + alignToPrecision, + createBody, + draftFromEvent, + emptyDraft, + updateBody, + validateDraft, + withPrecision, +} from "../life-event-draft"; +import { + orderedReadinessLanes, + readinessGaps, + readinessTally, + showReadinessCard, +} from "../readiness-model"; +import { readiness } from "./timeline-fixture"; + +describe("readiness", () => { + it("reads carrying lanes first, then thin, then empty", () => { + expect(orderedReadinessLanes(readiness()).map((l) => l.status)).toEqual([ + "carries", + "carries", + "carries", + "thin", + "empty", + "empty", + ]); + }); + + it("offers one link per gap, in reading order", () => { + expect(readinessGaps(readiness()).map((g) => g.key)).toEqual([ + "medicationsWithoutStart", + "lifeEventsEmpty", + "vaccinationsEmpty", + ]); + }); + + it("counts the carrying lanes, never a score", () => { + expect(readinessTally(readiness())).toEqual({ carrying: 3, total: 6 }); + }); + + it("shows the card while a thin or empty lane has a link, until closed", () => { + expect(showReadinessCard(readiness(), false)).toBe(true); + expect(showReadinessCard(readiness(), true)).toBe(false); + expect(showReadinessCard(undefined, false)).toBe(false); + const allCarry = readiness({ + lanes: readiness().lanes.map((l) => ({ + ...l, + status: "carries" as const, + gaps: [], + })), + }); + expect(showReadinessCard(allCarry, false)).toBe(false); + }); +}); + +const STORED: LifeEventDTO = { + id: "le-1", + category: "HOME", + startDate: "2023-09-01", + endDate: null, + precision: "MONTH", + title: "Umzug", + note: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("life-event draft", () => { + it("asks for a title and a kind before anything is sent", () => { + expect(validateDraft(emptyDraft("2026-10-07"))).toEqual({ + title: "titleRequired", + category: "categoryRequired", + }); + }); + + it("refuses an end before the start and an over-long title", () => { + const draft = { + ...emptyDraft("2026-10-07"), + title: "x".repeat(121), + category: "WORK" as const, + end: "2026-01-01", + }; + expect(validateDraft(draft)).toEqual({ + title: "titleTooLong", + end: "endBeforeStart", + }); + }); + + it("refuses a date that is not a calendar date", () => { + const draft = { + ...emptyDraft("2026-02-30"), + title: "a", + category: "OTHER" as const, + }; + expect(validateDraft(draft).start).toBe("dateRequired"); + }); + + it("aligns both ends when the precision changes", () => { + expect(alignToPrecision("2023-09-17", "MONTH")).toBe("2023-09-01"); + expect(alignToPrecision("2023-09-17", "YEAR")).toBe("2023-01-01"); + const draft = withPrecision( + { ...emptyDraft("2023-09-17"), end: "2024-02-11" }, + "MONTH", + ); + expect([draft.start, draft.end]).toEqual(["2023-09-01", "2024-02-01"]); + }); + + it("sends a trimmed body, a blank note as null", () => { + const body = createBody({ + ...emptyDraft("2023-09-17"), + title: " Umzug nach Bochum ", + category: "HOME", + precision: "MONTH", + note: " ", + }); + expect(body).toEqual({ + category: "HOME", + precision: "MONTH", + startDate: "2023-09-01", + endDate: null, + title: "Umzug nach Bochum", + note: null, + }); + }); + + it("patches only what changed, dates and precision together", () => { + const draft = draftFromEvent(STORED); + expect(updateBody(STORED, draft)).toEqual({}); + expect( + updateBody(STORED, { ...draft, title: "Umzug nach Bochum" }), + ).toEqual({ + title: "Umzug nach Bochum", + }); + expect(updateBody(STORED, withPrecision(draft, "YEAR"))).toEqual({ + precision: "YEAR", + startDate: "2023-01-01", + endDate: null, + }); + expect( + updateBody({ ...STORED, note: "alt" }, { ...draft, note: "" }), + ).toEqual({ + note: null, + }); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx new file mode 100644 index 000000000..5340a1fe3 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -0,0 +1,163 @@ +/** + * v1.42 (#613) — the timeline's surfaces as rendered: the readiness + * inventory in its three states, the card, the phone chronicle's honest + * gaps and the selection bar's hand-off to the day, and the capture-picker + * gate for life events. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; +import { I18nProvider } from "@/lib/i18n/context"; + +import { ReadinessCard } from "../readiness-card"; +import { ReadinessInventory, VerdictMeter } from "../readiness-inventory"; +import { SelectionBar } from "../selection-bar"; +import { TimelineChronicle } from "../timeline-chronicle"; +import { TODAY, fullTimeline, readiness } from "./timeline-fixture"; + +function render(node: React.ReactElement, locale: "de" | "en" = "de") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("readiness inventory", () => { + it("names every lane with its state and one link per gap", () => { + const html = render(); + expect(html.match(/data-slot="timeline-readiness-lane"/g)).toHaveLength(6); + expect(html).toContain('data-status="carries"'); + expect(html).toContain('data-status="thin"'); + expect(html).toContain('data-status="empty"'); + expect(html).toContain("6 Episoden, davon 1 chronisch"); + expect(html).toContain("5 Medikamente, 3 ohne Startdatum"); + expect(html).toContain("3 Messgrößen seit März 2019"); + expect(html.match(/data-slot="timeline-readiness-gap"/g)).toHaveLength(3); + expect(html).toContain('href="/medications/med-1?edit=1"'); + expect(html).toContain("Impfpass erfassen"); + }); + + it("leaves out a detail the bundle cannot word instead of showing a raw key", () => { + const html = render( + , + ); + expect(html).not.toContain("somethingNew"); + expect(html).not.toContain("unknownGap"); + expect(html).not.toContain('data-slot="timeline-readiness-gap"'); + }); + + it("states the verdict as a count, never a score", () => { + const carries = render(); + expect(carries).toContain("Trägt schon."); + expect(carries).toContain( + "3 von 6 Spuren reichen für einen Überblick ab März 2019.", + ); + expect(carries).toContain('aria-label="3 von 6 Spuren tragen"'); + const thin = render( + , + ); + expect(thin).toContain("Noch dünn."); + expect(thin).not.toContain("Überblick ab"); + }); + + it("puts the gaps on the timeline card with their lines", () => { + const html = render( + undefined} />, + ); + expect(html).toContain("3 Medikamente ohne Startdatum"); + expect(html).toContain("Noch keine Lebensereignisse"); + expect(html).toContain('href="/timeline?add=lifeEvent"'); + expect(html).toContain('data-slot="timeline-readiness-card-dismiss"'); + }); +}); + +describe("chronicle", () => { + it("names an empty stretch instead of skipping it, and folds the ongoing items", () => { + const html = render( + undefined} + onEditLifeEvent={null} + />, + ); + expect(html).toContain("April bis Oktober 2026 · keine Einträge"); + expect(html).toContain("Dauerhaft"); + expect(html).toContain("2 Allergien"); + expect(html).toContain("Erkältung vorbei"); + expect(html).toContain("nach 9 Tagen"); + expect(html).toContain("Höchster Wert seit Langem"); + // The selected day is the highlighted row. + expect(html).toMatch(/data-date="2026-01-03">]*bg-muted/); + }); + + it("shows the month's means beside the month", () => { + const html = render( + undefined} + onEditLifeEvent={null} + />, + ); + expect(html).toContain("129/82 mmHg · 82,6 kg"); + }); +}); + +describe("selection bar", () => { + it("lists the month's entries and offers the selected day", () => { + const html = render( + undefined} + />, + ); + expect(html).toContain("Januar 2026"); + expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan."); + expect(html).toContain("Vitamin D (Winter) · laufend"); + expect(html).not.toContain("Bluthochdruck"); + expect(html).toContain("3. Jan. öffnen"); + expect(html).toContain('data-date="2026-01-03"'); + expect(html).toContain("Ø 129/82 mmHg · 82,6 kg"); + }); +}); + +describe("life events in the capture picker", () => { + const own = { inSharedRecord: false, canWriteDomain: () => true }; + it("is offered only with the timeline module on", () => { + expect( + visibleCaptureKinds(own, CAPTURE_KIND_ORDER, { timeline: false }), + ).not.toContain("lifeEvent"); + expect( + visibleCaptureKinds(own, CAPTURE_KIND_ORDER, { timeline: true }), + ).toContain("lifeEvent"); + }); + + it("is never offered inside somebody else's record", () => { + const delegate = { inSharedRecord: true, canWriteDomain: () => true }; + expect( + visibleCaptureKinds(delegate, CAPTURE_KIND_ORDER, { timeline: true }), + ).not.toContain("lifeEvent"); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts new file mode 100644 index 000000000..2b466afd7 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -0,0 +1,329 @@ +/** + * A made-up record for the timeline tests: a chronic condition and several + * episodes, two allergies, a standing medication with a dose change, a winter + * course three years running, a medication whose start is unknown, visits + * with two procedures, documents, vaccinations and three life events. All + * data invented. + */ +import type { + TimelineItem, + TimelineReadinessResponse, + TimelineResponse, +} from "@/lib/day/contract"; + +export function item( + over: Partial & Pick, +): TimelineItem { + return { + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: over.id, + sub: null, + href: null, + ...over, + }; +} + +export const TODAY = "2026-10-07"; + +export function fullTimeline(): TimelineResponse { + return { + zoom: "all", + range: { from: "2019-01-01", to: "2026-12-31", dataFrom: "2019-01-01" }, + lanes: [ + { + key: "life", + items: [ + item({ + id: "le-1", + kind: "lifeEvent", + start: "2021-05-14", + label: "Geburt Tochter", + }), + item({ + id: "le-2", + kind: "lifeEvent", + start: "2023-09-01", + precision: "MONTH", + label: "Umzug", + }), + item({ + id: "le-3", + kind: "lifeEvent", + start: "2024-10-01", + label: "Neue Stelle", + }), + ], + }, + { + key: "illness", + items: [ + item({ + id: "ill-chronic", + kind: "chronic", + start: "2019-03-10", + open: true, + label: "Bluthochdruck", + sub: "chronisch", + }), + item({ + id: "ill-1", + kind: "episode", + start: "2020-09-01", + end: "2020-10-20", + label: "Schulter", + }), + item({ + id: "ill-2", + kind: "episode", + start: "2022-03-08", + end: "2022-03-24", + label: "COVID-19", + }), + item({ + id: "ill-3", + kind: "episode", + start: "2022-11-02", + end: "2023-02-15", + label: "Bandscheibe L4/L5", + }), + item({ + id: "ill-4", + kind: "episode", + start: "2024-08-10", + end: "2024-08-16", + label: "Magen-Darm", + }), + item({ + id: "ill-5", + kind: "episode", + start: "2025-12-31", + end: "2026-01-08", + label: "Erkältung", + }), + ], + }, + { + key: "allergies", + items: [ + item({ + id: "al-1", + kind: "allergy", + start: "2018-01-01", + open: true, + label: "Gräserpollen", + }), + item({ + id: "al-2", + kind: "allergy", + start: "2020-07-15", + open: true, + label: "Penicillin", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med-1", + kind: "medication", + start: "2019-04-02", + open: true, + label: "Ramipril", + sub: "2,5 mg", + }), + item({ + id: "dose-1", + kind: "doseChange", + start: "2020-08-12", + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "med-2", + kind: "medication", + start: "2022-11-03", + end: "2022-12-10", + label: "Ibuprofen", + }), + item({ + id: "course-1", + kind: "course", + start: "2023-11-01", + end: "2024-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "course-2", + kind: "course", + start: "2024-11-01", + end: "2025-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "course-3", + kind: "course", + start: "2025-11-01", + end: "2026-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "pause-1", + kind: "pause", + start: "2021-06-01", + end: "2021-06-20", + label: "Pause", + }), + ], + }, + { + key: "vaccinations", + items: [ + "2020-06-10", + "2021-05-20", + "2021-07-01", + "2022-10-15", + "2024-10-18", + "2025-10-10", + ].map((d, i) => + item({ + id: `vac-${i}`, + kind: "vaccination", + start: d, + label: "Impfung", + }), + ), + }, + { + key: "visits", + items: [ + item({ + id: "v-1", + kind: "visit", + start: "2019-03-10", + label: "Hausarztpraxis", + }), + item({ + id: "v-2", + kind: "procedure", + start: "2022-11-08", + label: "MRT Lendenwirbelsäule", + }), + item({ + id: "v-3", + kind: "procedure", + start: "2025-03-12", + label: "Darmspiegelung", + }), + item({ + id: "v-4", + kind: "visit", + start: "2026-01-03", + label: "Hausarztpraxis", + }), + ], + }, + { key: "labs", items: [] }, + { + key: "documents", + items: ["2019-03-12", "2022-11-09", "2026-01-03"].map((d, i) => + item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }), + ), + }, + ], + standing: [], + series: [ + { + key: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + granularity: "month", + points: [ + { t: "2025-10-01", mean: 127 }, + { t: "2025-11-01", mean: 128 }, + { t: "2025-12-01", mean: 130 }, + { t: "2026-01-01", mean: 129 }, + // Nothing for February to June. + { t: "2026-07-01", mean: 131 }, + ], + }, + { + key: "BLOOD_PRESSURE_DIA", + unit: "mmHg", + granularity: "month", + points: [{ t: "2026-01-01", mean: 82 }], + }, + { + key: "WEIGHT", + unit: "kg", + granularity: "month", + points: [{ t: "2026-01-01", mean: 82.6 }], + }, + ], + notable: [{ date: "2026-01-04", kind: "extremeHigh" }], + }; +} + +export function readiness( + over: Partial = {}, +): TimelineReadinessResponse { + return { + verdict: "carries", + since: "2019-03-01", + lanes: [ + { + key: "values", + status: "carries", + count: 3, + detail: { key: "values", params: { count: 3, since: "2019-03-01" } }, + gaps: [], + }, + { + key: "life", + status: "empty", + count: 0, + detail: { key: "emptyLife", params: {} }, + gaps: [ + { key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }, + { + key: "illness", + status: "carries", + count: 6, + detail: { key: "illness", params: { count: 6, chronic: 1 } }, + gaps: [], + }, + { + key: "medications", + status: "thin", + count: 5, + detail: { key: "medications", params: { count: 5, missingStart: 3 } }, + gaps: [ + { + key: "medicationsWithoutStart", + count: 3, + href: "/medications/med-1?edit=1", + }, + ], + }, + { + key: "vaccinations", + status: "empty", + count: 0, + detail: { key: "empty", params: {} }, + gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }], + }, + { + key: "visits", + status: "carries", + count: 11, + detail: { key: "visits", params: { count: 11, procedures: 2 } }, + gaps: [], + }, + ], + ...over, + }; +} diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts new file mode 100644 index 000000000..8b3f36e19 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -0,0 +1,421 @@ +/** + * v1.42 (#613) — the timeline's geometry: window, scale, grid, rows, labels + * and value lines. The renderer paints exactly this, so the promises the + * chart makes (no label on top of another, nothing past the card edge, an + * unknown start says so) are pinned here. + */ +import { describe, expect, it } from "vitest"; + +import { + createScale, + dateAtPointer, + estimateTextWidth, + fitText, + gridTicks, + itemLabel, + itemsInMonth, + layoutLane, + layoutSeries, + layoutTimeline, + stepSelection, + todayLabelFits, + windowFor, + wrapToWidth, + type LaneLayout, + type TimelineLayout, +} from "../timeline-geometry"; +import { dayNumber } from "../timeline-dates"; +import { TODAY, fullTimeline, item } from "./timeline-fixture"; + +const FMT = { + year: (k: string) => k.slice(0, 4), + monthShort: (k: string) => k.slice(5, 7), + monthYear: (k: string) => `Monat ${k.slice(0, 7)}`, +}; + +function layoutAt(width: number): TimelineLayout { + const tl = fullTimeline(); + return layoutTimeline({ + width, + window: windowFor("all", TODAY, null, tl.range), + lanes: tl.lanes, + series: tl.series, + startMissing: "Startdatum fehlt", + today: TODAY, + }); +} + +/** Every label's box, by the same estimate the layout uses. */ +function boxes(lane: LaneLayout) { + return lane.labels.map((l) => ({ + ...l, + a: l.x, + b: l.x + estimateTextWidth(l.text), + })); +} + +describe("text fitting", () => { + it("cuts a name to its column with an ellipsis", () => { + expect(fitText("Arzt und Eingriffe", 200, 13)).toBe("Arzt und Eingriffe"); + const cut = fitText("Arzt und Eingriffe", 60, 13); + expect(cut.endsWith("…")).toBe(true); + expect(estimateTextWidth(cut, 13)).toBeLessThanOrEqual(60); + }); + + it("wraps a note onto lines that fit, or drops it when a word cannot", () => { + expect(wrapToWidth("Noch keine Daten", 200)).toEqual(["Noch keine Daten"]); + expect(wrapToWidth("Noch keine Daten", 70)).toEqual([ + "Noch keine", + "Daten", + ]); + expect(wrapToWidth("Noch keine Daten", 20)).toEqual([]); + }); +}); + +describe("windowFor", () => { + it("follows the server range at `all`", () => { + expect( + windowFor("all", TODAY, null, { from: "2019-01-01", to: "2026-12-31" }), + ).toEqual({ from: "2019-01-01", to: "2026-12-31" }); + }); + + it("shows twelve whole months that never run past this month", () => { + expect(windowFor("year", TODAY, null)).toEqual({ + from: "2025-11-01", + to: "2026-10-31", + }); + // An older selection stays in view, with months on both sides. + expect(windowFor("year", TODAY, "2026-01-03")).toEqual({ + from: "2025-07-01", + to: "2026-06-30", + }); + }); + + it("shows three months around the selection at `quarter`", () => { + expect(windowFor("quarter", TODAY, "2026-01-03")).toEqual({ + from: "2025-12-01", + to: "2026-02-28", + }); + expect(windowFor("quarter", TODAY, null)).toEqual({ + from: "2026-08-01", + to: "2026-10-31", + }); + }); + + it("ignores a selection in the future", () => { + expect(windowFor("quarter", TODAY, "2027-05-01")).toEqual( + windowFor("quarter", TODAY, null), + ); + }); +}); + +describe("createScale", () => { + const scale = createScale({ from: "2026-01-01", to: "2026-12-31" }, 100, 465); + + it("maps the window onto the drawing width, one day per step", () => { + expect(scale.x("2026-01-01")).toBe(100); + expect(scale.x(dayNumber("2026-12-31") + 1)).toBe(465); + expect(scale.x("2026-01-02") - scale.x("2026-01-01")).toBeCloseTo(1, 6); + }); + + it("reads a day back from x, clamped into the window", () => { + expect(scale.dayAt(scale.xMid("2026-03-15"))).toBe("2026-03-15"); + expect(scale.dayAt(0)).toBe("2026-01-01"); + expect(scale.dayAt(10_000)).toBe("2026-12-31"); + }); +}); + +describe("gridTicks", () => { + it("labels years at `all` and drops labels that would touch", () => { + const window = { from: "2019-01-01", to: "2026-12-31" }; + const wide = gridTicks(window, "all", createScale(window, 164, 1100), FMT); + expect(wide.filter((t) => t.label).map((t) => t.label)).toEqual([ + "2019", + "2020", + "2021", + "2022", + "2023", + "2024", + "2025", + "2026", + ]); + const narrow = gridTicks(window, "all", createScale(window, 140, 330), FMT); + const labelled = narrow.filter((t) => t.label); + expect(labelled.length).toBeLessThan(8); + for (let i = 1; i < labelled.length; i++) { + const prevEnd = + labelled[i - 1].x + 6 + estimateTextWidth(labelled[i - 1].label!, 12); + expect(labelled[i].x + 6).toBeGreaterThanOrEqual(prevEnd); + } + }); + + it("lets the axis labels win over the Today label", () => { + const window = { from: "2019-01-01", to: "2026-12-31" }; + const scale = createScale(window, 164, 1100); + const ticks = gridTicks(window, "all", scale, FMT); + // Early October: clear of the 2026 label, room for "Heute". + expect(todayLabelFits(ticks, scale.xMid("2026-10-07"), 30, 164)).toBe(true); + // Mid January: right on top of the 2026 label. + expect(todayLabelFits(ticks, scale.xMid("2026-01-20"), 30, 164)).toBe( + false, + ); + }); + + it("draws months at `year` and weekly lines at `quarter`", () => { + const year = { from: "2025-11-01", to: "2026-10-31" }; + expect( + gridTicks(year, "year", createScale(year, 164, 1200), FMT), + ).toHaveLength(12); + const quarter = { from: "2025-12-01", to: "2026-02-28" }; + const ticks = gridTicks( + quarter, + "quarter", + createScale(quarter, 164, 1200), + FMT, + ); + expect(ticks.filter((t) => t.major)).toHaveLength(3); + expect(ticks.filter((t) => !t.major).length).toBeGreaterThanOrEqual(11); + }); +}); + +describe("layoutLane", () => { + const window = { from: "2019-01-01", to: "2026-12-31" }; + const scale = createScale(window, 164, 1200); + const opts = { startMissing: "Startdatum fehlt", today: TODAY }; + + it("puts overlapping periods on separate rows and lets later ones reuse a free row", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "illness")!; + const layout = layoutLane({ lane, top: 0 }, window, scale, opts); + const chronic = layout.spans.find((s) => s.item.id === "ill-chronic")!; + const episodes = layout.spans.filter((s) => s.item.kind === "episode"); + expect(chronic.row).toBe(0); + expect(new Set(episodes.map((s) => s.row))).toEqual(new Set([1])); + expect(layout.rows).toBe(2); + }); + + it("lays a dose change and a pause over the medication they belong to", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "medications")!; + const layout = layoutLane({ lane, top: 0 }, window, scale, opts); + const host = layout.spans.find((s) => s.item.id === "med-1")!; + expect(layout.points.find((p) => p.item.id === "dose-1")?.row).toBe( + host.row, + ); + const pause = layout.spans.find((s) => s.item.id === "pause-1")!; + expect(pause.pause).toBe(true); + expect(pause.row).toBe(host.row); + }); + + it("starts an open period at its start and runs it to today", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "allergies")!; + const layout = layoutLane({ lane, top: 0 }, window, scale, opts); + const grass = layout.spans.find((s) => s.item.id === "al-1")!; + expect(grass.clippedLeft).toBe(true); + expect(grass.xStart).toBe(164); + expect(grass.xEnd).toBeCloseTo(scale.x(dayNumber(TODAY) + 1), 6); + }); + + it("names an unknown start in the label", () => { + const unknown = item({ + id: "m", + kind: "medication", + start: "2024-05-01", + open: true, + startKnown: false, + label: "Ramipril", + sub: "5 mg", + }); + expect(itemLabel(unknown, "Startdatum fehlt")).toBe( + "Ramipril · 5 mg · Startdatum fehlt", + ); + const layout = layoutLane( + { lane: { key: "medications", items: [unknown] }, top: 0 }, + window, + scale, + opts, + ); + expect(layout.labels[0].text).toContain("Startdatum fehlt"); + }); + + it("names life events beside their diamonds, clear of the glyph", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "life")!; + const layout = layoutLane({ lane, top: 0 }, window, scale, opts); + expect(layout.labels.map((l) => l.text)).toEqual([ + "Geburt Tochter", + "Umzug", + "Neue Stelle", + ]); + for (const label of layout.labels) { + const point = layout.points.find((p) => p.item.id === label.itemId)!; + expect(label.x).toBeGreaterThan(point.x + 7); + expect(label.strong).toBe(true); + } + }); + + it("puts procedures on their own row below the plain visits", () => { + const lane = fullTimeline().lanes.find((l) => l.key === "visits")!; + const layout = layoutLane({ lane, top: 0 }, window, scale, opts); + expect( + layout.points + .filter((p) => p.item.kind === "visit") + .every((p) => p.row === 0), + ).toBe(true); + expect( + layout.points + .filter((p) => p.item.kind === "procedure") + .every((p) => p.row === 1), + ).toBe(true); + expect(layout.labels.map((l) => l.text)).toContain("Darmspiegelung"); + }); +}); + +describe("label collision avoidance", () => { + // 1440 full, 1440 with the docked day panel (~700), a laptop and a + // narrow tablet column. + for (const width of [1180, 940, 700, 520]) { + it(`never overlaps two labels or runs past the edge at ${width} px`, () => { + const layout = layoutAt(width); + for (const lane of layout.lanes) { + const list = boxes(lane); + for (const box of list) { + expect(box.a).toBeGreaterThanOrEqual(layout.scale.x0); + expect(box.b).toBeLessThanOrEqual(layout.scale.x1); + } + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + const a = list[i]; + const b = list[j]; + if (a.y !== b.y) continue; + const overlap = a.a < b.b && b.a < a.b; + expect(overlap, `${a.text} / ${b.text}`).toBe(false); + } + } + } + }); + } + + it("never puts a label over a bar of its own row", () => { + const layout = layoutAt(940); + for (const lane of layout.lanes) { + for (const label of lane.labels) { + // Labels on a bar's line sit at y + 4; above-labels at y - 7. + const onLine = lane.spans.filter((s) => s.y + 4 === label.y); + for (const s of onLine) { + const b = label.x + estimateTextWidth(label.text); + const overlap = label.x < s.xEnd && s.xStart < b; + expect(overlap, `${label.text} over ${s.item.label}`).toBe(false); + } + } + } + }); + + it("drops labels rather than squeezing them when the panel narrows the card", () => { + const wide = layoutAt(1180).lanes.reduce((n, l) => n + l.labels.length, 0); + const narrow = layoutAt(520).lanes.reduce((n, l) => n + l.labels.length, 0); + expect(narrow).toBeLessThan(wide); + expect(narrow).toBeGreaterThan(0); + }); +}); + +describe("layoutTimeline", () => { + it("leaves an empty lane out entirely", () => { + const layout = layoutAt(1180); + expect(layout.lanes.map((l) => l.key)).not.toContain("labs"); + expect(layout.lanes.map((l) => l.key)).toEqual([ + "life", + "illness", + "allergies", + "medications", + "vaccinations", + "visits", + "documents", + ]); + }); + + it("stacks lanes without gaps and puts the value lines below", () => { + const layout = layoutAt(1180); + for (let i = 1; i < layout.lanes.length; i++) { + expect(layout.lanes[i].top).toBe( + layout.lanes[i - 1].top + layout.lanes[i - 1].height, + ); + } + const last = layout.lanes.at(-1)!; + expect(layout.seriesTop).toBeGreaterThan(last.top + last.height); + expect(layout.series).toHaveLength(3); + }); +}); + +describe("layoutSeries", () => { + it("breaks the line across missing months instead of bridging them", () => { + const window = { from: "2025-10-01", to: "2026-10-31" }; + const scale = createScale(window, 164, 1200); + const [bp] = layoutSeries( + fullTimeline().series.slice(0, 1), + 0, + window, + scale, + ); + expect(bp.path.match(/M/g)).toHaveLength(2); + expect(bp.latest).toBe(131); + }); + + it("keeps an empty series as a row without a line", () => { + const window = { from: "2019-01-01", to: "2019-12-31" }; + const scale = createScale(window, 164, 1200); + const [bp] = layoutSeries( + fullTimeline().series.slice(0, 1), + 0, + window, + scale, + ); + expect(bp.path).toBe(""); + expect(bp.latest).toBeNull(); + }); +}); + +describe("interaction", () => { + it("snaps a click near a mark to the mark's date", () => { + const layout = layoutAt(1180); + const visits = layout.lanes.find((l) => l.key === "visits")!; + const visit = visits.points.find((p) => p.item.id === "v-4")!; + expect(dateAtPointer(layout, visit.x + 3, visit.y + 2)).toBe("2026-01-03"); + }); + + it("falls back to the day under the pointer", () => { + const layout = layoutAt(1180); + const x = layout.scale.xMid("2023-06-15"); + expect(dateAtPointer(layout, x, layout.height - 2)).toBe("2023-06-15"); + }); + + it("steps a day, a month or a year with the zoom, never into the future", () => { + expect(stepSelection("2026-01-03", 1, "quarter", TODAY, "2019-01-01")).toBe( + "2026-01-04", + ); + expect(stepSelection("2026-01-03", -1, "year", TODAY, "2019-01-01")).toBe( + "2025-12-03", + ); + expect(stepSelection("2026-01-03", -1, "all", TODAY, "2019-01-01")).toBe( + "2025-01-03", + ); + expect(stepSelection("2026-10-01", 1, "year", TODAY, "2019-01-01")).toBe( + TODAY, + ); + expect(stepSelection("2019-02-01", -1, "all", TODAY, "2019-01-01")).toBe( + "2019-01-01", + ); + }); + + it("names what happens, starts, ends or runs through the month, not the backdrop", () => { + const entries = itemsInMonth(fullTimeline().lanes, "2026-01-03", TODAY); + const ids = entries.map((e) => e.item.id); + expect(ids).toEqual(["ill-5", "course-3", "v-4"]); + // The winter course neither starts nor ends in January: it runs through. + expect(entries.find((e) => e.item.id === "course-3")?.through).toBe(true); + expect(entries.find((e) => e.item.id === "ill-5")?.through).toBe(false); + // Open since 2019 and 2018: the backdrop of every month, left out. + expect(ids).not.toContain("ill-chronic"); + expect(ids).not.toContain("al-1"); + // An open period that starts in the month is named. + const march2019 = itemsInMonth(fullTimeline().lanes, "2019-03-15", TODAY); + expect(march2019.map((e) => e.item.id)).toContain("ill-chronic"); + }); +}); diff --git a/src/components/timeline/__tests__/value-options.test.ts b/src/components/timeline/__tests__/value-options.test.ts new file mode 100644 index 000000000..d997ac2f6 --- /dev/null +++ b/src/components/timeline/__tests__/value-options.test.ts @@ -0,0 +1,25 @@ +/** + * v1.42 (#613) — the timeline's value selector names each option's owning + * module itself, to keep the signal registry out of its chunk. It must give + * the same answer the registry gives, or a switched-off module's values + * would still be offered. + */ +import { describe, expect, it } from "vitest"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; + +import { VALUE_OPTION_MODULE } from "../timeline-view"; + +describe("value options", () => { + for (const [key, owner] of Object.entries(VALUE_OPTION_MODULE)) { + if (key === "MOOD") continue; + it(`${key} is owned by ${owner ?? "no module"}`, () => { + expect(moduleForMeasurementType(key as MeasurementType)).toBe(owner); + }); + } + + it("ties mood to the mood module", () => { + expect(VALUE_OPTION_MODULE.MOOD).toBe("mood"); + }); +}); diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts new file mode 100644 index 000000000..4c50857ba --- /dev/null +++ b/src/components/timeline/chronicle-model.ts @@ -0,0 +1,303 @@ +/** + * The phone chronicle, as data (v1.42, #613). + * + * Below 768 px the timeline is not squeezed: it becomes a list, newest first, + * grouped by month (or by year), which is also the list form of the chart for + * a screen reader. What runs for years without a start that matters (a + * chronic condition, a standing medication, an allergy) sits once in the + * "Ongoing" block above the list instead of repeating in every month. What + * runs for a while and ends (an episode, a winter course) is drawn as a thin + * rail beside the rows it spans. + * + * Empty stretches are said out loud: "April to August 2025 · no entries" + * rather than months silently skipped, so a gap reads as a gap and not as a + * list that forgot something. + */ +import type { + DayNotableKind, + TimelineItem, + TimelineLane, + TimelineLaneKey, + TimelineResponse, +} from "@/lib/day/contract"; + +import { + dayNumber, + endOfMonth, + monthsBetween, + startOfMonth, +} from "./timeline-dates"; +import { LANE_ORDER, isSpan } from "./timeline-geometry"; + +export type ChronicleGrouping = "month" | "year"; + +/** One line in the chronicle. */ +export type ChronicleEntry = + | { + kind: "item"; + date: string; + lane: TimelineLaneKey; + item: TimelineItem; + /** A point, the start of a period, or its end. */ + role: "point" | "start" | "end"; + /** Length of a closed period in days, on its end line. */ + days: number | null; + } + | { kind: "notable"; date: string; notable: DayNotableKind }; + +/** Lanes whose closed periods are drawn as rails. */ +export const RAIL_LANES = ["illness", "medications"] as const; +export type RailLane = (typeof RAIL_LANES)[number]; + +/** + * How a rail passes a row. The list runs newest first, so a period's end is + * the upper of its two rows: on the end row the rail runs from the middle + * down (`end`), on the start row from the top to the middle (`start`). + */ +export type RailMode = "full" | "start" | "end" | "point"; + +export type ChronicleRails = Partial>; + +export type ChronicleRow = + | { type: "header"; group: string; rails: ChronicleRails } + | { type: "entry"; entry: ChronicleEntry; rails: ChronicleRails } + | { type: "gap"; from: string; to: string; rails: ChronicleRails }; + +export interface StandingChip { + lane: TimelineLaneKey; + id: string; + label: string; + /** More than one of the lane, folded into one chip ("2 allergies"). */ + count: number; +} + +/** The group a date belongs to: `YYYY-MM-01` or `YYYY-01-01`. */ +export function groupOf(date: string, grouping: ChronicleGrouping): string { + return grouping === "month" + ? startOfMonth(date) + : `${date.slice(0, 4)}-01-01`; +} + +function groupEnd(group: string, grouping: ChronicleGrouping): string { + return grouping === "month" + ? endOfMonth(group) + : `${group.slice(0, 4)}-12-31`; +} + +function groupsBetween( + from: string, + to: string, + grouping: ChronicleGrouping, +): string[] { + if (grouping === "month") return monthsBetween(from, to).reverse(); + const out: string[] = []; + for (let y = Number(to.slice(0, 4)); y >= Number(from.slice(0, 4)); y--) { + out.push(`${y}-01-01`); + } + return out; +} + +/** + * The Ongoing block: what has no start (`standing`) and every period still + * open. Allergies fold into one chip when there is more than one. + */ +export function standingChips( + timeline: Pick, +): StandingChip[] { + const chips: StandingChip[] = []; + const allergies: StandingChip[] = []; + const push = (chip: StandingChip) => + chip.lane === "allergies" ? allergies.push(chip) : chips.push(chip); + for (const s of timeline.standing) { + push({ lane: s.lane, id: s.id, label: s.label, count: 1 }); + } + for (const key of LANE_ORDER) { + if (key === "life" || key === "cycle") continue; + const lane = timeline.lanes.find((l) => l.key === key); + for (const item of lane?.items ?? []) { + if (item.open) + push({ lane: key, id: item.id, label: item.label, count: 1 }); + } + } + const ordered = chips.sort( + (a, b) => LANE_ORDER.indexOf(a.lane) - LANE_ORDER.indexOf(b.lane), + ); + if (allergies.length === 1) ordered.push(allergies[0]); + if (allergies.length > 1) { + ordered.push({ + lane: "allergies", + id: "allergies", + label: "", + count: allergies.length, + }); + } + return ordered; +} + +/** Every dated line, newest first. */ +export function chronicleEntries( + timeline: Pick, + today: string, +): ChronicleEntry[] { + const out: ChronicleEntry[] = []; + for (const lane of timeline.lanes) { + for (const item of lane.items) { + if (!isSpan(item)) { + out.push({ + kind: "item", + date: item.start, + lane: lane.key, + item, + role: "point", + days: null, + }); + continue; + } + out.push({ + kind: "item", + date: item.start, + lane: lane.key, + item, + role: "start", + days: null, + }); + if (!item.open && item.end && item.end <= today) { + out.push({ + kind: "item", + date: item.end, + lane: lane.key, + item, + role: "end", + days: dayNumber(item.end) - dayNumber(item.start) + 1, + }); + } + } + } + for (const n of timeline.notable) { + out.push({ kind: "notable", date: n.date, notable: n.kind }); + } + const laneRank = (e: ChronicleEntry) => + e.kind === "item" ? LANE_ORDER.indexOf(e.lane) : LANE_ORDER.length; + return out + .filter((e) => e.date <= today) + .sort((a, b) => + a.date !== b.date + ? a.date < b.date + ? 1 + : -1 + : laneRank(a) - laneRank(b), + ); +} + +/** Closed periods of the rail lanes. */ +function railSpans(lanes: readonly TimelineLane[]) { + const out: Array<{ lane: RailLane; start: string; end: string }> = []; + for (const lane of lanes) { + if (!(RAIL_LANES as readonly string[]).includes(lane.key)) continue; + for (const item of lane.items) { + if (item.open || !item.end || item.end === item.start) continue; + out.push({ + lane: lane.key as RailLane, + start: item.start, + end: item.end, + }); + } + } + return out; +} + +/** How every rail passes a row dated `date`. */ +export function railsAt( + spans: ReturnType, + date: string, +): ChronicleRails { + const rails: ChronicleRails = {}; + for (const lane of RAIL_LANES) { + let upper = false; + let lower = false; + for (const s of spans) { + if (s.lane !== lane || date < s.start || date > s.end) continue; + // The period continues to newer rows (above) unless it ends here, and + // to older rows (below) unless it starts here. + if (date < s.end) upper = true; + if (date > s.start) lower = true; + } + const touches = spans.some( + (s) => s.lane === lane && date >= s.start && date <= s.end, + ); + if (!touches) continue; + rails[lane] = + upper && lower ? "full" : upper ? "start" : lower ? "end" : "point"; + } + return rails; +} + +/** + * The rows: a header per group, its entries, and one gap row for every run + * of groups without an entry. Groups run from `today`'s back to the oldest + * entry, or to `dataFrom` when the record reaches further. + */ +export function buildChronicle( + timeline: Pick, + today: string, + grouping: ChronicleGrouping, +): ChronicleRow[] { + const entries = chronicleEntries(timeline, today); + const spans = railSpans(timeline.lanes); + const oldest = [ + entries.at(-1)?.date, + timeline.range.dataFrom ?? undefined, + ].filter((d): d is string => !!d && d <= today); + if (oldest.length === 0) return []; + const first = oldest.sort()[0]; + + const byGroup = new Map(); + for (const e of entries) { + const g = groupOf(e.date, grouping); + const list = byGroup.get(g); + if (list) list.push(e); + else byGroup.set(g, [e]); + } + + const rows: ChronicleRow[] = []; + let gapFrom: string | null = null; + let gapTo: string | null = null; + const flushGap = () => { + if (gapFrom && gapTo) { + // A period that spans the whole empty stretch keeps its rail through it. + const from = gapFrom; + const to = groupEnd(gapTo, grouping); + const rails: ChronicleRails = {}; + for (const lane of RAIL_LANES) { + if ( + spans.some((s) => s.lane === lane && s.start < from && s.end > to) + ) { + rails[lane] = "full"; + } + } + rows.push({ type: "gap", from: gapFrom, to: gapTo, rails }); + } + gapFrom = gapTo = null; + }; + for (const group of groupsBetween(first, today, grouping)) { + const list = byGroup.get(group); + if (!list) { + // Groups run newest first: the first empty one is the gap's newest end. + gapTo = gapTo ?? group; + gapFrom = group; + continue; + } + flushGap(); + const end = groupEnd(group, grouping); + rows.push({ + type: "header", + group, + rails: railsAt(spans, end < today ? end : today), + }); + for (const entry of list) { + rows.push({ type: "entry", entry, rails: railsAt(spans, entry.date) }); + } + } + flushGap(); + return rows; +} diff --git a/src/components/timeline/life-event-draft.ts b/src/components/timeline/life-event-draft.ts new file mode 100644 index 000000000..d2514303e --- /dev/null +++ b/src/components/timeline/life-event-draft.ts @@ -0,0 +1,146 @@ +/** + * The life-event form's state and its checks (v1.42, #613). + * + * Plain functions, no validator library: the timeline chunk stays light, and + * the rules are the server's own (`lifeEventCreateSchema` in + * `src/lib/day/wire-schemas.ts`), restated so the form can say what is wrong + * before anything is sent. The server checks again; this only spares a + * round trip. + * + * A date is held as a calendar key at the chosen precision: at MONTH it is + * the first of the month, at YEAR the first of January. Switching precision + * realigns both ends, so "September 2023" has exactly one spelling, the one + * the server accepts. + */ +import type { + LifeEventCategory, + LifeEventCreateInput, + LifeEventDTO, + LifeEventPrecision, + LifeEventUpdateInput, +} from "@/lib/day/contract"; +import { LIFE_EVENT_NOTE_MAX, LIFE_EVENT_TITLE_MAX } from "@/lib/day/contract"; +import { isCalendarDateKey } from "@/lib/tz/date-only"; + +export interface LifeEventDraft { + title: string; + category: LifeEventCategory | null; + precision: LifeEventPrecision; + start: string; + /** Null when the event is a moment rather than a period. */ + end: string | null; + note: string; +} + +export type LifeEventDraftErrors = Partial< + Record<"title" | "category" | "start" | "end" | "note", string> +>; + +/** Snap a date to the first day of its month or year. */ +export function alignToPrecision( + key: string, + precision: LifeEventPrecision, +): string { + if (!isCalendarDateKey(key)) return key; + if (precision === "MONTH") return `${key.slice(0, 7)}-01`; + if (precision === "YEAR") return `${key.slice(0, 4)}-01-01`; + return key; +} + +export function emptyDraft(defaultDate: string): LifeEventDraft { + return { + title: "", + category: null, + precision: "DAY", + start: defaultDate, + end: null, + note: "", + }; +} + +export function draftFromEvent(event: LifeEventDTO): LifeEventDraft { + return { + title: event.title ?? "", + category: event.category, + precision: event.precision, + start: event.startDate, + end: event.endDate, + note: event.note ?? "", + }; +} + +/** The draft at a new precision, both ends realigned. */ +export function withPrecision( + draft: LifeEventDraft, + precision: LifeEventPrecision, +): LifeEventDraft { + return { + ...draft, + precision, + start: alignToPrecision(draft.start, precision), + end: draft.end === null ? null : alignToPrecision(draft.end, precision), + }; +} + +/** + * The message keys (under `lifeEvents.errors`) for what keeps the draft from + * saving. An empty object means it can be sent. + */ +export function validateDraft(draft: LifeEventDraft): LifeEventDraftErrors { + const errors: LifeEventDraftErrors = {}; + const title = draft.title.trim(); + if (title.length === 0) errors.title = "titleRequired"; + else if (title.length > LIFE_EVENT_TITLE_MAX) errors.title = "titleTooLong"; + if (draft.category === null) errors.category = "categoryRequired"; + if (!isCalendarDateKey(draft.start)) errors.start = "dateRequired"; + if (draft.end !== null) { + if (!isCalendarDateKey(draft.end)) errors.end = "dateRequired"; + else if (isCalendarDateKey(draft.start) && draft.end < draft.start) { + errors.end = "endBeforeStart"; + } + } + if (draft.note.trim().length > LIFE_EVENT_NOTE_MAX) + errors.note = "noteTooLong"; + return errors; +} + +/** The POST body. Call only with a draft `validateDraft` passed. */ +export function createBody(draft: LifeEventDraft): LifeEventCreateInput { + const note = draft.note.trim(); + return { + category: draft.category as LifeEventCategory, + precision: draft.precision, + startDate: alignToPrecision(draft.start, draft.precision), + endDate: + draft.end === null ? null : alignToPrecision(draft.end, draft.precision), + title: draft.title.trim(), + note: note.length > 0 ? note : null, + }; +} + +/** + * The PATCH body: only what changed. Precision and both dates travel + * together whenever one of them moved, because the server re-checks their + * alignment against each other. + */ +export function updateBody( + before: LifeEventDTO, + draft: LifeEventDraft, +): LifeEventUpdateInput { + const next = createBody(draft); + const body: LifeEventUpdateInput = {}; + if (next.title !== (before.title ?? "")) body.title = next.title; + if (next.category !== before.category) body.category = next.category; + if ((next.note ?? null) !== (before.note ?? null)) + body.note = next.note ?? null; + if ( + next.precision !== before.precision || + next.startDate !== before.startDate || + (next.endDate ?? null) !== before.endDate + ) { + body.precision = next.precision; + body.startDate = next.startDate; + body.endDate = next.endDate ?? null; + } + return body; +} diff --git a/src/components/timeline/life-event-form.lazy.tsx b/src/components/timeline/life-event-form.lazy.tsx new file mode 100644 index 000000000..434b09fff --- /dev/null +++ b/src/components/timeline/life-event-form.lazy.tsx @@ -0,0 +1,26 @@ +"use client"; + +/** + * The life-event form, loaded when the capture picker first opens it + * (v1.42, #613). The picker sits in the shell of every authenticated page; + * this keeps the form and the timeline's date helpers out of that bundle. + * Same arrangement as the quick-entry forms. + */ +import dynamic from "next/dynamic"; + +import { Skeleton } from "@/components/ui/skeleton"; + +function FormSkeleton() { + return ( +
+ + + +
+ ); +} + +export const LifeEventForm = dynamic( + () => import("./life-event-form").then((m) => ({ default: m.LifeEventForm })), + { ssr: false, loading: FormSkeleton }, +); diff --git a/src/components/timeline/life-event-form.tsx b/src/components/timeline/life-event-form.tsx new file mode 100644 index 000000000..203ce7000 --- /dev/null +++ b/src/components/timeline/life-event-form.tsx @@ -0,0 +1,479 @@ +"use client"; + +/** + * The life-event form (v1.42, #613): what happened, its kind, when (to the + * day, the month or only the year), an optional end for a period, an + * optional note. Title and note are encrypted at rest and go to no model; + * the form says so in one line above the actions. + * + * Used in two places: the timeline's own sheet (add and edit) and the + * capture picker's sheet, which hands it a footer slot so the actions sit in + * the sheet's sticky footer like every other quick entry. + */ +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, Loader2, Lock, Plus, Trash2, X } from "lucide-react"; + +import { FieldError } from "@/components/forms/field-error"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { + LIFE_EVENT_CATEGORIES, + LIFE_EVENT_PRECISIONS, + LIFE_EVENT_TITLE_MAX, + type LifeEventCategory, + type LifeEventDTO, + type LifeEventPrecision, +} from "@/lib/day/contract"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +import { + createBody, + draftFromEvent, + emptyDraft, + updateBody, + validateDraft, + withPrecision, + type LifeEventDraft, + type LifeEventDraftErrors, +} from "./life-event-draft"; +import { Segmented } from "./segmented"; +import { formatMonthLong, todayKeyIn } from "./timeline-dates"; +import { useLifeEventMutations } from "./use-timeline"; + +export interface LifeEventFormProps { + /** The event being edited, or null to add one. */ + event?: LifeEventDTO | null; + /** Prefilled start for a new event (a day's capture sheet passes its day). */ + defaultDate?: string; + onSuccess?: () => void; + onCancel?: () => void; + /** Where the action row goes; inline when absent. */ + footerSlot?: HTMLElement | null; +} + +function CategoryChips({ + value, + onChange, + labelledBy, + invalid, +}: { + value: LifeEventCategory | null; + onChange: (next: LifeEventCategory) => void; + labelledBy: string; + invalid: boolean; +}) { + const { t } = useTranslations(); + const { getRadioProps } = useRovingRadioGroup({ + count: LIFE_EVENT_CATEGORIES.length, + selectedIndex: value ? LIFE_EVENT_CATEGORIES.indexOf(value) : -1, + onSelect: (index) => onChange(LIFE_EVENT_CATEGORIES[index]), + }); + return ( +
+ {LIFE_EVENT_CATEGORIES.map((category, index) => { + const checked = value === category; + return ( + + ); + })} +
+ ); +} + +/** A date at the draft's precision: day field, month and year, or year. */ +function PrecisionDate({ + id, + value, + precision, + onChange, + invalid, + describedBy, + today, +}: { + id: string; + value: string; + precision: LifeEventPrecision; + onChange: (next: string) => void; + invalid: boolean; + describedBy?: string; + today: string; +}) { + const { t, locale } = useTranslations(); + const intl = resolveIntlLocale(locale); + const thisYear = Number(today.slice(0, 4)); + const years = Array.from( + { length: thisYear + 2 - 1900 }, + (_, i) => thisYear + 1 - i, + ); + const year = Number(value.slice(0, 4)) || thisYear; + const month = Number(value.slice(5, 7)) || 1; + const pad = (n: number) => String(n).padStart(2, "0"); + + if (precision === "DAY") { + return ( + + ); + } + const yearSelect = ( + + onChange( + precision === "YEAR" + ? `${e.target.value}-01-01` + : `${e.target.value}-${pad(month)}-01`, + ) + } + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + > + {years.map((y) => ( + + ))} + + ); + if (precision === "YEAR") return yearSelect; + return ( +
+ onChange(`${year}-${pad(Number(e.target.value))}-01`)} + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + > + {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ( + + ))} + + {yearSelect} +
+ ); +} + +export function LifeEventForm({ + event = null, + defaultDate, + onSuccess, + onCancel, + footerSlot, +}: LifeEventFormProps) { + const { t } = useTranslations(); + const ids = { + form: useId(), + title: useId(), + category: useId(), + start: useId(), + end: useId(), + note: useId(), + }; + const today = todayKeyIn(undefined); + const [draft, setDraft] = useState(() => + event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today), + ); + const [errors, setErrors] = useState({}); + const [saveError, setSaveError] = useState(null); + const { create, update, remove } = useLifeEventMutations(); + const saving = create.isPending || update.isPending || remove.isPending; + + const set = ( + key: K, + value: LifeEventDraft[K], + ) => setDraft((d) => ({ ...d, [key]: value })); + const fieldError = (field: keyof LifeEventDraftErrors) => + errors[field] ? t(`lifeEvents.errors.${errors[field]}`) : undefined; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (saving) return; + setSaveError(null); + const found = validateDraft(draft); + setErrors(found); + if (Object.keys(found).length > 0) return; + try { + if (event) { + const body = updateBody(event, draft); + if (Object.keys(body).length > 0) { + await update.mutateAsync({ id: event.id, body }); + } + } else { + await create.mutateAsync(createBody(draft)); + } + toastWrittenOutcome("success", t("lifeEvents.saved")); + onSuccess?.(); + } catch (err) { + setSaveError(localizedApiError(err, t, "lifeEvents.saveFailed")); + } + } + + async function destroy() { + if (!event || saving) return; + setSaveError(null); + try { + await remove.mutateAsync(event.id); + toastWrittenOutcome("success", t("lifeEvents.deleted")); + onSuccess?.(); + } catch (err) { + setSaveError(localizedApiError(err, t, "lifeEvents.deleteFailed")); + } + } + + const footer = ( +
+ {event && ( + void destroy()} + pending={remove.isPending} + disabled={saving} + size="sm" + variant="destructive" + className="mr-auto min-h-11 sm:min-h-9" + icon={
+ ); + + return ( +
+
+ + set("title", e.target.value)} + aria-invalid={!!errors.title || undefined} + aria-describedby={errors.title ? `${ids.title}-error` : undefined} + data-slot="life-event-title" + /> + +
+ +
+ + set("category", c)} + labelledBy={ids.category} + invalid={!!errors.category} + /> + +
+ +
+
+ + set("start", v)} + invalid={!!errors.start} + describedBy={errors.start ? `${ids.start}-error` : undefined} + today={today} + /> + +
+
+ + ({ + value: p, + label: t(`lifeEvents.precision.${p}`), + }))} + value={draft.precision} + onChange={(p) => setDraft((d) => withPrecision(d, p))} + label={t("lifeEvents.fields.precision")} + slot="life-event-precision" + stretch + asToggleButtons + /> +
+
+ + {draft.end === null ? ( + + ) : ( +
+
+ + +
+ set("end", v)} + invalid={!!errors.end} + describedBy={errors.end ? `${ids.end}-error` : undefined} + today={today} + /> + +
+ )} + +
+ +