mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge the timeline into release/v1.42.0
This commit is contained in:
commit
5bd70c29e6
50 files changed
+8256
-54
No files matched your search
@@ -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<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
// 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);
|
||||
|
||||
@@ -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<void> {
|
||||
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 <day>" 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<string, string | null>;
|
||||
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/);
|
||||
});
|
||||
@@ -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<Item> & Pick<Item, "id" | "kind" | "start">): 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<TimelineMockLog> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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}",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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}",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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}",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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}",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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ù."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -427,7 +427,9 @@
|
||||
"workout": "운동",
|
||||
"workoutDescription": "기기가 기록하지 못한 운동",
|
||||
"symptom": "증상",
|
||||
"symptomDescription": "직접 정한 증상과 강도"
|
||||
"symptomDescription": "직접 정한 증상과 강도",
|
||||
"lifeEventDescription": "이사, 출산, 새 직장",
|
||||
"lifeEvent": "삶의 사건"
|
||||
},
|
||||
"cycle": "생리 주기",
|
||||
"labs": "검사",
|
||||
@@ -578,6 +580,7 @@
|
||||
"quickAddMood": "기분 기록",
|
||||
"quickAddMedicationIntake": "복용 기록",
|
||||
"quickAddWorkout": "운동 기록",
|
||||
"quickAddLifeEvent": "삶의 사건 기록",
|
||||
"customizeDashboard": "대시보드 맞춤 설정",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "복용 기록",
|
||||
@@ -12229,8 +12232,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
"subtitle": "몇 년을 한눈에.",
|
||||
"legend": "나란히 보여줄 뿐, 원인과 결과를 뜻하지 않아요.",
|
||||
"subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.",
|
||||
"legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.",
|
||||
"standing": "지속",
|
||||
"startMissing": "시작일 없음",
|
||||
"openDay": "{date} 열기",
|
||||
@@ -12269,16 +12272,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": "취소",
|
||||
@@ -12294,7 +12432,8 @@
|
||||
"endDate": "종료",
|
||||
"note": "메모",
|
||||
"optionalTag": "선택",
|
||||
"notePlaceholder": "나중에 알고 싶은 내용"
|
||||
"notePlaceholder": "나중에 알고 싶은 내용",
|
||||
"removeEnd": "종료일 삭제"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "가족",
|
||||
@@ -12307,6 +12446,21 @@
|
||||
"DAY": "일",
|
||||
"MONTH": "월",
|
||||
"YEAR": "연"
|
||||
},
|
||||
"edit": "삶의 사건 편집",
|
||||
"errors": {
|
||||
"titleRequired": "사건의 이름을 적어 주세요.",
|
||||
"titleTooLong": "최대 120자까지예요.",
|
||||
"categoryRequired": "종류를 골라 주세요.",
|
||||
"dateRequired": "올바른 날짜를 입력해 주세요.",
|
||||
"endBeforeStart": "끝이 시작보다 앞서요.",
|
||||
"noteTooLong": "최대 2000자까지예요."
|
||||
},
|
||||
"saveFailed": "저장하지 못했어요.",
|
||||
"deleteFailed": "삭제하지 못했어요.",
|
||||
"deleteConfirm": {
|
||||
"title": "이 삶의 사건을 삭제할까요?",
|
||||
"body": "제목과 메모가 사라져요."
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
-7
@@ -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",
|
||||
@@ -12229,8 +12232,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}",
|
||||
@@ -12269,16 +12272,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",
|
||||
@@ -12294,7 +12432,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",
|
||||
@@ -12307,6 +12446,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ą."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -79,8 +79,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
*/
|
||||
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
|
||||
timeline: "v1.42 timeline and its readiness inventory (#613)",
|
||||
lifeEvents: "v1.42 life-event form and list (#613)",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 <PageAuthGate />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageAuthGate />}>
|
||||
<TimelineView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,8 @@ describe("<DashboardHeader> — 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("<DashboardHeader> — 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(
|
||||
|
||||
@@ -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")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddLifeEvent && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("lifeEvent")}>
|
||||
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddLifeEvent")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
const [lifeEventFooterEl, setLifeEventFooterEl] =
|
||||
useState<HTMLDivElement | null>(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({
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* v1.42 — a life event (the opt-in timeline module). Mounted only
|
||||
while open, so every opening starts from an empty draft. */}
|
||||
<ResponsiveSheet
|
||||
open={openSheet === "lifeEvent"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("lifeEvents.title")}
|
||||
description={t("lifeEvents.subtitle")}
|
||||
footer={<div ref={setLifeEventFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{openSheet === "lifeEvent" && (
|
||||
<LifeEventForm
|
||||
onSuccess={onClose}
|
||||
onCancel={onClose}
|
||||
footerSlot={lifeEventFooterEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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 → `<ManualWorkoutForm>` (the workouts page's "Log workout")
|
||||
* - Symptom → `<SymptomEntryForm>` (one of the person's own symptoms,
|
||||
* v1.40; rides the illness module)
|
||||
* - Life event → `<LifeEventForm>` (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<Record<CaptureKind, ShareDomain | null>> = {
|
||||
// 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<CaptureKind> = [
|
||||
"mood",
|
||||
"symptom",
|
||||
"workout",
|
||||
"lifeEvent",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -240,6 +254,12 @@ export function CapturePicker({
|
||||
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));
|
||||
|
||||
@@ -249,6 +269,7 @@ export function CapturePicker({
|
||||
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
|
||||
@@ -329,6 +350,14 @@ export function CapturePicker({
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "lifeEvent" && (
|
||||
<LifeEventForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "mood" && (
|
||||
<MoodForm
|
||||
onSuccess={closeForm}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
* A module is enabled unless its key is explicitly `false` — default-on.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -127,6 +129,10 @@ export function ModulesSection() {
|
||||
const { user } = useAuth();
|
||||
const coach = useAiCapability("coach");
|
||||
const queryClient = useQueryClient();
|
||||
// v1.42 (#613) — switching the timeline on opens its readiness inventory:
|
||||
// an honest look at what it can already show, with one link per gap,
|
||||
// before the person goes there. It blocks nothing.
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
const moduleAvailability = user?.moduleAvailability ?? {};
|
||||
@@ -186,6 +192,7 @@ export function ModulesSection() {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
}
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
if (vars.key === "timeline" && vars.enabled) setReadinessOpen(true);
|
||||
},
|
||||
onError: (err) => {
|
||||
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
|
||||
@@ -283,6 +290,10 @@ export function ModulesSection() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TimelineReadinessSheet
|
||||
open={readinessOpen}
|
||||
onOpenChange={setReadinessOpen}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("readiness inventory", () => {
|
||||
it("names every lane with its state and one link per gap", () => {
|
||||
const html = render(<ReadinessInventory readiness={readiness()} />);
|
||||
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(
|
||||
<ReadinessInventory
|
||||
readiness={readiness({
|
||||
lanes: [
|
||||
{
|
||||
key: "labs",
|
||||
status: "carries",
|
||||
count: 2,
|
||||
detail: { key: "somethingNew", params: { count: 2 } },
|
||||
gaps: [{ key: "unknownGap", count: 0, href: "/labs" }],
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(<VerdictMeter readiness={readiness()} />);
|
||||
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(
|
||||
<VerdictMeter readiness={readiness({ verdict: "thin", since: null })} />,
|
||||
);
|
||||
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(
|
||||
<ReadinessCard readiness={readiness()} onDismiss={() => 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(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected="2026-01-03"
|
||||
onOpenDay={() => 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"><button[^>]*bg-muted/);
|
||||
});
|
||||
|
||||
it("shows the month's means beside the month", () => {
|
||||
const html = render(
|
||||
<TimelineChronicle
|
||||
timeline={fullTimeline()}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
onOpenDay={() => 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(
|
||||
<SelectionBar
|
||||
timeline={fullTimeline()}
|
||||
selected="2026-01-03"
|
||||
today={TODAY}
|
||||
onOpenDay={() => 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");
|
||||
});
|
||||
});
|
||||
@@ -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<TimelineItem> & Pick<TimelineItem, "id" | "kind" | "start">,
|
||||
): 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> = {},
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<RailLane, RailMode>>;
|
||||
|
||||
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<TimelineResponse, "lanes" | "standing">,
|
||||
): 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<TimelineResponse, "lanes" | "notable">,
|
||||
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<typeof railSpans>,
|
||||
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<TimelineResponse, "lanes" | "notable" | "range">,
|
||||
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<string, ChronicleEntry[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4" data-slot="life-event-form-loading">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LifeEventForm = dynamic(
|
||||
() => import("./life-event-form").then((m) => ({ default: m.LifeEventForm })),
|
||||
{ ssr: false, loading: FormSkeleton },
|
||||
);
|
||||
@@ -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 (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={labelledBy}
|
||||
aria-invalid={invalid || undefined}
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="life-event-category"
|
||||
>
|
||||
{LIFE_EVENT_CATEGORIES.map((category, index) => {
|
||||
const checked = value === category;
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
data-value={category}
|
||||
onClick={() => onChange(category)}
|
||||
{...getRadioProps(index)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 min-h-11 rounded-full border px-3.5 text-sm font-medium outline-none focus-visible:ring-2 sm:min-h-9",
|
||||
checked
|
||||
? "bg-foreground text-background border-transparent"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t(`lifeEvents.category.${category}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<DateField
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const yearSelect = (
|
||||
<NativeSelect
|
||||
id={precision === "YEAR" ? id : `${id}-year`}
|
||||
aria-label={t("lifeEvents.precision.YEAR")}
|
||||
value={String(year)}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
precision === "YEAR"
|
||||
? `${e.target.value}-01-01`
|
||||
: `${e.target.value}-${pad(month)}-01`,
|
||||
)
|
||||
}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
);
|
||||
if (precision === "YEAR") return yearSelect;
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_6.5rem] gap-2">
|
||||
<NativeSelect
|
||||
id={id}
|
||||
aria-label={t("lifeEvents.precision.MONTH")}
|
||||
value={String(month)}
|
||||
onChange={(e) => onChange(`${year}-${pad(Number(e.target.value))}-01`)}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{formatMonthLong(`2001-${pad(m)}-01`, intl)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{yearSelect}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<LifeEventDraft>(() =>
|
||||
event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today),
|
||||
);
|
||||
const [errors, setErrors] = useState<LifeEventDraftErrors>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const { create, update, remove } = useLifeEventMutations();
|
||||
const saving = create.isPending || update.isPending || remove.isPending;
|
||||
|
||||
const set = <K extends keyof LifeEventDraft>(
|
||||
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 = (
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
{event && (
|
||||
<ConfirmButton
|
||||
label={t("lifeEvents.delete")}
|
||||
title={t("lifeEvents.deleteConfirm.title")}
|
||||
body={t("lifeEvents.deleteConfirm.body")}
|
||||
confirmLabel={t("lifeEvents.delete")}
|
||||
onConfirm={() => void destroy()}
|
||||
pending={remove.isPending}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="mr-auto min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden="true" />}
|
||||
slot="life-event-delete"
|
||||
/>
|
||||
)}
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("lifeEvents.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form={ids.form}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={saving}
|
||||
data-slot="life-event-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : event ? (
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("lifeEvents.save")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
id={ids.form}
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
className="space-y-4"
|
||||
data-slot="life-event-form"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.title} noColon>
|
||||
{t("lifeEvents.fields.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id={ids.title}
|
||||
value={draft.title}
|
||||
maxLength={LIFE_EVENT_TITLE_MAX}
|
||||
autoComplete="off"
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
aria-invalid={!!errors.title || undefined}
|
||||
aria-describedby={errors.title ? `${ids.title}-error` : undefined}
|
||||
data-slot="life-event-title"
|
||||
/>
|
||||
<FieldError id={`${ids.title}-error`} message={fieldError("title")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label id={ids.category} noColon asChild>
|
||||
<span>{t("lifeEvents.fields.category")}</span>
|
||||
</Label>
|
||||
<CategoryChips
|
||||
value={draft.category}
|
||||
onChange={(c) => set("category", c)}
|
||||
labelledBy={ids.category}
|
||||
invalid={!!errors.category}
|
||||
/>
|
||||
<FieldError
|
||||
id={`${ids.category}-error`}
|
||||
message={fieldError("category")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.start} noColon>
|
||||
{t("lifeEvents.fields.when")}
|
||||
</Label>
|
||||
<PrecisionDate
|
||||
id={ids.start}
|
||||
value={draft.start}
|
||||
precision={draft.precision}
|
||||
onChange={(v) => set("start", v)}
|
||||
invalid={!!errors.start}
|
||||
describedBy={errors.start ? `${ids.start}-error` : undefined}
|
||||
today={today}
|
||||
/>
|
||||
<FieldError id={`${ids.start}-error`} message={fieldError("start")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label id={`${ids.start}-precision`} noColon asChild>
|
||||
<span>{t("lifeEvents.fields.precision")}</span>
|
||||
</Label>
|
||||
<Segmented
|
||||
options={LIFE_EVENT_PRECISIONS.map((p) => ({
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.end === null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2 min-h-11 sm:min-h-9"
|
||||
onClick={() => set("end", draft.start)}
|
||||
data-slot="life-event-add-end"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{t("lifeEvents.fields.addEnd")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={ids.end} noColon>
|
||||
{t("lifeEvents.fields.endDate")}
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mr-2 min-h-11 sm:min-h-8"
|
||||
onClick={() => set("end", null)}
|
||||
data-slot="life-event-remove-end"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
{t("lifeEvents.fields.removeEnd")}
|
||||
</Button>
|
||||
</div>
|
||||
<PrecisionDate
|
||||
id={ids.end}
|
||||
value={draft.end}
|
||||
precision={draft.precision}
|
||||
onChange={(v) => set("end", v)}
|
||||
invalid={!!errors.end}
|
||||
describedBy={errors.end ? `${ids.end}-error` : undefined}
|
||||
today={today}
|
||||
/>
|
||||
<FieldError id={`${ids.end}-error`} message={fieldError("end")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ids.note} noColon className="gap-1">
|
||||
{t("lifeEvents.fields.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{t("lifeEvents.fields.optionalTag")}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id={ids.note}
|
||||
rows={3}
|
||||
value={draft.note}
|
||||
placeholder={t("lifeEvents.fields.notePlaceholder")}
|
||||
onChange={(e) => set("note", e.target.value)}
|
||||
aria-invalid={!!errors.note || undefined}
|
||||
aria-describedby={errors.note ? `${ids.note}-error` : undefined}
|
||||
data-slot="life-event-note"
|
||||
/>
|
||||
<FieldError id={`${ids.note}-error`} message={fieldError("note")} />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground flex items-start gap-2 text-xs"
|
||||
data-slot="life-event-privacy"
|
||||
>
|
||||
<Lock className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("lifeEvents.privacy")}</span>
|
||||
</p>
|
||||
|
||||
{saveError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerSlot ? createPortal(footer, footerSlot) : footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The timeline's own life-event sheet (v1.42, #613): add from the header
|
||||
* button or a readiness link, edit from a life event in the chronicle or the
|
||||
* selection bar. A bottom sheet on a phone, a dialog above.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import type { LifeEventDTO } from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { LifeEventForm } from "./life-event-form";
|
||||
|
||||
export function LifeEventSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
event,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
event: LifeEventDTO | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [footer, setFooter] = useState<HTMLDivElement | null>(null);
|
||||
const close = () => onOpenChange(false);
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={event ? t("lifeEvents.edit") : t("lifeEvents.title")}
|
||||
description={t("lifeEvents.subtitle")}
|
||||
contentWidth="lg"
|
||||
footer={<div ref={setFooter} className="flex w-full" />}
|
||||
>
|
||||
{open && (
|
||||
<LifeEventForm
|
||||
// A different event remounts the form with its own draft.
|
||||
key={event?.id ?? "new"}
|
||||
event={event}
|
||||
onSuccess={close}
|
||||
onCancel={close}
|
||||
footerSlot={footer}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The readiness inventory as a card on top of the timeline (v1.42, #613),
|
||||
* while a lane is thin or empty and offers a way to close the gap. One
|
||||
* sentence on what the timeline already carries, then each gap with its one
|
||||
* link. Closing it is remembered on this device; the full inventory stays
|
||||
* one tap away under "Data coverage".
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Layers, X } from "lucide-react";
|
||||
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import type { TimelineReadinessResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { readinessGaps, readinessTally } from "./readiness-model";
|
||||
import { wordKey } from "./readiness-inventory";
|
||||
import { formatMonthYear } from "./timeline-dates";
|
||||
|
||||
export function ReadinessCard({
|
||||
readiness,
|
||||
onDismiss,
|
||||
}: {
|
||||
readiness: TimelineReadinessResponse;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { carrying, total } = readinessTally(readiness);
|
||||
const gaps = readinessGaps(readiness);
|
||||
const summary = readiness.since
|
||||
? t("timeline.readiness.card.summary", {
|
||||
since: formatMonthYear(readiness.since, intl),
|
||||
carrying,
|
||||
total,
|
||||
})
|
||||
: t("timeline.readiness.meter", { carrying, total });
|
||||
|
||||
return (
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="timeline-readiness-card">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
size="sm"
|
||||
icon={Layers}
|
||||
title={t("timeline.readiness.card.title")}
|
||||
right={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="-my-1.5 -mr-1.5 size-11 sm:size-8"
|
||||
aria-label={t("timeline.readiness.card.dismiss")}
|
||||
onClick={onDismiss}
|
||||
data-slot="timeline-readiness-card-dismiss"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm">{summary}</p>
|
||||
<ul className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{gaps.map((gap) => {
|
||||
const line = wordKey(
|
||||
t,
|
||||
tCount,
|
||||
`timeline.readiness.gapLine.${gap.key}`,
|
||||
{ count: gap.count },
|
||||
);
|
||||
const link = wordKey(
|
||||
t,
|
||||
tCount,
|
||||
`timeline.readiness.gap.${gap.key}`,
|
||||
{
|
||||
count: gap.count,
|
||||
},
|
||||
);
|
||||
if (!line || !link) return null;
|
||||
return (
|
||||
<li
|
||||
key={`${gap.lane}-${gap.key}`}
|
||||
className="flex items-center justify-between gap-3 sm:justify-start"
|
||||
>
|
||||
<span className="text-sm">{line}</span>
|
||||
<Link
|
||||
href={gap.href}
|
||||
data-slot="timeline-readiness-gap"
|
||||
data-gap={gap.key}
|
||||
className="hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-0.5 rounded-md px-2 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-2 sm:min-h-8"
|
||||
>
|
||||
{link}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The readiness inventory (v1.42, #613): one row per lane with what it holds
|
||||
* ("carries", "thin", "empty"), exactly one link per gap, and the verdict as
|
||||
* a plain count — never a score. Shown in the sheet that opens when the
|
||||
* module is switched on, and in the timeline's "Data coverage" entry.
|
||||
*
|
||||
* Every word comes from the bundle, keyed by what the server sent. A key the
|
||||
* bundle does not know is left out rather than shown raw.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
Contrast,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineReadinessLane,
|
||||
TimelineReadinessResponse,
|
||||
TimelineReadinessStatus,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { formatMonthYear } from "./timeline-dates";
|
||||
import { orderedReadinessLanes, readinessTally } from "./readiness-model";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
|
||||
|
||||
/** Params with every calendar date worded as "March 2019". */
|
||||
function wordedParams(
|
||||
params: Record<string, string | number>,
|
||||
intl: string,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
out[k] =
|
||||
typeof v === "string" && isCalendarDateKey(v)
|
||||
? formatMonthYear(v, intl)
|
||||
: v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word a key under `base`: through the plural tiers when the bundle has them
|
||||
* and a `count` is given, else as one string; null when the bundle has
|
||||
* neither.
|
||||
*/
|
||||
export function wordKey(
|
||||
t: Translate,
|
||||
tCount: TranslateCount,
|
||||
base: string,
|
||||
params: Record<string, string | number>,
|
||||
): string | null {
|
||||
const count = typeof params.count === "number" ? params.count : null;
|
||||
if (count !== null && t(`${base}Other`) !== `${base}Other`) {
|
||||
return tCount(base, count, params);
|
||||
}
|
||||
const single = t(base, params);
|
||||
return single === base ? null : single;
|
||||
}
|
||||
|
||||
export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) {
|
||||
if (
|
||||
key === "values" ||
|
||||
key === "mood" ||
|
||||
key === "environment" ||
|
||||
key === "life"
|
||||
) {
|
||||
return t(`timeline.readiness.lanes.${key}`);
|
||||
}
|
||||
return t(`timeline.lanes.${key}`);
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<TimelineReadinessStatus, typeof CircleCheck> = {
|
||||
carries: CircleCheck,
|
||||
thin: Contrast,
|
||||
empty: CircleDashed,
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<TimelineReadinessStatus, string> = {
|
||||
carries: "text-success",
|
||||
thin: "text-warning",
|
||||
empty: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export function VerdictMeter({
|
||||
readiness,
|
||||
}: {
|
||||
readiness: Pick<TimelineReadinessResponse, "lanes" | "verdict" | "since">;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { carrying, total } = readinessTally(readiness);
|
||||
const label = t("timeline.readiness.meter", { carrying, total });
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/60 flex flex-col items-start gap-2 rounded-lg px-3.5 py-3 sm:flex-row sm:items-center sm:gap-3"
|
||||
data-slot="timeline-readiness-verdict"
|
||||
data-verdict={readiness.verdict}
|
||||
>
|
||||
<div className="flex shrink-0 gap-1" role="img" aria-label={label}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 w-4 rounded-full",
|
||||
i < carrying ? "bg-foreground" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">
|
||||
{t(`timeline.readiness.verdict.${readiness.verdict}`)}
|
||||
</span>
|
||||
{readiness.verdict === "carries" && (
|
||||
<>
|
||||
{" "}
|
||||
{readiness.since
|
||||
? t("timeline.readiness.carriesDetail", {
|
||||
carrying,
|
||||
total,
|
||||
since: formatMonthYear(readiness.since, intl),
|
||||
})
|
||||
: t("timeline.readiness.meter", { carrying, total })}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadinessInventory({
|
||||
readiness,
|
||||
onNavigate,
|
||||
}: {
|
||||
readiness: TimelineReadinessResponse;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (
|
||||
<ul className="divide-border divide-y" data-slot="timeline-readiness-lanes">
|
||||
{orderedReadinessLanes(readiness).map((lane) => {
|
||||
const Icon = STATUS_ICON[lane.status];
|
||||
const detail =
|
||||
lane.key === "life" && lane.status === "empty"
|
||||
? t("lifeEvents.emptyHint")
|
||||
: lane.detail
|
||||
? wordKey(
|
||||
t,
|
||||
tCount,
|
||||
`timeline.readiness.detail.${lane.detail.key}`,
|
||||
wordedParams(lane.detail.params, intl),
|
||||
)
|
||||
: null;
|
||||
const gap = lane.gaps[0];
|
||||
const gapLabel = gap
|
||||
? wordKey(t, tCount, `timeline.readiness.gap.${gap.key}`, {
|
||||
count: gap.count,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={lane.key}
|
||||
className="grid grid-cols-[1.25rem_1fr_auto] items-start gap-x-3 py-3"
|
||||
data-slot="timeline-readiness-lane"
|
||||
data-lane={lane.key}
|
||||
data-status={lane.status}
|
||||
>
|
||||
<Icon
|
||||
className={cn("mt-0.5 size-4", STATUS_CLASS[lane.status])}
|
||||
aria-label={t(`timeline.readiness.status.${lane.status}`)}
|
||||
role="img"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{laneLabel(t, lane.key)}</p>
|
||||
{detail && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
{gap && gapLabel ? (
|
||||
<Link
|
||||
href={gap.href}
|
||||
onClick={onNavigate}
|
||||
data-slot="timeline-readiness-gap"
|
||||
data-gap={gap.key}
|
||||
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 -my-2 inline-flex min-h-11 items-center gap-0.5 rounded-md px-2 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-2 sm:min-h-9"
|
||||
>
|
||||
{gapLabel}
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* What the readiness inventory shows, decided once (v1.42, #613).
|
||||
*
|
||||
* The server decides each lane's status and the verdict; nothing here
|
||||
* recounts. This module only orders the lanes for reading (what carries,
|
||||
* then what is thin, then what is empty), flattens the gap links, and says
|
||||
* when the dismissible card belongs on the timeline.
|
||||
*/
|
||||
import type {
|
||||
TimelineReadinessLane,
|
||||
TimelineReadinessResponse,
|
||||
TimelineReadinessStatus,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
const STATUS_RANK: Record<TimelineReadinessStatus, number> = {
|
||||
carries: 0,
|
||||
thin: 1,
|
||||
empty: 2,
|
||||
};
|
||||
|
||||
/** Lanes in reading order; the server's order is kept within one status. */
|
||||
export function orderedReadinessLanes(
|
||||
readiness: Pick<TimelineReadinessResponse, "lanes">,
|
||||
): TimelineReadinessLane[] {
|
||||
return readiness.lanes
|
||||
.map((lane, index) => ({ lane, index }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
STATUS_RANK[a.lane.status] - STATUS_RANK[b.lane.status] ||
|
||||
a.index - b.index,
|
||||
)
|
||||
.map(({ lane }) => lane);
|
||||
}
|
||||
|
||||
export interface ReadinessGap {
|
||||
lane: TimelineReadinessLane["key"];
|
||||
key: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** Every gap with its one link, in the lanes' reading order. */
|
||||
export function readinessGaps(
|
||||
readiness: Pick<TimelineReadinessResponse, "lanes">,
|
||||
): ReadinessGap[] {
|
||||
return orderedReadinessLanes(readiness).flatMap((lane) =>
|
||||
lane.gaps.map((gap) => ({ lane: lane.key, ...gap })),
|
||||
);
|
||||
}
|
||||
|
||||
/** How many lanes carry, out of how many are listed. */
|
||||
export function readinessTally(
|
||||
readiness: Pick<TimelineReadinessResponse, "lanes">,
|
||||
): { carrying: number; total: number } {
|
||||
return {
|
||||
carrying: readiness.lanes.filter((l) => l.status === "carries").length,
|
||||
total: readiness.lanes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The card belongs on the timeline while a lane is thin or empty and offers
|
||||
* a way to close the gap, until the person closes the card.
|
||||
*/
|
||||
export function showReadinessCard(
|
||||
readiness: Pick<TimelineReadinessResponse, "lanes"> | undefined,
|
||||
dismissed: boolean,
|
||||
): boolean {
|
||||
if (!readiness || dismissed) return false;
|
||||
return readiness.lanes.some(
|
||||
(lane) => lane.status !== "carries" && lane.gaps.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Storage key for the dismissed card; a per-viewer convenience only. */
|
||||
export const READINESS_CARD_DISMISSED_KEY = "healthlog.timeline.readinessCard";
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The readiness sheet, loaded when it first opens (v1.42, #613). Settings
|
||||
* mounts this, so the Modules page carries none of the timeline's code until
|
||||
* the switch is flipped on. Client-only: the sheet is closed on the server.
|
||||
*/
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const TimelineReadinessSheet = dynamic(
|
||||
() =>
|
||||
import("./readiness-sheet").then((m) => ({
|
||||
default: m.TimelineReadinessSheet,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The readiness sheet (v1.42, #613): opens right after the timeline module
|
||||
* is switched on in Settings, and again from "Data coverage" on the
|
||||
* timeline. It blocks nothing: "Later" closes it, "Open timeline" goes
|
||||
* there, and every gap link goes straight to where that gap is closed.
|
||||
*
|
||||
* Loaded on demand (`readiness-sheet.lazy.tsx`), so Settings carries none of
|
||||
* the timeline's code until the switch is flipped.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { ReadinessInventory, VerdictMeter } from "./readiness-inventory";
|
||||
import { useTimelineReadiness } from "./use-timeline";
|
||||
|
||||
export function TimelineReadinessSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
showOpenTimeline = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** False on the timeline itself, where "open" would go nowhere. */
|
||||
showOpenTimeline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const readiness = useTimelineReadiness(open);
|
||||
const close = () => onOpenChange(false);
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("timeline.readiness.title")}
|
||||
description={t("timeline.readiness.subtitle")}
|
||||
contentWidth="lg"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={close}
|
||||
data-slot="timeline-readiness-later"
|
||||
>
|
||||
{t("timeline.readiness.later")}
|
||||
</Button>
|
||||
{showOpenTimeline && (
|
||||
<Button asChild size="sm" className="min-h-11 sm:min-h-9">
|
||||
<Link
|
||||
href="/timeline"
|
||||
onClick={close}
|
||||
data-slot="timeline-readiness-open"
|
||||
>
|
||||
{t("timeline.readiness.open")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="timeline-readiness-sheet">
|
||||
{readiness.isPending ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
</div>
|
||||
) : readiness.isError ? (
|
||||
<QueryErrorRow
|
||||
message={t("timeline.readiness.loadFailed")}
|
||||
onRetry={() => void readiness.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VerdictMeter readiness={readiness.data} />
|
||||
<ReadinessInventory readiness={readiness.data} onNavigate={close} />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("timeline.readiness.hint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A small segmented choice (zoom, chronicle grouping, life-event precision):
|
||||
* an ARIA radiogroup with one tab stop and arrow keys, through the shared
|
||||
* roving helper. Neutral palette; the chosen segment is raised, not coloured.
|
||||
*
|
||||
* Inside a form sheet it renders as toggle buttons (`aria-pressed`) instead:
|
||||
* the sheets' discard guard reads any checked radio as the person's input,
|
||||
* and a precision that starts on "Day" is a default, not an answer.
|
||||
*/
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
slot,
|
||||
className,
|
||||
stretch = false,
|
||||
asToggleButtons = false,
|
||||
}: {
|
||||
options: ReadonlyArray<SegmentedOption<T>>;
|
||||
value: T;
|
||||
onChange: (next: T) => void;
|
||||
label: string;
|
||||
slot: string;
|
||||
className?: string;
|
||||
stretch?: boolean;
|
||||
asToggleButtons?: boolean;
|
||||
}) {
|
||||
const selectedIndex = options.findIndex((o) => o.value === value);
|
||||
const { getRadioProps } = useRovingRadioGroup({
|
||||
count: options.length,
|
||||
selectedIndex,
|
||||
onSelect: (index) => onChange(options[index].value),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
role={asToggleButtons ? "group" : "radiogroup"}
|
||||
aria-label={label}
|
||||
data-slot={slot}
|
||||
className={cn(
|
||||
"bg-muted inline-flex items-center gap-1 rounded-lg p-1",
|
||||
stretch && "flex w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const checked = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
{...(asToggleButtons
|
||||
? { "aria-pressed": checked }
|
||||
: {
|
||||
role: "radio",
|
||||
"aria-checked": checked,
|
||||
...getRadioProps(index),
|
||||
})}
|
||||
data-value={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center justify-center rounded-md px-3 text-sm font-medium outline-none focus-visible:ring-2",
|
||||
stretch && "flex-1",
|
||||
checked
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The bar under the chart (v1.42, #613): the selected month, every entry
|
||||
* that touches it as a chip (each one a way into its day), the month's
|
||||
* means of the chosen values, and "Open 3 Jan." for the selected day.
|
||||
*
|
||||
* It is also where a label the chart had to leave out is read in full, and
|
||||
* where each keyboard step on the chart is announced (`aria-live`).
|
||||
*/
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
endOfMonth,
|
||||
formatAtPrecision,
|
||||
formatDayMonth,
|
||||
formatMonthYear,
|
||||
monthOf,
|
||||
startOfMonth,
|
||||
} from "./timeline-dates";
|
||||
import { itemsInMonth } from "./timeline-geometry";
|
||||
import { laneDotStyle } from "./timeline-chart";
|
||||
import { formatMonthMeans } from "./series-format";
|
||||
|
||||
export interface SelectionBarProps {
|
||||
timeline: TimelineResponse;
|
||||
selected: string;
|
||||
today: string;
|
||||
onOpenDay: (date: string) => void;
|
||||
onEditLifeEvent?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** The month means of each series for the month of `date`. */
|
||||
export function monthMeans(
|
||||
series: TimelineResponse["series"],
|
||||
date: string,
|
||||
): Array<{ key: string; mean: number; unit: string | null }> {
|
||||
const month = monthOf(date);
|
||||
const out: Array<{ key: string; mean: number; unit: string | null }> = [];
|
||||
for (const s of series) {
|
||||
const inMonth = s.points.filter((p) => monthOf(p.t) === month);
|
||||
if (inMonth.length === 0) continue;
|
||||
const mean = inMonth.reduce((sum, p) => sum + p.mean, 0) / inMonth.length;
|
||||
out.push({ key: s.key, mean, unit: s.unit });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function SelectionBar({
|
||||
timeline,
|
||||
selected,
|
||||
today,
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: SelectionBarProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const entries = itemsInMonth(timeline.lanes, selected, today);
|
||||
const means = monthMeans(timeline.series, selected);
|
||||
const monthFrom = startOfMonth(selected);
|
||||
const monthTo = endOfMonth(selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/60 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-3 py-2.5"
|
||||
data-slot="timeline-selection-bar"
|
||||
data-month={monthOf(selected)}
|
||||
>
|
||||
<span className="text-sm font-semibold" aria-live="polite">
|
||||
{formatMonthYear(selected, intl)}
|
||||
</span>
|
||||
{entries.length === 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("timeline.selection.empty")}
|
||||
</span>
|
||||
)}
|
||||
{entries.map(({ lane, item, through }) => {
|
||||
// A chip opens the day its entry touches inside this month.
|
||||
const day =
|
||||
item.start >= monthFrom && item.start <= monthTo
|
||||
? item.start
|
||||
: monthFrom;
|
||||
const canOpenDay = item.precision === "DAY" && day <= today;
|
||||
const when =
|
||||
item.open || through
|
||||
? t("timeline.selection.ongoing")
|
||||
: item.end && item.end !== item.start
|
||||
? t("timeline.selection.range", {
|
||||
from: formatDayMonth(item.start, intl),
|
||||
to: formatDayMonth(item.end, intl),
|
||||
})
|
||||
: item.precision === "DAY"
|
||||
? formatDayMonth(item.start, intl)
|
||||
: formatAtPrecision(item.start, item.precision, intl);
|
||||
const text = `${item.label}${item.sub ? ` ${item.sub}` : ""} · ${when}`;
|
||||
const editable = item.kind === "lifeEvent" && onEditLifeEvent;
|
||||
if (!canOpenDay && !editable) {
|
||||
return (
|
||||
<span
|
||||
key={`${lane}-${item.id}`}
|
||||
className="bg-background text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={laneDotStyle(lane)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${lane}-${item.id}`}
|
||||
type="button"
|
||||
data-slot="timeline-selection-chip"
|
||||
data-item={item.id}
|
||||
onClick={() =>
|
||||
editable ? onEditLifeEvent(item.id) : onOpenDay(day)
|
||||
}
|
||||
className="bg-background text-foreground text-2xs hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 py-1 font-medium outline-none focus-visible:ring-2 sm:min-h-7"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={laneDotStyle(lane)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{means.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("timeline.selection.mean", {
|
||||
values: formatMonthMeans(means, fmt),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="timeline-open-day"
|
||||
data-date={selected}
|
||||
onClick={() => onOpenDay(selected)}
|
||||
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 ml-auto inline-flex min-h-11 items-center gap-1 rounded-md px-2 text-sm font-medium outline-none focus-visible:ring-2 sm:min-h-8"
|
||||
>
|
||||
{t("timeline.openDay", { date: formatDayMonth(selected, intl) })}
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* How a value line's number reads (v1.42, #613): whole numbers for pressure,
|
||||
* pulse and counts, one decimal for the rest, sleep in hours. The unit is the
|
||||
* server's, already in the person's display unit.
|
||||
*/
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
|
||||
const WHOLE_NUMBER_KEYS: ReadonlySet<string> = new Set([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
|
||||
export function formatSeriesNumber(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): { value: string; unit: string | null } {
|
||||
if (key === "SLEEP_DURATION" && unit === "min") {
|
||||
return { value: fmt.number(value / 60, 1), unit: "h" };
|
||||
}
|
||||
// Glucose arrives in the person's display unit: whole numbers in the
|
||||
// hundreds, one decimal for single digits.
|
||||
const digits =
|
||||
WHOLE_NUMBER_KEYS.has(key) || (key === "BLOOD_GLUCOSE" && value >= 30)
|
||||
? 0
|
||||
: 1;
|
||||
return { value: fmt.number(value, digits), unit };
|
||||
}
|
||||
|
||||
/** "129 mmHg", "82,6 kg", "7,4 h". */
|
||||
export function formatSeriesValue(
|
||||
key: string,
|
||||
value: number,
|
||||
unit: string | null,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): string {
|
||||
const out = formatSeriesNumber(key, value, unit, fmt);
|
||||
return out.unit ? `${out.value} ${out.unit}` : out.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The month means in one short line: systolic and diastolic fold into
|
||||
* "129/82 mmHg", the rest follow as "82,6 kg".
|
||||
*/
|
||||
export function formatMonthMeans(
|
||||
means: ReadonlyArray<{ key: string; mean: number; unit: string | null }>,
|
||||
fmt: Pick<Formatters, "number">,
|
||||
): string {
|
||||
const sys = means.find((m) => m.key === "BLOOD_PRESSURE_SYS");
|
||||
const dia = means.find((m) => m.key === "BLOOD_PRESSURE_DIA");
|
||||
const parts: string[] = [];
|
||||
for (const m of means) {
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_DIA") continue;
|
||||
if (sys && dia && m.key === "BLOOD_PRESSURE_SYS") {
|
||||
parts.push(
|
||||
`${fmt.number(sys.mean, 0)}/${fmt.number(dia.mean, 0)}${sys.unit ? ` ${sys.unit}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
parts.push(formatSeriesValue(m.key, m.mean, m.unit, fmt));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The timeline's lanes and value lines, as one hand-drawn SVG (v1.42, #613).
|
||||
*
|
||||
* Recharts draws every value chart in the app and stays there; it cannot lay
|
||||
* periods and events out in lanes on a shared time axis, so this one surface
|
||||
* is plain SVG, like the mood and intake heatmaps. Nothing is decided here:
|
||||
* `timeline-geometry.ts` places every mark and label, this file paints them.
|
||||
*
|
||||
* Reading it without sight or without a mouse:
|
||||
* - The drawing itself is `aria-hidden`. A visually hidden table beside it
|
||||
* lists every entry with its lane, start and end, and a one-line summary
|
||||
* names the window and the lanes.
|
||||
* - The chart is one tab stop. Left and right move the selection by a day,
|
||||
* a month or a year with the zoom; Home and End jump to the oldest data
|
||||
* and to today; Enter opens the selected day. The selection bar under the
|
||||
* chart announces each move.
|
||||
* - A pointer selects with a click and opens the day with a double click.
|
||||
*
|
||||
* The width comes from a ResizeObserver, so a docked day panel that narrows
|
||||
* the card re-runs the layout: labels that no longer fit are left out, never
|
||||
* overlapped.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
CalendarHeart,
|
||||
FileText,
|
||||
Flag,
|
||||
FlaskConical,
|
||||
Pill,
|
||||
Shield,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
TimelineLaneKey,
|
||||
TimelineResponse,
|
||||
TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
formatAtPrecision,
|
||||
formatMonthShort,
|
||||
formatMonthYear,
|
||||
dayNumber,
|
||||
} from "./timeline-dates";
|
||||
import {
|
||||
AXIS_HEIGHT,
|
||||
LANE_COLOR,
|
||||
dateAtPointer,
|
||||
estimateTextWidth,
|
||||
fitText,
|
||||
gridTicks,
|
||||
isSpan,
|
||||
todayLabelFits,
|
||||
wrapToWidth,
|
||||
layoutTimeline,
|
||||
stepSelection,
|
||||
type PlacedPoint,
|
||||
type PlacedSpan,
|
||||
type TimeWindow,
|
||||
} from "./timeline-geometry";
|
||||
import { formatSeriesValue } from "./series-format";
|
||||
|
||||
export const LANE_ICON: Readonly<Record<TimelineLaneKey, LucideIcon>> = {
|
||||
life: Flag,
|
||||
illness: Thermometer,
|
||||
allergies: Shield,
|
||||
medications: Pill,
|
||||
vaccinations: Syringe,
|
||||
visits: Stethoscope,
|
||||
labs: FlaskConical,
|
||||
documents: FileText,
|
||||
cycle: CalendarHeart,
|
||||
};
|
||||
|
||||
export interface TimelineChartProps {
|
||||
timeline: TimelineResponse;
|
||||
window: TimeWindow;
|
||||
zoom: TimelineZoom;
|
||||
today: string;
|
||||
selected: string | null;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
seriesLabel: (key: string) => string;
|
||||
onSelect: (date: string) => void;
|
||||
onOpenDay: (date: string) => void;
|
||||
}
|
||||
|
||||
/** Observed content width of an element, 0 until measured. */
|
||||
function useElementWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setWidth(el.clientWidth);
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const next = Math.floor(entries[0]?.contentRect.width ?? el.clientWidth);
|
||||
setWidth((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return { ref, width };
|
||||
}
|
||||
|
||||
export function TimelineChart({
|
||||
timeline,
|
||||
window,
|
||||
zoom,
|
||||
today,
|
||||
selected,
|
||||
hiddenLanes,
|
||||
seriesLabel,
|
||||
onSelect,
|
||||
onOpenDay,
|
||||
}: TimelineChartProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { ref, width } = useElementWidth<HTMLDivElement>();
|
||||
|
||||
const lanes = useMemo(
|
||||
() => timeline.lanes.filter((l) => !hiddenLanes.has(l.key)),
|
||||
[timeline.lanes, hiddenLanes],
|
||||
);
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
width > 0
|
||||
? layoutTimeline({
|
||||
width,
|
||||
window,
|
||||
lanes,
|
||||
series: timeline.series,
|
||||
startMissing: t("timeline.startMissing"),
|
||||
today,
|
||||
})
|
||||
: null,
|
||||
[width, window, lanes, timeline.series, t, today],
|
||||
);
|
||||
|
||||
const floor = timeline.range.dataFrom ?? window.from;
|
||||
|
||||
function handleKey(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const current = selected ?? today;
|
||||
let next: string | null = null;
|
||||
if (event.key === "ArrowLeft")
|
||||
next = stepSelection(current, -1, zoom, today, floor);
|
||||
else if (event.key === "ArrowRight")
|
||||
next = stepSelection(current, 1, zoom, today, floor);
|
||||
else if (event.key === "Home") next = floor;
|
||||
else if (event.key === "End") next = today;
|
||||
else if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpenDay(current);
|
||||
return;
|
||||
}
|
||||
if (next !== null) {
|
||||
event.preventDefault();
|
||||
onSelect(next);
|
||||
}
|
||||
}
|
||||
|
||||
function pointerDate(event: React.MouseEvent<SVGSVGElement>): string | null {
|
||||
if (!layout) return null;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
if (x < layout.scale.x0) return null;
|
||||
return dateAtPointer(layout, x, y);
|
||||
}
|
||||
|
||||
const summary = t("timeline.chartLabel", {
|
||||
from: formatMonthYear(window.from, intl),
|
||||
to: formatMonthYear(window.to, intl),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription={t("timeline.title")}
|
||||
aria-label={summary}
|
||||
aria-describedby="timeline-chart-help"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKey}
|
||||
data-slot="timeline-chart"
|
||||
data-width={width}
|
||||
className="focus-visible:ring-ring/50 rounded-lg outline-none focus-visible:ring-2"
|
||||
>
|
||||
<p id="timeline-chart-help" className="sr-only">
|
||||
{t("timeline.keyboardHint")}
|
||||
</p>
|
||||
{layout ? (
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
aria-hidden="true"
|
||||
className="block select-none"
|
||||
onClick={(e) => {
|
||||
const date = pointerDate(e);
|
||||
if (date) onSelect(date);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
const date = pointerDate(e);
|
||||
if (date) onOpenDay(date);
|
||||
}}
|
||||
>
|
||||
<ChartBody
|
||||
layout={layout}
|
||||
window={window}
|
||||
zoom={zoom}
|
||||
today={today}
|
||||
selected={selected}
|
||||
dataFrom={timeline.range.dataFrom}
|
||||
seriesLabel={seriesLabel}
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="h-64" />
|
||||
)}
|
||||
</div>
|
||||
<TimelineTable
|
||||
timeline={timeline}
|
||||
hiddenLanes={hiddenLanes}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChartBodyProps {
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
window: TimeWindow;
|
||||
zoom: TimelineZoom;
|
||||
today: string;
|
||||
selected: string | null;
|
||||
dataFrom: string | null;
|
||||
seriesLabel: (key: string) => string;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: ReturnType<typeof useFormatters>;
|
||||
}
|
||||
|
||||
function ChartBody({
|
||||
layout,
|
||||
window,
|
||||
zoom,
|
||||
today,
|
||||
selected,
|
||||
dataFrom,
|
||||
seriesLabel,
|
||||
intl,
|
||||
t,
|
||||
fmt,
|
||||
}: ChartBodyProps) {
|
||||
const { scale, height, width } = layout;
|
||||
const todayInWindow = today >= window.from && today <= window.to;
|
||||
const todayX = scale.xMid(today);
|
||||
const todayLabel = t("timeline.today");
|
||||
const todayLabelWidth = estimateTextWidth(todayLabel, 11);
|
||||
const ticks = gridTicks(window, zoom, scale, {
|
||||
year: (k) => k.slice(0, 4),
|
||||
monthShort: (k) => formatMonthShort(k, intl),
|
||||
monthYear: (k) => formatMonthYear(k, intl),
|
||||
});
|
||||
const showTodayLabel =
|
||||
todayInWindow && todayLabelFits(ticks, todayX, todayLabelWidth, scale.x0);
|
||||
const noDataUntil =
|
||||
dataFrom && dataFrom > window.from && dataFrom <= window.to
|
||||
? scale.x(dataFrom)
|
||||
: null;
|
||||
const seriesTop = layout.seriesTop;
|
||||
|
||||
return (
|
||||
<g fontFamily="inherit">
|
||||
<defs>
|
||||
<linearGradient id="timeline-fade">
|
||||
<stop offset="0" stopColor="var(--card)" />
|
||||
<stop offset="1" stopColor="var(--card)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{noDataUntil !== null && (
|
||||
<g data-slot="timeline-no-data">
|
||||
<rect
|
||||
x={scale.x0}
|
||||
y={AXIS_HEIGHT}
|
||||
width={Math.max(0, noDataUntil - scale.x0)}
|
||||
height={height - AXIS_HEIGHT}
|
||||
fill="var(--muted)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{wrapToWidth(
|
||||
t("timeline.noDataYet"),
|
||||
noDataUntil - scale.x0 - 12,
|
||||
).map((line, i, lines) => (
|
||||
<text
|
||||
key={line}
|
||||
x={scale.x0 + 8}
|
||||
y={height - 8 - (lines.length - 1 - i) * 13}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Grid and axis labels. */}
|
||||
{ticks.map((tick) => (
|
||||
<g key={tick.key}>
|
||||
<line
|
||||
x1={tick.x}
|
||||
x2={tick.x}
|
||||
y1={tick.major ? AXIS_HEIGHT - 6 : AXIS_HEIGHT}
|
||||
y2={height}
|
||||
stroke="var(--border)"
|
||||
strokeOpacity={tick.major ? 0.9 : 0.45}
|
||||
/>
|
||||
{tick.label && (
|
||||
<text
|
||||
x={tick.x + 6}
|
||||
y={14}
|
||||
fontSize={12}
|
||||
fontWeight={500}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Lanes. */}
|
||||
{layout.lanes.map((lane, index) => {
|
||||
const Icon = LANE_ICON[lane.key];
|
||||
const color = LANE_COLOR[lane.key];
|
||||
return (
|
||||
<g key={lane.key} data-lane={lane.key}>
|
||||
{index > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={lane.top}
|
||||
y2={lane.top}
|
||||
stroke="var(--border)"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
x={0}
|
||||
y={lane.top + 7}
|
||||
width={16}
|
||||
height={16}
|
||||
color="var(--muted-foreground)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={24}
|
||||
y={lane.top + 19}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(t(`timeline.lanes.${lane.key}`), scale.x0 - 30, 13)}
|
||||
</text>
|
||||
{lane.spans.map((span) => (
|
||||
<SpanMark
|
||||
key={span.item.id}
|
||||
span={span}
|
||||
color={color}
|
||||
x0={scale.x0}
|
||||
intl={intl}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{lane.points.map((point) => (
|
||||
<PointMark
|
||||
key={point.item.id}
|
||||
point={point}
|
||||
color={color}
|
||||
intl={intl}
|
||||
/>
|
||||
))}
|
||||
{lane.labels.map((label) => (
|
||||
<text
|
||||
key={`label-${label.itemId}`}
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
fontSize={11}
|
||||
fill={
|
||||
label.strong ? "var(--foreground)" : "var(--muted-foreground)"
|
||||
}
|
||||
>
|
||||
{label.text}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Value lines: neutral, one scale each, never coloured by meaning. */}
|
||||
{layout.series.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={seriesTop - 18}
|
||||
y2={seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{layout.series.map((series) => (
|
||||
<g key={series.key} data-series={series.key}>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{fitText(seriesLabel(series.key), scale.x0 - 12, 13)}
|
||||
</text>
|
||||
<text
|
||||
x={0}
|
||||
y={series.top + 30}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{series.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: formatSeriesValue(series.key, series.latest, series.unit, fmt)}
|
||||
</text>
|
||||
{series.path && (
|
||||
<path
|
||||
d={series.path}
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.6}
|
||||
strokeOpacity={0.85}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{series.points.length === 1 && (
|
||||
<circle
|
||||
cx={series.points[0].x}
|
||||
cy={series.points[0].y}
|
||||
r={2.5}
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Today. */}
|
||||
{todayInWindow && (
|
||||
<g data-slot="timeline-today">
|
||||
<line
|
||||
x1={todayX}
|
||||
x2={todayX}
|
||||
y1={AXIS_HEIGHT - 6}
|
||||
y2={height}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
{showTodayLabel && (
|
||||
<text
|
||||
x={todayX - 4}
|
||||
y={14}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{todayLabel}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Selection. */}
|
||||
{selected && selected >= window.from && selected <= window.to && (
|
||||
<g data-slot="timeline-selection" data-date={selected}>
|
||||
<line
|
||||
x1={scale.xMid(selected)}
|
||||
x2={scale.xMid(selected)}
|
||||
y1={AXIS_HEIGHT - 6}
|
||||
y2={height}
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
<circle
|
||||
cx={scale.xMid(selected)}
|
||||
cy={AXIS_HEIGHT - 6}
|
||||
r={3.5}
|
||||
fill="var(--foreground)"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function spanTitle(
|
||||
span: PlacedSpan,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
const { item } = span;
|
||||
const from = formatAtPrecision(item.start, item.precision, intl);
|
||||
const to = item.open
|
||||
? t("timeline.selection.ongoing")
|
||||
: formatAtPrecision(item.end ?? item.start, item.precision, intl);
|
||||
const name = item.sub ? `${item.label} · ${item.sub}` : item.label;
|
||||
return `${name}: ${t("timeline.selection.range", { from, to })}`;
|
||||
}
|
||||
|
||||
function SpanMark({
|
||||
span,
|
||||
color,
|
||||
x0,
|
||||
intl,
|
||||
t,
|
||||
}: {
|
||||
span: PlacedSpan;
|
||||
color: string;
|
||||
x0: number;
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
}) {
|
||||
const { item, xStart, xEnd, y } = span;
|
||||
const title = spanTitle(span, intl, t);
|
||||
if (span.pause) {
|
||||
return (
|
||||
<g data-kind="pause">
|
||||
<title>{title}</title>
|
||||
<rect
|
||||
x={xStart}
|
||||
y={y - 4.5}
|
||||
width={xEnd - xStart}
|
||||
height={9}
|
||||
rx={2}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const thick = item.open ? 3 : 7;
|
||||
return (
|
||||
<g data-kind={item.kind} data-item={item.id}>
|
||||
<title>{title}</title>
|
||||
{!item.startKnown && (
|
||||
<line
|
||||
x1={Math.max(x0, xStart - 28)}
|
||||
x2={xStart}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeDasharray="3 4"
|
||||
opacity={0.6}
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={xStart}
|
||||
y={y - thick / 2}
|
||||
width={Math.max(xEnd - xStart, 4)}
|
||||
height={thick}
|
||||
rx={thick / 2}
|
||||
fill={color}
|
||||
opacity={item.open ? 0.75 : 0.9}
|
||||
/>
|
||||
{item.open && (
|
||||
<path
|
||||
d={`M${xEnd + 1} ${y - 4} l6 4 l-6 4z`}
|
||||
fill={color}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)}
|
||||
{span.clippedLeft && item.startKnown && (
|
||||
<rect
|
||||
x={x0}
|
||||
y={y - 4}
|
||||
width={22}
|
||||
height={8}
|
||||
fill="url(#timeline-fade)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function PointMark({
|
||||
point,
|
||||
color,
|
||||
intl,
|
||||
}: {
|
||||
point: PlacedPoint;
|
||||
color: string;
|
||||
intl: string;
|
||||
}) {
|
||||
const { x, y, item, shape } = point;
|
||||
const title = `${item.label}${item.sub ? ` · ${item.sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`;
|
||||
let glyph: React.ReactNode;
|
||||
switch (shape) {
|
||||
case "diamond":
|
||||
glyph = (
|
||||
<rect
|
||||
x={x - 5}
|
||||
y={y - 5}
|
||||
width={10}
|
||||
height={10}
|
||||
transform={`rotate(45 ${x} ${y})`}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "square":
|
||||
glyph = (
|
||||
<rect
|
||||
x={x - 4.5}
|
||||
y={y - 4.5}
|
||||
width={9}
|
||||
height={9}
|
||||
rx={2}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "tick":
|
||||
glyph = (
|
||||
<rect
|
||||
x={x - 1}
|
||||
y={y - 6}
|
||||
width={2}
|
||||
height={12}
|
||||
rx={1}
|
||||
fill={color}
|
||||
opacity={0.7}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "ring":
|
||||
glyph = (
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3.5}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "doseMark":
|
||||
glyph = (
|
||||
<line
|
||||
x1={x}
|
||||
x2={x}
|
||||
y1={y - 6}
|
||||
y2={y + 6}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
glyph = (
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill={color}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<g data-kind={item.kind} data-item={item.id}>
|
||||
<title>{title}</title>
|
||||
{glyph}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart as a table, for a screen reader: every entry with its lane and
|
||||
* dates. Visually hidden; the phone chronicle is the visible list form.
|
||||
*/
|
||||
function TimelineTable({
|
||||
timeline,
|
||||
hiddenLanes,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const rows = timeline.lanes
|
||||
.filter((lane) => !hiddenLanes.has(lane.key))
|
||||
.flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item })))
|
||||
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
|
||||
return (
|
||||
<table className="sr-only" data-slot="timeline-table">
|
||||
<caption>{t("timeline.table.caption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.lane")}</th>
|
||||
<th scope="col">{t("timeline.table.entry")}</th>
|
||||
<th scope="col">{t("timeline.table.start")}</th>
|
||||
<th scope="col">{t("timeline.table.end")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ lane, item }) => (
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(`timeline.lanes.${lane}`)}</td>
|
||||
<td>
|
||||
{item.label}
|
||||
{item.sub ? ` · ${item.sub}` : ""}
|
||||
{!item.startKnown ? ` · ${t("timeline.startMissing")}` : ""}
|
||||
</td>
|
||||
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
|
||||
<td>
|
||||
{item.open
|
||||
? t("timeline.selection.ongoing")
|
||||
: isSpan(item) && item.end
|
||||
? formatAtPrecision(item.end, item.precision, intl)
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline style for a lane colour dot in HTML (chips, chronicle rails). */
|
||||
export function laneDotStyle(lane: TimelineLaneKey): React.CSSProperties {
|
||||
return { background: LANE_COLOR[lane] };
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The timeline on a phone (v1.42, #613): an "Ongoing" block that folds away,
|
||||
* then the chronicle, newest first, with the month's means beside each
|
||||
* month, thin rails beside the rows an episode or a course spans, and empty
|
||||
* stretches named rather than skipped. Built from `chronicle-model.ts`.
|
||||
*
|
||||
* A dated row opens its day (the same `?day=` layer the chart hands off to);
|
||||
* a life event opens its edit sheet instead, because the event is the
|
||||
* person's own entry and that is where it is changed.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CalendarHeart,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Flag,
|
||||
FlaskConical,
|
||||
Pill,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { TimelineItemKind, TimelineResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
RAIL_LANES,
|
||||
buildChronicle,
|
||||
standingChips,
|
||||
type ChronicleEntry,
|
||||
type ChronicleGrouping,
|
||||
type ChronicleRails,
|
||||
} from "./chronicle-model";
|
||||
import {
|
||||
formatAtPrecision,
|
||||
formatDayMonth,
|
||||
formatMonthLong,
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { LANE_COLOR } from "./timeline-geometry";
|
||||
import { laneDotStyle } from "./timeline-chart";
|
||||
import { monthMeans } from "./selection-bar";
|
||||
import { formatMonthMeans } from "./series-format";
|
||||
|
||||
const KIND_ICON: Partial<Record<TimelineItemKind, LucideIcon>> = {
|
||||
lifeEvent: Flag,
|
||||
travel: Flag,
|
||||
episode: Thermometer,
|
||||
chronic: Thermometer,
|
||||
allergy: Shield,
|
||||
medication: Pill,
|
||||
course: Pill,
|
||||
doseChange: Pill,
|
||||
pause: Pill,
|
||||
vaccination: Syringe,
|
||||
visit: Stethoscope,
|
||||
procedure: Stethoscope,
|
||||
labDay: FlaskConical,
|
||||
document: FileText,
|
||||
cycle: CalendarHeart,
|
||||
};
|
||||
|
||||
const RAIL_X: Record<(typeof RAIL_LANES)[number], number> = {
|
||||
illness: 6,
|
||||
medications: 13,
|
||||
};
|
||||
|
||||
function Rails({ rails }: { rails: ChronicleRails }) {
|
||||
// One positioned layer over the row, so the rails never take a grid cell.
|
||||
return (
|
||||
<span className="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
{RAIL_LANES.map((lane) => {
|
||||
const mode = rails[lane];
|
||||
if (!mode) return null;
|
||||
const color = LANE_COLOR[lane];
|
||||
const span =
|
||||
mode === "full"
|
||||
? { top: 0, bottom: 0 }
|
||||
: mode === "start"
|
||||
? { top: 0, bottom: "50%" }
|
||||
: mode === "end"
|
||||
? { top: "50%", bottom: 0 }
|
||||
: null;
|
||||
return (
|
||||
<span key={lane}>
|
||||
{span && (
|
||||
<span
|
||||
className="absolute w-[3px] rounded-sm"
|
||||
style={{
|
||||
left: RAIL_X[lane],
|
||||
...span,
|
||||
background: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mode !== "full" && (
|
||||
<span
|
||||
className="absolute size-[7px] rounded-full"
|
||||
style={{
|
||||
left: RAIL_X[lane] - 2,
|
||||
top: "calc(50% - 3.5px)",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineChronicle({
|
||||
timeline,
|
||||
today,
|
||||
grouping,
|
||||
selected,
|
||||
onOpenDay,
|
||||
onEditLifeEvent,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
today: string;
|
||||
grouping: ChronicleGrouping;
|
||||
selected: string | null;
|
||||
onOpenDay: (date: string) => void;
|
||||
onEditLifeEvent: ((id: string) => void) | null;
|
||||
}) {
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const [standingOpen, setStandingOpen] = useState(true);
|
||||
const chips = standingChips(timeline);
|
||||
const rows = buildChronicle(timeline, today, grouping);
|
||||
|
||||
const groupLabel = (group: string) =>
|
||||
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
|
||||
const gapLabel = (from: string, to: string) => {
|
||||
if (from === to)
|
||||
return t("timeline.chronicle.gapSingle", { period: groupLabel(from) });
|
||||
const sameYear = from.slice(0, 4) === to.slice(0, 4);
|
||||
const fromText =
|
||||
grouping === "month" && sameYear
|
||||
? formatMonthLong(from, intl)
|
||||
: groupLabel(from);
|
||||
return t("timeline.chronicle.gap", { from: fromText, to: groupLabel(to) });
|
||||
};
|
||||
|
||||
function entryText(entry: ChronicleEntry): {
|
||||
title: string;
|
||||
meta: string | null;
|
||||
} {
|
||||
if (entry.kind === "notable") {
|
||||
return {
|
||||
title: t(`timeline.chronicle.notable.${entry.notable}`),
|
||||
meta: t("timeline.chronicle.notableMeta"),
|
||||
};
|
||||
}
|
||||
const { item, role } = entry;
|
||||
if (role === "end") {
|
||||
return {
|
||||
title: t("timeline.chronicle.ended", { label: item.label }),
|
||||
meta: entry.days
|
||||
? tCount("timeline.chronicle.afterDays", entry.days)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
const meta = [item.sub, item.startKnown ? null : t("timeline.startMissing")]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return { title: item.label, meta: meta || null };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="timeline-chronicle">
|
||||
{chips.length > 0 && (
|
||||
<Card className="gap-2 py-3" data-slot="timeline-standing">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={standingOpen}
|
||||
aria-controls="timeline-standing-chips"
|
||||
onClick={() => setStandingOpen((v) => !v)}
|
||||
className="focus-visible:ring-ring/50 flex min-h-11 items-center justify-between rounded-md px-4 text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("timeline.standing")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground size-4 motion-safe:transition-transform",
|
||||
standingOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{standingOpen && (
|
||||
<ul
|
||||
id="timeline-standing-chips"
|
||||
className="flex flex-wrap gap-1.5 px-4"
|
||||
>
|
||||
{chips.map((chip) => (
|
||||
<li
|
||||
key={`${chip.lane}-${chip.id}`}
|
||||
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={laneDotStyle(chip.lane)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{chip.count > 1
|
||||
? tCount("timeline.standingAllergies", chip.count)
|
||||
: chip.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ol className="relative" aria-label={t("timeline.table.caption")}>
|
||||
{rows.map((row) => {
|
||||
if (row.type === "gap") {
|
||||
return (
|
||||
<li
|
||||
key={`gap-${row.from}`}
|
||||
data-slot="timeline-chronicle-gap"
|
||||
className="relative pt-2 pl-6"
|
||||
>
|
||||
<Rails rails={row.rails} />
|
||||
<p className="text-muted-foreground border-border rounded-lg border border-dashed px-3 py-2.5 text-xs">
|
||||
{gapLabel(row.from, row.to)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (row.type === "header") {
|
||||
const means =
|
||||
grouping === "month"
|
||||
? monthMeans(timeline.series, row.group)
|
||||
: [];
|
||||
return (
|
||||
<li
|
||||
key={`h-${row.group}`}
|
||||
className="relative flex items-baseline justify-between gap-3 pt-4 pb-1.5 pl-6"
|
||||
data-slot="timeline-chronicle-month"
|
||||
data-group={row.group}
|
||||
>
|
||||
<Rails rails={row.rails} />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{groupLabel(row.group)}
|
||||
</h3>
|
||||
{means.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatMonthMeans(means, fmt)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const { entry } = row;
|
||||
const { title, meta } = entryText(entry);
|
||||
const Icon =
|
||||
entry.kind === "notable"
|
||||
? Sparkles
|
||||
: (KIND_ICON[entry.item.kind] ?? Flag);
|
||||
const precision =
|
||||
entry.kind === "item" ? entry.item.precision : "DAY";
|
||||
const dateText =
|
||||
precision === "DAY"
|
||||
? formatDayMonth(entry.date, intl)
|
||||
: formatAtPrecision(entry.date, precision, intl);
|
||||
const isLifeEvent =
|
||||
entry.kind === "item" && entry.item.kind === "lifeEvent";
|
||||
const action =
|
||||
isLifeEvent && onEditLifeEvent && entry.kind === "item"
|
||||
? () => onEditLifeEvent(entry.item.id)
|
||||
: precision === "DAY"
|
||||
? () => onOpenDay(entry.date)
|
||||
: null;
|
||||
const highlighted = selected === entry.date;
|
||||
const body = (
|
||||
<>
|
||||
<Rails rails={row.rails} />
|
||||
<span className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums">
|
||||
{dateText}
|
||||
</span>
|
||||
<Icon
|
||||
className="text-muted-foreground mt-0.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm leading-5",
|
||||
entry.kind === "notable" ? "font-normal" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{meta && (
|
||||
<span className="text-muted-foreground mt-0.5 block text-xs">
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const rowClass = cn(
|
||||
"relative grid w-full grid-cols-[3.25rem_1rem_1fr] items-start gap-x-2.5 rounded-lg py-2.5 pr-3 pl-6 text-left",
|
||||
highlighted && "bg-muted",
|
||||
);
|
||||
const key =
|
||||
entry.kind === "item"
|
||||
? `${entry.item.id}-${entry.role}`
|
||||
: `notable-${entry.date}-${entry.notable}`;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
data-slot="timeline-chronicle-row"
|
||||
data-date={entry.date}
|
||||
>
|
||||
{action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action}
|
||||
className={cn(
|
||||
rowClass,
|
||||
"hover:bg-accent focus-visible:ring-ring/50 min-h-11 outline-none focus-visible:ring-2",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div className={rowClass}>{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Calendar arithmetic for the timeline (v1.42, #613).
|
||||
*
|
||||
* Every date the timeline handles is a stated calendar date (`YYYY-MM-DD`):
|
||||
* the server cut it in the record's zone already, so nothing here may read it
|
||||
* through the browser's zone again. Dates become whole day numbers since the
|
||||
* epoch (UTC midnight), which keeps the x-scale linear across daylight-saving
|
||||
* changes and makes "one day to the right" exactly `+1`.
|
||||
*
|
||||
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
|
||||
* date never moves by a day for a reader east or west of Greenwich.
|
||||
*/
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
|
||||
export function dayNumber(key: string): number {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
return Math.round(Date.UTC(y, m - 1, d) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** The `YYYY-MM-DD` key of a day number. */
|
||||
export function dayKey(day: number): string {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
|
||||
return new Date(day * MS_PER_DAY).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** `YYYY-MM` of a date key. */
|
||||
export function monthOf(key: string): string {
|
||||
return key.slice(0, 7);
|
||||
}
|
||||
|
||||
/** First day of the month a key falls in. */
|
||||
export function startOfMonth(key: string): string {
|
||||
return `${key.slice(0, 7)}-01`;
|
||||
}
|
||||
|
||||
/** Last day of the month a key falls in. */
|
||||
export function endOfMonth(key: string): string {
|
||||
const [y, m] = key.split("-").map(Number);
|
||||
return dayKey(Math.round(Date.UTC(y, m, 0) / MS_PER_DAY));
|
||||
}
|
||||
|
||||
/**
|
||||
* The key `months` calendar months away, clamped to the target month's
|
||||
* length (31 January plus one month is 28 or 29 February).
|
||||
*/
|
||||
export function addMonths(key: string, months: number): string {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
const target = new Date(Date.UTC(y, m - 1 + months, 1));
|
||||
const lastDay = new Date(
|
||||
Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
target.setUTCDate(Math.min(d, lastDay));
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
|
||||
return target.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
|
||||
export function monthsBetween(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cursor = startOfMonth(from);
|
||||
const last = startOfMonth(to);
|
||||
while (cursor <= last) {
|
||||
out.push(cursor);
|
||||
cursor = addMonths(cursor, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Today's calendar date in an IANA zone, as `YYYY-MM-DD`. */
|
||||
export function todayKeyIn(timeZone: string | undefined, now = new Date()) {
|
||||
try {
|
||||
// `en-CA` formats as YYYY-MM-DD.
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timeZone || undefined,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(now);
|
||||
} catch {
|
||||
// An unknown zone name: the browser's own calendar day is the honest
|
||||
// fallback, not UTC's.
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
}
|
||||
}
|
||||
|
||||
function utcDate(key: string): Date {
|
||||
return new Date(dayNumber(key) * MS_PER_DAY);
|
||||
}
|
||||
|
||||
function format(
|
||||
key: string,
|
||||
intlLocale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(intlLocale, {
|
||||
...options,
|
||||
timeZone: "UTC",
|
||||
}).format(utcDate(key));
|
||||
}
|
||||
|
||||
/** "Januar 2026". */
|
||||
export function formatMonthYear(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
/** "Jan. 2026". */
|
||||
export function formatMonthYearShort(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
/** "3. Jan.". */
|
||||
export function formatDayMonth(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
/** "3. Jan. 2026". */
|
||||
export function formatDayMonthYear(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** "Jan". Axis labels. */
|
||||
export function formatMonthShort(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { month: "short" });
|
||||
}
|
||||
|
||||
/** "Januar". */
|
||||
export function formatMonthLong(key: string, intlLocale: string): string {
|
||||
return format(key, intlLocale, { month: "long" });
|
||||
}
|
||||
|
||||
/**
|
||||
* A date at the precision it was recorded with: "3. Jan. 2026", "Sept.
|
||||
* 2023" or "2019". Life events and stand-in dates use this, so a month the
|
||||
* person gave is never shown as its first day.
|
||||
*/
|
||||
export function formatAtPrecision(
|
||||
key: string,
|
||||
precision: "DAY" | "MONTH" | "YEAR",
|
||||
intlLocale: string,
|
||||
): string {
|
||||
if (precision === "YEAR") return key.slice(0, 4);
|
||||
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
|
||||
return formatDayMonthYear(key, intlLocale);
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* The timeline's geometry, as pure functions (v1.42, #613).
|
||||
*
|
||||
* Everything the SVG draws is decided here, from the server's answer, the
|
||||
* width the card has and the zoom: the time window, the x-scale, the grid,
|
||||
* which row each period sits on, where each label goes and which labels are
|
||||
* left out, and the value lines below. The renderer only paints the result,
|
||||
* so every rule that matters (no label overlaps another label or a bar, no
|
||||
* label runs past the card edge, a span with an unknown start says so) is
|
||||
* provable without a browser.
|
||||
*
|
||||
* Text is never measured in the DOM. Label widths are estimated from the
|
||||
* character count at the label font size, deliberately on the wide side: an
|
||||
* estimate that runs short would let two labels touch, one that runs long
|
||||
* only leaves a label out a little early. A label that is left out is never
|
||||
* lost — the selection bar under the chart names every entry of the selected
|
||||
* month in full.
|
||||
*/
|
||||
import type {
|
||||
TimelineItem,
|
||||
TimelineItemKind,
|
||||
TimelineLane,
|
||||
TimelineLaneKey,
|
||||
TimelineSeries,
|
||||
TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
addMonths,
|
||||
dayKey,
|
||||
dayNumber,
|
||||
endOfMonth,
|
||||
monthsBetween,
|
||||
startOfMonth,
|
||||
} from "./timeline-dates";
|
||||
|
||||
/* ─── Constants ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Height of the axis strip above the first lane. */
|
||||
export const AXIS_HEIGHT = 26;
|
||||
/** Centre of the first row below a lane's top edge. */
|
||||
export const LANE_PAD_TOP = 16;
|
||||
/** Distance between two row centres in one lane. */
|
||||
export const ROW_HEIGHT = 22;
|
||||
/** Last row centre to the lane's bottom edge. */
|
||||
export const LANE_PAD_BOTTOM = 18;
|
||||
/** Gap between the last lane and the first value line. */
|
||||
export const SERIES_GAP = 18;
|
||||
/** Height of one value line, label included. */
|
||||
export const SERIES_HEIGHT = 60;
|
||||
/** Label font size in the lanes, px. */
|
||||
export const LABEL_FONT_PX = 11;
|
||||
/** Estimated advance of one label character at 11 px, on the wide side. */
|
||||
export const LABEL_CHAR_PX = 6.1;
|
||||
/** Horizontal room a label keeps from anything beside it. */
|
||||
export const LABEL_MARGIN = 6;
|
||||
/** Two bars on one row keep at least this much air between them. */
|
||||
export const BAR_GAP = 6;
|
||||
/** Width of the dashed lead-in before a span whose start is unknown. */
|
||||
export const UNKNOWN_START_LEAD = 28;
|
||||
/** A click within this many px of a mark selects the mark's date. */
|
||||
export const HIT_SLOP = 8;
|
||||
|
||||
/** Margin of a label set beside the mark it names. */
|
||||
const BESIDE_MARGIN = 4;
|
||||
|
||||
/** Half the drawn width of each point glyph, for the occupancy map. */
|
||||
const GLYPH_HALF_WIDTH: Record<PointShape, number> = {
|
||||
dot: 4,
|
||||
diamond: 7,
|
||||
square: 4.5,
|
||||
tick: 1,
|
||||
ring: 4,
|
||||
doseMark: 1,
|
||||
};
|
||||
|
||||
/** Lane order, top to bottom: the contract's order. */
|
||||
export const LANE_ORDER: readonly TimelineLaneKey[] = [
|
||||
"life",
|
||||
"illness",
|
||||
"allergies",
|
||||
"medications",
|
||||
"vaccinations",
|
||||
"visits",
|
||||
"labs",
|
||||
"documents",
|
||||
"cycle",
|
||||
];
|
||||
|
||||
/**
|
||||
* One colour per lane, as tokens. Never the only signal: every lane has its
|
||||
* name and icon at the left, and every kind its own shape.
|
||||
*/
|
||||
export const LANE_COLOR: Readonly<Record<TimelineLaneKey, string>> = {
|
||||
life: "var(--foreground)",
|
||||
illness: "var(--chart-5)",
|
||||
allergies: "var(--chart-3)",
|
||||
medications: "var(--chart-1)",
|
||||
vaccinations: "var(--chart-2)",
|
||||
visits: "var(--chart-4)",
|
||||
labs: "var(--chart-4)",
|
||||
documents: "var(--muted-foreground)",
|
||||
cycle: "var(--chart-3)",
|
||||
};
|
||||
|
||||
/* ─── Window and scale ────────────────────────────────────────────────────── */
|
||||
|
||||
/** An inclusive range of calendar dates. */
|
||||
export interface TimeWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window a zoom shows.
|
||||
*
|
||||
* `all` follows the server's range (it knows where the record begins); the
|
||||
* fallback before an answer is the current and the previous year. `year` and
|
||||
* `quarter` are 12 and 3 whole months that keep `anchor` (the selected day)
|
||||
* in view, end no later than the month of `today`, and so never open onto an
|
||||
* empty future.
|
||||
*/
|
||||
export function windowFor(
|
||||
zoom: TimelineZoom,
|
||||
today: string,
|
||||
anchor: string | null,
|
||||
serverRange?: { from: string; to: string } | null,
|
||||
): TimeWindow {
|
||||
if (zoom === "all") {
|
||||
if (serverRange) return { from: serverRange.from, to: serverRange.to };
|
||||
const year = Number(today.slice(0, 4));
|
||||
return { from: `${year - 1}-01-01`, to: `${year}-12-31` };
|
||||
}
|
||||
const months = zoom === "year" ? 12 : 3;
|
||||
const ahead = zoom === "year" ? 5 : 1;
|
||||
const base = anchor && anchor <= today ? anchor : today;
|
||||
const lastMonth = startOfMonth(addMonths(startOfMonth(base), ahead));
|
||||
const capped =
|
||||
lastMonth > startOfMonth(today) ? startOfMonth(today) : lastMonth;
|
||||
return {
|
||||
from: addMonths(capped, -(months - 1)),
|
||||
to: endOfMonth(capped),
|
||||
};
|
||||
}
|
||||
|
||||
/** A linear map from calendar days to x, over `[from, to + 1 day)`. */
|
||||
export interface Scale {
|
||||
readonly x0: number;
|
||||
readonly x1: number;
|
||||
readonly fromDay: number;
|
||||
readonly toDay: number;
|
||||
/** x of the start of a day (a key or a day number). */
|
||||
x: (day: string | number) => number;
|
||||
/** x of the middle of a day. */
|
||||
xMid: (day: string | number) => number;
|
||||
/** The day under an x position, clamped into the window. */
|
||||
dayAt: (x: number) => string;
|
||||
}
|
||||
|
||||
export function createScale(window: TimeWindow, x0: number, x1: number) {
|
||||
const fromDay = dayNumber(window.from);
|
||||
const toDay = dayNumber(window.to) + 1;
|
||||
const span = Math.max(1, toDay - fromDay);
|
||||
const width = Math.max(1, x1 - x0);
|
||||
const toNumber = (d: string | number) =>
|
||||
typeof d === "number" ? d : dayNumber(d);
|
||||
const x = (d: string | number) =>
|
||||
x0 + ((toNumber(d) - fromDay) / span) * width;
|
||||
return {
|
||||
x0,
|
||||
x1,
|
||||
fromDay,
|
||||
toDay,
|
||||
x,
|
||||
xMid: (d: string | number) => x(toNumber(d) + 0.5),
|
||||
dayAt: (px: number) => {
|
||||
const raw = Math.floor(fromDay + ((px - x0) / width) * span);
|
||||
return dayKey(Math.min(toDay - 1, Math.max(fromDay, raw)));
|
||||
},
|
||||
} satisfies Scale;
|
||||
}
|
||||
|
||||
/* ─── Text ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Estimated width of a label at `fontPx`. */
|
||||
export function estimateTextWidth(text: string, fontPx = LABEL_FONT_PX) {
|
||||
return text.length * LABEL_CHAR_PX * (fontPx / LABEL_FONT_PX);
|
||||
}
|
||||
|
||||
/** Occupied horizontal intervals on one text or bar line. */
|
||||
export class LineOccupancy {
|
||||
private readonly lines = new Map<string, Array<[number, number]>>();
|
||||
|
||||
/** True when `[a, b]` keeps `margin` from everything on `line`. */
|
||||
fits(line: string, a: number, b: number, margin = LABEL_MARGIN): boolean {
|
||||
for (const [c, d] of this.lines.get(line) ?? []) {
|
||||
if (a < d + margin && b > c - margin) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
add(line: string, a: number, b: number): void {
|
||||
const list = this.lines.get(line);
|
||||
if (list) list.push([a, b]);
|
||||
else this.lines.set(line, [[a, b]]);
|
||||
}
|
||||
|
||||
/** Place `[a, b]` on `line` if it fits; report whether it did. */
|
||||
tryAdd(line: string, a: number, b: number, margin = LABEL_MARGIN) {
|
||||
if (!this.fits(line, a, b, margin)) return false;
|
||||
this.add(line, a, b);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `text` cut to fit `maxWidth` at `fontPx`, with an ellipsis when it had to
|
||||
* be cut. Lane and value names sit in a fixed column; a long translation
|
||||
* must not run into the plot.
|
||||
*/
|
||||
export function fitText(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
fontPx = LABEL_FONT_PX,
|
||||
) {
|
||||
if (estimateTextWidth(text, fontPx) <= maxWidth) return text;
|
||||
const perChar = LABEL_CHAR_PX * (fontPx / LABEL_FONT_PX);
|
||||
const keep = Math.max(1, Math.floor(maxWidth / perChar) - 1);
|
||||
return `${text.slice(0, keep).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Words of `text` broken into lines that each fit `maxWidth`, or no lines
|
||||
* at all when a single word does not fit: a note that cannot be read whole
|
||||
* is better left out.
|
||||
*/
|
||||
export function wrapToWidth(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
fontPx = LABEL_FONT_PX,
|
||||
) {
|
||||
const lines: string[] = [];
|
||||
for (const word of text.split(/\s+/)) {
|
||||
const last = lines.at(-1);
|
||||
if (
|
||||
last !== undefined &&
|
||||
estimateTextWidth(`${last} ${word}`, fontPx) <= maxWidth
|
||||
) {
|
||||
lines[lines.length - 1] = `${last} ${word}`;
|
||||
} else {
|
||||
if (estimateTextWidth(word, fontPx) > maxWidth) return [];
|
||||
lines.push(word);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/* ─── Grid ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface GridTick {
|
||||
key: string;
|
||||
x: number;
|
||||
/** Text above the grid line, or null for an unlabelled line. */
|
||||
label: string | null;
|
||||
/** Year lines (all), month lines (year, quarter) are major. */
|
||||
major: boolean;
|
||||
}
|
||||
|
||||
export interface GridFormat {
|
||||
year: (key: string) => string;
|
||||
monthShort: (key: string) => string;
|
||||
monthYear: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical grid lines and their axis labels. A label that would touch its
|
||||
* neighbour (or the "today" label at the right) is dropped; the line stays.
|
||||
*/
|
||||
export function gridTicks(
|
||||
window: TimeWindow,
|
||||
zoom: TimelineZoom,
|
||||
scale: Scale,
|
||||
fmt: GridFormat,
|
||||
): GridTick[] {
|
||||
const ticks: GridTick[] = [];
|
||||
const months = monthsBetween(window.from, window.to);
|
||||
if (zoom === "all") {
|
||||
const pxPerYear = (scale.x1 - scale.x0) / Math.max(1, months.length / 12);
|
||||
for (const m of months) {
|
||||
if (m < window.from) continue;
|
||||
const month = Number(m.slice(5, 7));
|
||||
if (month === 1) {
|
||||
ticks.push({ key: m, x: scale.x(m), label: fmt.year(m), major: true });
|
||||
} else if (
|
||||
pxPerYear >= 240 &&
|
||||
(month === 4 || month === 7 || month === 10)
|
||||
) {
|
||||
ticks.push({ key: m, x: scale.x(m), label: null, major: false });
|
||||
}
|
||||
}
|
||||
} else if (zoom === "year") {
|
||||
for (const m of months) {
|
||||
if (m < window.from) continue;
|
||||
const january = m.slice(5, 7) === "01";
|
||||
ticks.push({
|
||||
key: m,
|
||||
x: scale.x(m),
|
||||
label: january ? fmt.year(m) : fmt.monthShort(m),
|
||||
major: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const m of months) {
|
||||
if (m < window.from) continue;
|
||||
ticks.push({
|
||||
key: m,
|
||||
x: scale.x(m),
|
||||
label: fmt.monthYear(m),
|
||||
major: true,
|
||||
});
|
||||
}
|
||||
// Weekly minor lines, on Mondays.
|
||||
const fromDay = dayNumber(window.from);
|
||||
const toDay = dayNumber(window.to);
|
||||
// 1970-01-01 was a Thursday: day numbers with (n + 3) % 7 === 0 are Mondays.
|
||||
for (let d = fromDay; d <= toDay; d++) {
|
||||
if ((d + 3) % 7 !== 0) continue;
|
||||
const key = dayKey(d);
|
||||
if (key.endsWith("-01")) continue;
|
||||
ticks.push({ key, x: scale.x(d), label: null, major: false });
|
||||
}
|
||||
ticks.sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
// Labels sit right of their line; drop the ones that would collide.
|
||||
const occupancy = new LineOccupancy();
|
||||
return ticks.map((tick) => {
|
||||
if (tick.label === null) return tick;
|
||||
const a = tick.x + 6;
|
||||
const b = a + estimateTextWidth(tick.label, 12);
|
||||
if (b > scale.x1 || !occupancy.tryAdd("axis", a, b, 8)) {
|
||||
return { ...tick, label: null };
|
||||
}
|
||||
return tick;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "Today" label (right-aligned to the today line) fits on the
|
||||
* axis: inside the plot and clear of every year or month label. The axis
|
||||
* labels win; the dashed line says "today" on its own.
|
||||
*/
|
||||
export function todayLabelFits(
|
||||
ticks: readonly GridTick[],
|
||||
todayX: number,
|
||||
labelWidth: number,
|
||||
x0: number,
|
||||
): boolean {
|
||||
const a = todayX - 4 - labelWidth;
|
||||
const b = todayX - 4;
|
||||
if (a < x0) return false;
|
||||
return ticks.every((tick) => {
|
||||
if (!tick.label) return true;
|
||||
const ta = tick.x + 6;
|
||||
const tb = ta + estimateTextWidth(tick.label, 12);
|
||||
return b + 6 <= ta || a >= tb + 6;
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Lanes ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Point shapes, one per kind; colour is never the only difference. */
|
||||
export type PointShape =
|
||||
"dot" | "diamond" | "square" | "tick" | "ring" | "doseMark";
|
||||
|
||||
const POINT_SHAPE: Partial<Record<TimelineItemKind, PointShape>> = {
|
||||
lifeEvent: "diamond",
|
||||
vaccination: "dot",
|
||||
visit: "dot",
|
||||
procedure: "square",
|
||||
labDay: "ring",
|
||||
document: "tick",
|
||||
doseChange: "doseMark",
|
||||
};
|
||||
|
||||
export interface PlacedSpan {
|
||||
item: TimelineItem;
|
||||
row: number;
|
||||
y: number;
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
/** Started before the window: drawn with a fade at the left edge. */
|
||||
clippedLeft: boolean;
|
||||
/** A pause, drawn as an outlined gap over the period it interrupts. */
|
||||
pause: boolean;
|
||||
}
|
||||
|
||||
export interface PlacedPoint {
|
||||
item: TimelineItem;
|
||||
row: number;
|
||||
y: number;
|
||||
x: number;
|
||||
shape: PointShape;
|
||||
}
|
||||
|
||||
export interface PlacedLabel {
|
||||
itemId: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** The person's own life events read as content; other labels as meta. */
|
||||
strong: boolean;
|
||||
}
|
||||
|
||||
export interface LaneLayout {
|
||||
key: TimelineLaneKey;
|
||||
top: number;
|
||||
height: number;
|
||||
rows: number;
|
||||
spans: PlacedSpan[];
|
||||
points: PlacedPoint[];
|
||||
labels: PlacedLabel[];
|
||||
}
|
||||
|
||||
/** A span is anything with an end or still open; the rest are points. */
|
||||
export function isSpan(item: TimelineItem): boolean {
|
||||
return item.open || (item.end !== null && item.end !== item.start);
|
||||
}
|
||||
|
||||
function inWindow(item: TimelineItem, window: TimeWindow): boolean {
|
||||
if (item.start > window.to) return false;
|
||||
if (isSpan(item)) {
|
||||
return item.open || (item.end ?? item.start) >= window.from;
|
||||
}
|
||||
return item.start >= window.from;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible text of an item: its label, the dose or second line when it
|
||||
* has one, and the unknown-start note.
|
||||
*/
|
||||
export function itemLabel(item: TimelineItem, startMissing: string): string {
|
||||
const parts = [item.label];
|
||||
if (item.sub && item.kind !== "doseChange") parts.push(item.sub);
|
||||
if (!item.startKnown) parts.push(startMissing);
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
interface LaneInput {
|
||||
lane: TimelineLane;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out one lane: rows for the periods (first row that is free), points on
|
||||
* their own row above them, pauses and dose marks over the medication they
|
||||
* belong to, then the labels in priority order through one occupancy map.
|
||||
*/
|
||||
export function layoutLane(
|
||||
{ lane, top }: LaneInput,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
options: { startMissing: string; today: string },
|
||||
): LaneLayout {
|
||||
const visible = lane.items.filter((item) => inWindow(item, window));
|
||||
const pointItems = visible.filter(
|
||||
(item) => !isSpan(item) && item.kind !== "doseChange",
|
||||
);
|
||||
const doseItems = visible.filter((item) => item.kind === "doseChange");
|
||||
const pauseItems = visible.filter(
|
||||
(item) => item.kind === "pause" && isSpan(item),
|
||||
);
|
||||
const spanItems = visible
|
||||
.filter((item) => isSpan(item) && item.kind !== "pause")
|
||||
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
|
||||
|
||||
// Points take the first row(s); procedures sit on a second point row so
|
||||
// their names never cover the plain visits.
|
||||
const hasProcedures = pointItems.some((p) => p.kind === "procedure");
|
||||
const hasPlainPoints = pointItems.some((p) => p.kind !== "procedure");
|
||||
const pointRows = (hasPlainPoints ? 1 : 0) + (hasProcedures ? 1 : 0);
|
||||
const procedureRow = hasPlainPoints ? 1 : 0;
|
||||
|
||||
const rowY = (row: number) => top + LANE_PAD_TOP + row * ROW_HEIGHT;
|
||||
const todayX = scale.x(dayNumber(options.today) + 1);
|
||||
|
||||
// Period packing: the first row whose last bar ended before this one.
|
||||
const rowEnds: number[] = [];
|
||||
const spans: PlacedSpan[] = [];
|
||||
for (const item of spanItems) {
|
||||
const rawStart = scale.x(item.start);
|
||||
const clippedLeft = rawStart < scale.x0;
|
||||
const xStart = Math.max(scale.x0, rawStart);
|
||||
const lead = item.startKnown ? 0 : UNKNOWN_START_LEAD;
|
||||
const endX = item.end
|
||||
? scale.x(dayNumber(item.end) + 1)
|
||||
: Math.min(scale.x1, todayX);
|
||||
const xEnd = Math.min(scale.x1, Math.max(xStart + 4, endX));
|
||||
let row = rowEnds.findIndex((end) => end + BAR_GAP <= xStart - lead);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(xEnd);
|
||||
} else {
|
||||
rowEnds[row] = xEnd;
|
||||
}
|
||||
spans.push({
|
||||
item,
|
||||
row: row + pointRows,
|
||||
y: rowY(row + pointRows),
|
||||
xStart,
|
||||
xEnd,
|
||||
clippedLeft,
|
||||
pause: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Pauses lie over the period they interrupt; one without a host gets a row.
|
||||
for (const item of pauseItems) {
|
||||
const xStart = Math.max(scale.x0, scale.x(item.start));
|
||||
const xEnd = Math.min(
|
||||
scale.x1,
|
||||
item.end ? scale.x(dayNumber(item.end) + 1) : todayX,
|
||||
);
|
||||
const host = spans.find(
|
||||
(s) => !s.pause && s.xStart <= xStart && s.xEnd >= xStart,
|
||||
);
|
||||
const row = host ? host.row : pointRows + rowEnds.length;
|
||||
if (!host) rowEnds.push(xEnd);
|
||||
spans.push({
|
||||
item,
|
||||
row,
|
||||
y: rowY(row),
|
||||
xStart,
|
||||
xEnd: Math.max(xStart + 4, xEnd),
|
||||
clippedLeft: false,
|
||||
pause: true,
|
||||
});
|
||||
}
|
||||
|
||||
const points: PlacedPoint[] = [];
|
||||
for (const item of pointItems) {
|
||||
const row = item.kind === "procedure" ? procedureRow : 0;
|
||||
points.push({
|
||||
item,
|
||||
row,
|
||||
y: rowY(row),
|
||||
x: scale.xMid(item.start),
|
||||
shape: POINT_SHAPE[item.kind] ?? "dot",
|
||||
});
|
||||
}
|
||||
// Dose marks sit on the medication line that runs through their date.
|
||||
for (const item of doseItems) {
|
||||
const x = scale.xMid(item.start);
|
||||
const host = spans.find((s) => !s.pause && s.xStart <= x && s.xEnd >= x);
|
||||
const row = host ? host.row : pointRows;
|
||||
if (!host && rowEnds.length === 0) rowEnds.push(x);
|
||||
points.push({ item, row, y: rowY(row), x, shape: "doseMark" });
|
||||
}
|
||||
|
||||
const rows = Math.max(1, pointRows + rowEnds.length);
|
||||
const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM;
|
||||
|
||||
const labels = placeLabels(spans, points, scale, options.startMissing);
|
||||
return { key: lane.key, top, height, rows, spans, points, labels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels through one occupancy map per text line. Bars and point glyphs are
|
||||
* registered first as obstacles, so a label never covers a mark either.
|
||||
*
|
||||
* Priority: open periods (they define the lane), life events, procedures,
|
||||
* closed periods from the longest down, dose marks.
|
||||
*/
|
||||
function placeLabels(
|
||||
spans: PlacedSpan[],
|
||||
points: PlacedPoint[],
|
||||
scale: Scale,
|
||||
startMissing: string,
|
||||
): PlacedLabel[] {
|
||||
const occ = new LineOccupancy();
|
||||
const line = (row: number, where: "on" | "above") => `${row}:${where}`;
|
||||
for (const s of spans) {
|
||||
const lead = s.item.startKnown ? 0 : UNKNOWN_START_LEAD;
|
||||
// An open period ends in a small arrow head past its last day.
|
||||
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + (s.item.open ? 7 : 0));
|
||||
}
|
||||
for (const p of points) {
|
||||
if (p.shape === "doseMark") continue;
|
||||
const half = GLYPH_HALF_WIDTH[p.shape];
|
||||
occ.add(line(p.row, "on"), p.x - half, p.x + half);
|
||||
}
|
||||
|
||||
const labels: PlacedLabel[] = [];
|
||||
const fitsCard = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2;
|
||||
|
||||
const openSpans = spans.filter((s) => s.item.open && !s.pause);
|
||||
const closedSpans = spans
|
||||
.filter((s) => !s.item.open && !s.pause)
|
||||
.sort((a, b) => b.xEnd - b.xStart - (a.xEnd - a.xStart));
|
||||
const pausesWithLabel = spans.filter((s) => s.pause);
|
||||
|
||||
const aboveLabel = (s: PlacedSpan, text: string) => {
|
||||
const a = s.xStart + (s.clippedLeft ? 26 : 2);
|
||||
const b = a + estimateTextWidth(text);
|
||||
if (!fitsCard(a, b)) return false;
|
||||
if (!occ.tryAdd(line(s.row, "above"), a, b)) return false;
|
||||
labels.push({ itemId: s.item.id, text, x: a, y: s.y - 7, strong: false });
|
||||
return true;
|
||||
};
|
||||
const rightLabel = (
|
||||
id: string,
|
||||
row: number,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
strong: boolean,
|
||||
) => {
|
||||
const a = x;
|
||||
const b = a + estimateTextWidth(text);
|
||||
if (!fitsCard(a, b)) return false;
|
||||
// The label already keeps its own distance from the mark it names, so a
|
||||
// smaller margin here; the full margin still separates it from the next
|
||||
// mark because that mark's own box is wider than this gap.
|
||||
if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false;
|
||||
labels.push({ itemId: id, text, x: a, y: y + 4, strong });
|
||||
return true;
|
||||
};
|
||||
|
||||
const leftLabel = (
|
||||
id: string,
|
||||
row: number,
|
||||
xRight: number,
|
||||
y: number,
|
||||
text: string,
|
||||
strong: boolean,
|
||||
) => {
|
||||
const b = xRight;
|
||||
const a = b - estimateTextWidth(text);
|
||||
if (!fitsCard(a, b)) return false;
|
||||
if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false;
|
||||
labels.push({ itemId: id, text, x: a, y: y + 4, strong });
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const s of openSpans) aboveLabel(s, itemLabel(s.item, startMissing));
|
||||
// A point's name starts just clear of its own glyph.
|
||||
const besideGlyph = (p: PlacedPoint) =>
|
||||
p.x + GLYPH_HALF_WIDTH[p.shape] + BESIDE_MARGIN + 1;
|
||||
const leftOfGlyph = (p: PlacedPoint) =>
|
||||
p.x - GLYPH_HALF_WIDTH[p.shape] - BESIDE_MARGIN - 1;
|
||||
for (const kind of ["lifeEvent", "procedure"] as const) {
|
||||
const strong = kind === "lifeEvent";
|
||||
for (const p of points.filter((p) => p.item.kind === kind)) {
|
||||
if (
|
||||
rightLabel(p.item.id, p.row, besideGlyph(p), p.y, p.item.label, strong)
|
||||
)
|
||||
continue;
|
||||
leftLabel(p.item.id, p.row, leftOfGlyph(p), p.y, p.item.label, strong);
|
||||
}
|
||||
}
|
||||
// A period's name goes right of its bar, else above it, else left of it.
|
||||
// A name already shown on the same row (a winter course every year) is
|
||||
// shown once: the repeats read as the same thing again.
|
||||
const shownOnRow = new Set<string>();
|
||||
for (const s of closedSpans.sort((a, b) => a.xStart - b.xStart)) {
|
||||
const text = itemLabel(s.item, startMissing);
|
||||
const rowKey = `${s.row}:${text}`;
|
||||
if (shownOnRow.has(rowKey)) continue;
|
||||
const strong = s.item.kind === "lifeEvent";
|
||||
const placed =
|
||||
rightLabel(s.item.id, s.row, s.xEnd + 6, s.y, text, strong) ||
|
||||
aboveLabel(s, text) ||
|
||||
leftLabel(s.item.id, s.row, s.xStart - 6, s.y, text, strong);
|
||||
if (placed) shownOnRow.add(rowKey);
|
||||
}
|
||||
for (const s of pausesWithLabel) aboveLabel(s, s.item.label);
|
||||
for (const p of points.filter((p) => p.shape === "doseMark")) {
|
||||
const text = p.item.sub ?? p.item.label;
|
||||
const a = p.x + 2;
|
||||
const b = a + estimateTextWidth(text);
|
||||
if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) {
|
||||
labels.push({ itemId: p.item.id, text, x: a, y: p.y - 7, strong: false });
|
||||
}
|
||||
}
|
||||
// A labelled point with no room left is simply drawn without its name.
|
||||
return labels;
|
||||
}
|
||||
|
||||
/* ─── Value lines ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SeriesLayout {
|
||||
key: string;
|
||||
unit: string | null;
|
||||
top: number;
|
||||
height: number;
|
||||
/** SVG path data; empty when the window holds no point. */
|
||||
path: string;
|
||||
/** The most recent mean inside the window, or null. */
|
||||
latest: number | null;
|
||||
points: Array<{ t: string; mean: number; x: number; y: number }>;
|
||||
}
|
||||
|
||||
const BUCKET_DAYS: Record<TimelineSeries["granularity"], number> = {
|
||||
month: 30,
|
||||
week: 7,
|
||||
day: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* A point sits in the middle of its bucket. Missing buckets break the line
|
||||
* rather than bridging the gap: a straight stroke across eight empty months
|
||||
* would claim values nobody measured.
|
||||
*/
|
||||
export function layoutSeries(
|
||||
series: readonly TimelineSeries[],
|
||||
top: number,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
): SeriesLayout[] {
|
||||
return series.map((s, index) => {
|
||||
const rowTop = top + index * SERIES_HEIGHT + 6;
|
||||
const height = SERIES_HEIGHT - 16;
|
||||
const bucket = BUCKET_DAYS[s.granularity];
|
||||
const inside = s.points
|
||||
.filter((p) => p.t >= startOfMonthIf(window.from, s) && p.t <= window.to)
|
||||
.sort((a, b) => (a.t < b.t ? -1 : 1));
|
||||
if (inside.length === 0) {
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path: "",
|
||||
latest: null,
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
let lo = Math.min(...inside.map((p) => p.mean));
|
||||
let hi = Math.max(...inside.map((p) => p.mean));
|
||||
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
const placed = inside.map((p) => {
|
||||
const mid = dayNumber(p.t) + bucket / 2;
|
||||
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
|
||||
return { t: p.t, mean: p.mean, x, y: y(p.mean) };
|
||||
});
|
||||
let path = "";
|
||||
let prevDay: number | null = null;
|
||||
for (const p of placed) {
|
||||
const day = dayNumber(p.t);
|
||||
const broken = prevDay === null || day - prevDay > bucket * 1.6;
|
||||
path += `${broken ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
prevDay = day;
|
||||
}
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path,
|
||||
latest: inside[inside.length - 1].mean,
|
||||
points: placed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function startOfMonthIf(from: string, s: TimelineSeries): string {
|
||||
// A month bucket keyed on the 1st still belongs to a window that starts
|
||||
// mid-month.
|
||||
return s.granularity === "month" ? startOfMonth(from) : from;
|
||||
}
|
||||
|
||||
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TimelineLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: Scale;
|
||||
lanes: LaneLayout[];
|
||||
seriesTop: number;
|
||||
series: SeriesLayout[];
|
||||
}
|
||||
|
||||
/** Width of the lane-name column at a given chart width. */
|
||||
export function labelColumnWidth(width: number): number {
|
||||
return width >= 600 ? 164 : 140;
|
||||
}
|
||||
|
||||
export function layoutTimeline(input: {
|
||||
width: number;
|
||||
window: TimeWindow;
|
||||
lanes: readonly TimelineLane[];
|
||||
series: readonly TimelineSeries[];
|
||||
startMissing: string;
|
||||
today: string;
|
||||
}): TimelineLayout {
|
||||
const x0 = labelColumnWidth(input.width);
|
||||
const scale = createScale(input.window, x0, input.width - 8);
|
||||
let top = AXIS_HEIGHT;
|
||||
const lanes: LaneLayout[] = [];
|
||||
for (const key of LANE_ORDER) {
|
||||
const lane = input.lanes.find((l) => l.key === key);
|
||||
if (!lane) continue;
|
||||
const layout = layoutLane({ lane, top }, input.window, scale, {
|
||||
startMissing: input.startMissing,
|
||||
today: input.today,
|
||||
});
|
||||
// An empty lane is not drawn at all.
|
||||
if (layout.spans.length === 0 && layout.points.length === 0) continue;
|
||||
lanes.push(layout);
|
||||
top += layout.height;
|
||||
}
|
||||
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
|
||||
const series = layoutSeries(input.series, seriesTop, input.window, scale);
|
||||
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
|
||||
return { width: input.width, height, scale, lanes, seriesTop, series };
|
||||
}
|
||||
|
||||
/* ─── Interaction ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The date a pointer at (x, y) selects: the date of a mark within reach, the
|
||||
* start of a period it lands on, otherwise the day under the pointer.
|
||||
*/
|
||||
export function dateAtPointer(
|
||||
layout: TimelineLayout,
|
||||
x: number,
|
||||
y: number,
|
||||
): string {
|
||||
let best: { distance: number; date: string } | null = null;
|
||||
for (const lane of layout.lanes) {
|
||||
if (y < lane.top || y > lane.top + lane.height) continue;
|
||||
for (const p of lane.points) {
|
||||
const distance = Math.hypot(p.x - x, (p.y - y) / 2);
|
||||
if (distance <= HIT_SLOP && (!best || distance < best.distance)) {
|
||||
best = { distance, date: p.item.start };
|
||||
}
|
||||
}
|
||||
if (best) return best.date;
|
||||
for (const s of lane.spans) {
|
||||
if (
|
||||
Math.abs(s.y - y) <= HIT_SLOP &&
|
||||
x >= s.xStart - 2 &&
|
||||
x <= s.xEnd + 2
|
||||
) {
|
||||
const startInside = s.item.start >= layout.scale.dayAt(layout.scale.x0);
|
||||
return startInside ? s.item.start : layout.scale.dayAt(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
return layout.scale.dayAt(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection after an arrow key: a day in `quarter`, a month in `year`,
|
||||
* a year in `all`, never past `today` and never before `floor`.
|
||||
*/
|
||||
export function stepSelection(
|
||||
current: string,
|
||||
direction: -1 | 1,
|
||||
zoom: TimelineZoom,
|
||||
today: string,
|
||||
floor: string,
|
||||
): string {
|
||||
let next: string;
|
||||
if (zoom === "quarter") next = dayKey(dayNumber(current) + direction);
|
||||
else if (zoom === "year") next = addMonths(current, direction);
|
||||
else next = addMonths(current, 12 * direction);
|
||||
if (next > today) return today;
|
||||
if (next < floor) return floor;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the selection bar names for the month of `selected`: everything that
|
||||
* happens, starts or ends in it, and every closed period that runs through
|
||||
* it. A period that has been open since before the month (a chronic
|
||||
* condition, a standing medication, an allergy) is the backdrop of every
|
||||
* month and is left out, as are documents, which the day lists with their
|
||||
* visit. Lane order kept.
|
||||
*/
|
||||
export function itemsInMonth(
|
||||
lanes: readonly TimelineLane[],
|
||||
selected: string,
|
||||
today: string,
|
||||
): Array<{ lane: TimelineLaneKey; item: TimelineItem; through: boolean }> {
|
||||
const from = startOfMonth(selected);
|
||||
const to = endOfMonth(selected);
|
||||
const out: Array<{
|
||||
lane: TimelineLaneKey;
|
||||
item: TimelineItem;
|
||||
through: boolean;
|
||||
}> = [];
|
||||
for (const key of LANE_ORDER) {
|
||||
if (key === "documents") continue;
|
||||
const lane = lanes.find((l) => l.key === key);
|
||||
if (!lane) continue;
|
||||
for (const item of lane.items) {
|
||||
const end = item.open ? today : (item.end ?? item.start);
|
||||
if (item.start > to || end < from) continue;
|
||||
const startsInside = item.start >= from;
|
||||
if (item.open && !startsInside) continue;
|
||||
const endsInside = !item.open && end <= to;
|
||||
out.push({ lane: key, item, through: !startsInside && !endsInside });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two menus above the chart (v1.42, #613): which value lines run under
|
||||
* the lanes (at most three), and which lanes show, with the way back into
|
||||
* the readiness inventory ("Data coverage").
|
||||
*/
|
||||
import { ChartLine, ChevronDown, Layers } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { TimelineLaneKey } from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The most value lines the chart runs under its lanes. */
|
||||
export const MAX_VALUE_SERIES = 3;
|
||||
|
||||
export function ValueSeriesMenu({
|
||||
options,
|
||||
selected,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
selected: readonly string[];
|
||||
label: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const summary =
|
||||
selected.length > 0
|
||||
? selected.map(label).join(", ")
|
||||
: t("timeline.values.none");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 max-w-full sm:min-h-9"
|
||||
data-slot="timeline-values-trigger"
|
||||
>
|
||||
<ChartLine className="size-4" aria-hidden="true" />
|
||||
<span className="truncate">{summary}</span>
|
||||
<ChevronDown className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>{t("timeline.values.menuLabel")}</DropdownMenuLabel>
|
||||
{options.map((key) => {
|
||||
const checked = selected.includes(key);
|
||||
const full = !checked && selected.length >= MAX_VALUE_SERIES;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
disabled={full}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(next) =>
|
||||
onChange(
|
||||
next ? [...selected, key] : selected.filter((k) => k !== key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayersMenu({
|
||||
lanes,
|
||||
hidden,
|
||||
onToggle,
|
||||
onOpenReadiness,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
lanes: readonly TimelineLaneKey[];
|
||||
hidden: ReadonlySet<TimelineLaneKey>;
|
||||
onToggle: (lane: TimelineLaneKey, visible: boolean) => void;
|
||||
onOpenReadiness: () => void;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={iconOnly ? "icon" : "sm"}
|
||||
className={iconOnly ? "size-11" : "min-h-11 sm:min-h-9"}
|
||||
aria-label={iconOnly ? t("timeline.layers") : undefined}
|
||||
data-slot="timeline-layers-trigger"
|
||||
>
|
||||
<Layers className="size-4" aria-hidden="true" />
|
||||
{!iconOnly && t("timeline.layers")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel>{t("timeline.layersLanes")}</DropdownMenuLabel>
|
||||
{lanes.map((lane) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={lane}
|
||||
checked={!hidden.has(lane)}
|
||||
data-slot="timeline-layer-option"
|
||||
data-lane={lane}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(next) => onToggle(lane, next === true)}
|
||||
>
|
||||
{t(`timeline.lanes.${lane}`)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
data-slot="timeline-open-readiness"
|
||||
onSelect={onOpenReadiness}
|
||||
>
|
||||
{t("timeline.readiness.menuEntry")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
|
||||
* medications, vaccinations, visits, documents and the person's own life
|
||||
* events in lanes, up to three neutral value lines below them, all on one
|
||||
* time axis. "Stacked means at the same time, not cause and effect" is said
|
||||
* once, in the legend, and nothing on the page draws a connection.
|
||||
*
|
||||
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
|
||||
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
|
||||
* parameter onto the current entry's URL, the day layer opens over the page,
|
||||
* and Back closes it. An incoming `?day=` selects that day and zooms to
|
||||
* three months, so a day's "In the timeline" lands on itself.
|
||||
*
|
||||
* At 768 px and up the chart; below it the chronicle, which is also the
|
||||
* list form of the chart. The readiness card sits on top while a lane is
|
||||
* thin and the person has not closed it; empty lanes are never drawn.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { openDay as openDayLayer } from "@/components/day/day-layer-controller";
|
||||
import { parseDayParam } from "@/components/day/day-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
DAY_QUERY_PARAM,
|
||||
TIMELINE_ZOOMS,
|
||||
type LifeEventDTO,
|
||||
type TimelineLaneKey,
|
||||
type TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
import type { ChronicleGrouping } from "./chronicle-model";
|
||||
import { LifeEventSheet } from "./life-event-sheet";
|
||||
import { ReadinessCard } from "./readiness-card";
|
||||
import {
|
||||
READINESS_CARD_DISMISSED_KEY,
|
||||
showReadinessCard,
|
||||
} from "./readiness-model";
|
||||
import { TimelineReadinessSheet } from "./readiness-sheet";
|
||||
import { Segmented } from "./segmented";
|
||||
import { SelectionBar } from "./selection-bar";
|
||||
import { TimelineChart } from "./timeline-chart";
|
||||
import { TimelineChronicle } from "./timeline-chronicle";
|
||||
import { todayKeyIn } from "./timeline-dates";
|
||||
import { LANE_ORDER, windowFor } from "./timeline-geometry";
|
||||
import {
|
||||
LayersMenu,
|
||||
MAX_VALUE_SERIES,
|
||||
ValueSeriesMenu,
|
||||
} from "./timeline-menus";
|
||||
import {
|
||||
isBoolean,
|
||||
isStringArray,
|
||||
useDevicePreference,
|
||||
} from "./use-device-preference";
|
||||
import {
|
||||
useLifeEvents,
|
||||
useTimeline,
|
||||
useTimelineReadiness,
|
||||
} from "./use-timeline";
|
||||
|
||||
/**
|
||||
* Value lines offered in the selector, in this order, with the module that
|
||||
* owns each (null: core, never hidden). Spelled out rather than asked of
|
||||
* `moduleForMeasurementType`, whose signal registry would ride into this
|
||||
* chunk for eleven answers; `value-options.test.ts` holds the two equal.
|
||||
*/
|
||||
export const VALUE_OPTION_MODULE = {
|
||||
BLOOD_PRESSURE_SYS: null,
|
||||
BLOOD_PRESSURE_DIA: null,
|
||||
WEIGHT: null,
|
||||
RESTING_HEART_RATE: "recovery",
|
||||
PULSE: null,
|
||||
SLEEP_DURATION: "sleep",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
BODY_FAT: null,
|
||||
HEART_RATE_VARIABILITY: "recovery",
|
||||
ACTIVITY_STEPS: null,
|
||||
MOOD: "mood",
|
||||
} as const satisfies Record<string, ModuleKey | null>;
|
||||
|
||||
export const VALUE_OPTIONS = Object.keys(VALUE_OPTION_MODULE) as Array<
|
||||
keyof typeof VALUE_OPTION_MODULE
|
||||
>;
|
||||
|
||||
export const DEFAULT_VALUES = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"WEIGHT",
|
||||
"RESTING_HEART_RATE",
|
||||
];
|
||||
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
|
||||
|
||||
/** The latest dated entry on or before today, to select on arrival. */
|
||||
function latestEntryDate(
|
||||
lanes: ReadonlyArray<{
|
||||
items: ReadonlyArray<{ start: string; end: string | null }>;
|
||||
}>,
|
||||
today: string,
|
||||
): string | null {
|
||||
let best: string | null = null;
|
||||
for (const lane of lanes) {
|
||||
for (const item of lane.items) {
|
||||
for (const d of [item.start, item.end]) {
|
||||
if (d && d <= today && (!best || d > best)) best = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const caps = useRecordCapabilities();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const today = todayKeyIn(user?.timezone);
|
||||
const dayParam = parseDayParam(searchParams.get(DAY_QUERY_PARAM), today);
|
||||
// Life events are written in one's own record only (owner-only routes).
|
||||
const canAddLifeEvent = !caps.inSharedRecord;
|
||||
|
||||
const [zoom, setZoom] = useState<TimelineZoom>(dayParam ? "quarter" : "all");
|
||||
const [grouping, setGrouping] = useState<ChronicleGrouping>("month");
|
||||
// The selection follows `?day=` (Back, Forward, the day panel's previous
|
||||
// and next) and otherwise the last click or key. A pick remembers the
|
||||
// parameter it was made under; once the parameter moves, the parameter wins.
|
||||
const [picked, setPicked] = useState<{
|
||||
date: string;
|
||||
param: string | null;
|
||||
} | null>(null);
|
||||
const selected =
|
||||
picked && picked.param === dayParam
|
||||
? picked.date
|
||||
: (dayParam ?? picked?.date ?? null);
|
||||
const setSelected = (date: string) => setPicked({ date, param: dayParam });
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const [lifeEventOpen, setLifeEventOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LifeEventDTO | null>(null);
|
||||
|
||||
const [storedValues, setStoredValues] = useDevicePreference(
|
||||
VALUES_KEY,
|
||||
DEFAULT_VALUES,
|
||||
isStringArray,
|
||||
);
|
||||
const [hiddenList, setHiddenList] = useDevicePreference<string[]>(
|
||||
HIDDEN_LANES_KEY,
|
||||
[],
|
||||
isStringArray,
|
||||
);
|
||||
const [cardDismissed, setCardDismissed] = useDevicePreference(
|
||||
READINESS_CARD_DISMISSED_KEY,
|
||||
false,
|
||||
isBoolean,
|
||||
);
|
||||
|
||||
const modules = user?.modules;
|
||||
const valueOptions = useMemo(
|
||||
() =>
|
||||
VALUE_OPTIONS.filter((key) => {
|
||||
const owner: ModuleKey | null = VALUE_OPTION_MODULE[key];
|
||||
return owner === null || modules?.[owner] !== false;
|
||||
}),
|
||||
[modules],
|
||||
);
|
||||
const values = useMemo(
|
||||
() =>
|
||||
storedValues
|
||||
.filter((v) => (valueOptions as readonly string[]).includes(v))
|
||||
.slice(0, MAX_VALUE_SERIES),
|
||||
[storedValues, valueOptions],
|
||||
);
|
||||
const hidden = useMemo(
|
||||
() => new Set(hiddenList as TimelineLaneKey[]),
|
||||
[hiddenList],
|
||||
);
|
||||
|
||||
const requestWindow =
|
||||
zoom === "all" ? null : windowFor(zoom, today, selected ?? dayParam);
|
||||
const timeline = useTimeline(
|
||||
zoom,
|
||||
requestWindow?.from ?? null,
|
||||
requestWindow?.to ?? null,
|
||||
values,
|
||||
);
|
||||
const readiness = useTimelineReadiness();
|
||||
const lifeEvents = useLifeEvents(canAddLifeEvent);
|
||||
|
||||
// A readiness link (`?add=lifeEvent`) arrives with the sheet open; closing
|
||||
// it takes the parameter out of the URL, so a reload does not reopen it.
|
||||
const addRequested =
|
||||
searchParams.get("add") === "lifeEvent" && canAddLifeEvent;
|
||||
const sheetOpen = lifeEventOpen || addRequested;
|
||||
function setSheetOpen(open: boolean) {
|
||||
setLifeEventOpen(open);
|
||||
if (open) return;
|
||||
setEditing(null);
|
||||
if (searchParams.has("add")) {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
next.delete("add");
|
||||
const search = next.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
search ? `${pathname}?${search}` : pathname,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data = timeline.data;
|
||||
const effectiveSelected =
|
||||
selected ?? (data ? latestEntryDate(data.lanes, today) : null) ?? today;
|
||||
|
||||
// The shell's day layer owns `?day=` and its history entries; this only
|
||||
// asks it to open the day, and keeps the selection on it.
|
||||
function openDay(date: string) {
|
||||
if (date > today) return;
|
||||
setPicked({ date, param: date });
|
||||
openDayLayer(date);
|
||||
}
|
||||
|
||||
function editLifeEvent(id: string) {
|
||||
const event = lifeEvents.data?.events.find((e) => e.id === id);
|
||||
if (!event) return;
|
||||
setEditing(event);
|
||||
setLifeEventOpen(true);
|
||||
}
|
||||
|
||||
// Blood pressure reads as its own name here; the list labels ("Sys",
|
||||
// "Dia") lean on a column header the chart does not have.
|
||||
const seriesLabel = (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(`timeline.values.series.${key}`)
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
|
||||
|
||||
const window_ = data
|
||||
? windowFor(
|
||||
zoom,
|
||||
today,
|
||||
effectiveSelected,
|
||||
zoom === "all" ? data.range : null,
|
||||
)
|
||||
: null;
|
||||
const presentLanes = (data?.lanes ?? [])
|
||||
.filter((l) => l.items.length > 0)
|
||||
.map((l) => l.key)
|
||||
.sort((a, b) => LANE_ORDER.indexOf(a) - LANE_ORDER.indexOf(b));
|
||||
const isEmpty =
|
||||
!!data &&
|
||||
presentLanes.length === 0 &&
|
||||
data.standing.length === 0 &&
|
||||
data.series.every((s) => s.points.length === 0);
|
||||
|
||||
const zoomOptions = TIMELINE_ZOOMS.map((z) => ({
|
||||
value: z,
|
||||
label: t(`timeline.zoom.${z}`),
|
||||
}));
|
||||
const groupingOptions: Array<{ value: ChronicleGrouping; label: string }> = [
|
||||
{ value: "year", label: t("timeline.chronicle.years") },
|
||||
{ value: "month", label: t("timeline.chronicle.months") },
|
||||
];
|
||||
|
||||
const addButton = canAddLifeEvent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
aria-label={t("lifeEvents.add")}
|
||||
data-slot="timeline-add-life-event"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{t("lifeEvents.add")}</span>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const layers = (iconOnly: boolean) => (
|
||||
<LayersMenu
|
||||
lanes={presentLanes}
|
||||
hidden={hidden}
|
||||
iconOnly={iconOnly}
|
||||
onToggle={(lane, visible) =>
|
||||
setHiddenList(
|
||||
visible
|
||||
? hiddenList.filter((l) => l !== lane)
|
||||
: [...hiddenList.filter((l) => l !== lane), lane],
|
||||
)
|
||||
}
|
||||
onOpenReadiness={() => setReadinessOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="timeline-page">
|
||||
<PageHeader
|
||||
title={t("timeline.title")}
|
||||
description={t("timeline.subtitle")}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
{readiness.data && showReadinessCard(readiness.data, cardDismissed) && (
|
||||
<ReadinessCard
|
||||
readiness={readiness.data}
|
||||
onDismiss={() => setCardDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeline.isPending ? (
|
||||
<Card data-slot="timeline-loading">
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : timeline.isError ? (
|
||||
<QueryErrorCard
|
||||
description={t("timeline.loadFailed")}
|
||||
onRetry={() => void timeline.refetch()}
|
||||
/>
|
||||
) : isEmpty || !data || !window_ ? (
|
||||
<EmptyState
|
||||
title={t("timeline.empty.title")}
|
||||
description={t("timeline.empty.description")}
|
||||
action={addButton}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop and tablet: the chart. */}
|
||||
<div
|
||||
className="hidden space-y-4 md:block"
|
||||
data-slot="timeline-desktop"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Segmented
|
||||
options={zoomOptions}
|
||||
value={zoom}
|
||||
onChange={setZoom}
|
||||
label={t("timeline.zoomLabel")}
|
||||
slot="timeline-zoom"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ValueSeriesMenu
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{layers(false)}
|
||||
</div>
|
||||
</div>
|
||||
<Card className="md:gap-4">
|
||||
<CardContent className="space-y-4">
|
||||
<TimelineChart
|
||||
timeline={data}
|
||||
window={window_}
|
||||
zoom={zoom}
|
||||
today={today}
|
||||
selected={effectiveSelected}
|
||||
hiddenLanes={hidden}
|
||||
seriesLabel={seriesLabel}
|
||||
onSelect={setSelected}
|
||||
onOpenDay={openDay}
|
||||
/>
|
||||
<SelectionBar
|
||||
timeline={data}
|
||||
selected={effectiveSelected}
|
||||
today={today}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
|
||||
/>
|
||||
<Legend />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Phone: the chronicle. */}
|
||||
<div className="space-y-4 md:hidden" data-slot="timeline-mobile">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Segmented
|
||||
options={groupingOptions}
|
||||
value={grouping}
|
||||
onChange={setGrouping}
|
||||
label={t("timeline.chronicle.groupingLabel")}
|
||||
slot="timeline-grouping"
|
||||
/>
|
||||
{layers(true)}
|
||||
</div>
|
||||
<TimelineChronicle
|
||||
timeline={{
|
||||
...data,
|
||||
lanes: data.lanes.filter((l) => !hidden.has(l.key)),
|
||||
}}
|
||||
today={today}
|
||||
grouping={grouping}
|
||||
selected={dayParam}
|
||||
onOpenDay={openDay}
|
||||
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TimelineReadinessSheet
|
||||
open={readinessOpen}
|
||||
onOpenChange={setReadinessOpen}
|
||||
showOpenTimeline={false}
|
||||
/>
|
||||
{canAddLifeEvent && (
|
||||
<LifeEventSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
event={editing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs"
|
||||
data-slot="timeline-legend"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
|
||||
{t("timeline.legendMean")}
|
||||
</span>
|
||||
<span className="sm:ml-auto" data-slot="timeline-legend-causality">
|
||||
{t("timeline.legend")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A small preference kept on this device only (v1.42, #613): which value
|
||||
* lines run under the chart, which lanes are hidden, whether the readiness
|
||||
* card was closed. Conveniences, never record state: storage can be empty,
|
||||
* blocked or throw, and the timeline then simply starts from its defaults.
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function readDevicePreference<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
accept: (raw: unknown) => raw is T,
|
||||
): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return accept(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDevicePreference<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
accept: (raw: unknown) => raw is T,
|
||||
): [T, (next: T) => void] {
|
||||
// Read once, on the client: the timeline mounts only after the account
|
||||
// resolved, so there is no server render of it to disagree with.
|
||||
const [value, setValue] = useState<T>(() =>
|
||||
typeof window === "undefined"
|
||||
? fallback
|
||||
: readDevicePreference(key, fallback, accept),
|
||||
);
|
||||
const update = useCallback(
|
||||
(next: T) => {
|
||||
setValue(next);
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
// Storage unavailable: the choice holds for this visit only.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
return [value, update];
|
||||
}
|
||||
|
||||
export const isStringArray = (raw: unknown): raw is string[] =>
|
||||
Array.isArray(raw) && raw.every((v) => typeof v === "string");
|
||||
|
||||
export const isBoolean = (raw: unknown): raw is boolean =>
|
||||
typeof raw === "boolean";
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Reads and writes behind the timeline (v1.42, #613).
|
||||
*
|
||||
* Every key comes from the factory (`queryKeys.timeline*`), every read
|
||||
* unwraps the envelope through `apiGet`. A life-event write changes the
|
||||
* lanes, the readiness inventory, the event list and any open day, so it
|
||||
* evicts the timeline root and the day root together.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
LifeEventCreateInput,
|
||||
LifeEventDTO,
|
||||
LifeEventListResponse,
|
||||
LifeEventUpdateInput,
|
||||
TimelineReadinessResponse,
|
||||
TimelineResponse,
|
||||
TimelineZoom,
|
||||
} from "@/lib/day/contract";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The query string for `GET /api/timeline`. */
|
||||
export function timelinePath(
|
||||
zoom: TimelineZoom,
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
values: readonly string[],
|
||||
): string {
|
||||
const params = new URLSearchParams({ zoom });
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
if (values.length > 0) params.set("values", values.join(","));
|
||||
return `/api/timeline?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function useTimeline(
|
||||
zoom: TimelineZoom,
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
values: readonly string[],
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.timeline(zoom, from, to, values.join(",")),
|
||||
queryFn: () =>
|
||||
apiGet<TimelineResponse>(timelinePath(zoom, from, to, values)),
|
||||
// Moving between zoom levels keeps the last answer on screen until the
|
||||
// next one arrives, rather than blanking the chart.
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTimelineReadiness(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.timelineReadiness(),
|
||||
enabled,
|
||||
queryFn: () => apiGet<TimelineReadinessResponse>("/api/timeline/readiness"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLifeEvents(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.lifeEvents(),
|
||||
enabled,
|
||||
queryFn: () => apiGet<LifeEventListResponse>("/api/life-events"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLifeEventMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () =>
|
||||
invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: LifeEventCreateInput) =>
|
||||
apiPost<LifeEventDTO>("/api/life-events", body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: LifeEventUpdateInput }) =>
|
||||
apiPatch<LifeEventDTO>(
|
||||
`/api/life-events/${encodeURIComponent(id)}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<unknown>(`/api/life-events/${encodeURIComponent(id)}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -279,6 +279,7 @@ describe("every surface that shows a module's data is owned by it", () => {
|
||||
["capture:medication", "medications"],
|
||||
["capture:workout", "workouts"],
|
||||
["capture:symptom", "illness"],
|
||||
["capture:lifeEvent", "timeline"],
|
||||
["trend:mood", "mood"],
|
||||
["widget:medications", "medications"],
|
||||
["correlation:MEDICATION_COMPLIANCE", "medications"],
|
||||
|
||||
@@ -133,6 +133,7 @@ const STATIC_SURFACE_MODULE = {
|
||||
"capture:workout": "workouts",
|
||||
// v1.40 — an occurrence of a person-defined symptom rides the illness module.
|
||||
"capture:symptom": "illness",
|
||||
"capture:lifeEvent": "timeline",
|
||||
|
||||
// ── Dashboard widgets ──
|
||||
// CORE widgets (weight, blood pressure, pulse, body fat, the vital-derived
|
||||
|
||||
Reference in new issue
Block a user