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} + /> + +
+ )} + +
+ +