mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat: add the timeline page, its readiness inventory and life-event entry
The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone.
This commit is contained in:
1 parent
b8f6372963
commit
32258bc5ab
50 files changed
+8277
-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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Zeitleiste",
|
||||
"subtitle": "Jahre auf einen Blick.",
|
||||
"legend": "Nebeneinander gezeigt, nicht als Ursache und Wirkung.",
|
||||
"subtitle": "Was wann war, neben deinen Werten.",
|
||||
"legend": "Untereinander heißt gleichzeitig, nicht ursächlich.",
|
||||
"standing": "Dauerhaft",
|
||||
"startMissing": "Startdatum fehlt",
|
||||
"openDay": "{date} öffnen",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Werte",
|
||||
"mood": "Stimmung",
|
||||
"environment": "Umwelt"
|
||||
"environment": "Umwelt",
|
||||
"life": "Lebensereignisse"
|
||||
},
|
||||
"meter": "{carrying} von {total} Spuren tragen",
|
||||
"carriesDetail": "{carrying} von {total} Spuren reichen für einen Überblick ab {since}.",
|
||||
"loadFailed": "Die Bestandsaufnahme konnte nicht geladen werden.",
|
||||
"card": {
|
||||
"title": "Was die Zeitleiste schon trägt",
|
||||
"summary": "Sie reicht bis {since} zurück, {carrying} von {total} Spuren tragen schon.",
|
||||
"dismiss": "Ausblenden"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} Messgröße seit {since}",
|
||||
"valuesFew": "{count} Messgrößen seit {since}",
|
||||
"valuesOther": "{count} Messgrößen seit {since}",
|
||||
"illnessOne": "{count} Episode, davon {chronic} chronisch",
|
||||
"illnessFew": "{count} Episoden, davon {chronic} chronisch",
|
||||
"illnessOther": "{count} Episoden, davon {chronic} chronisch",
|
||||
"allergiesOne": "{count} Allergie",
|
||||
"allergiesFew": "{count} Allergien",
|
||||
"allergiesOther": "{count} Allergien",
|
||||
"medicationsOne": "{count} Medikament, {missingStart} ohne Startdatum",
|
||||
"medicationsFew": "{count} Medikamente, {missingStart} ohne Startdatum",
|
||||
"medicationsOther": "{count} Medikamente, {missingStart} ohne Startdatum",
|
||||
"medicationsDatedOne": "{count} Medikament mit Startdatum",
|
||||
"medicationsDatedFew": "{count} Medikamente mit Startdatum",
|
||||
"medicationsDatedOther": "{count} Medikamente mit Startdatum",
|
||||
"vaccinationsOne": "{count} Impfung",
|
||||
"vaccinationsFew": "{count} Impfungen",
|
||||
"vaccinationsOther": "{count} Impfungen",
|
||||
"visitsOne": "{count} Besuch · {procedures} mit Eingriff",
|
||||
"visitsFew": "{count} Besuche · {procedures} mit Eingriff",
|
||||
"visitsOther": "{count} Besuche · {procedures} mit Eingriff",
|
||||
"visitsPlannedOne": "{count} Besuch, nur geplant",
|
||||
"visitsPlannedFew": "{count} Besuche, nur geplant",
|
||||
"visitsPlannedOther": "{count} Besuche, nur geplant",
|
||||
"labsOne": "{count} Befundtag",
|
||||
"labsFew": "{count} Befundtage",
|
||||
"labsOther": "{count} Befundtage",
|
||||
"documentsOne": "{count} Dokument mit Datum",
|
||||
"documentsFew": "{count} Dokumente mit Datum",
|
||||
"documentsOther": "{count} Dokumente mit Datum",
|
||||
"lifeOne": "{count} Lebensereignis",
|
||||
"lifeFew": "{count} Lebensereignisse",
|
||||
"lifeOther": "{count} Lebensereignisse",
|
||||
"cycleOne": "{count} Zyklus",
|
||||
"cycleFew": "{count} Zyklen",
|
||||
"cycleOther": "{count} Zyklen",
|
||||
"moodOne": "{count} Eintrag seit {since}",
|
||||
"moodFew": "{count} Einträge seit {since}",
|
||||
"moodOther": "{count} Einträge seit {since}",
|
||||
"environmentOne": "{count} Tag mit Umweltdaten",
|
||||
"environmentFew": "{count} Tage mit Umweltdaten",
|
||||
"environmentOther": "{count} Tage mit Umweltdaten",
|
||||
"empty": "Noch keine"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Quellen verbinden",
|
||||
"illnessEmpty": "Episode anlegen",
|
||||
"medicationsWithoutStart": "Ergänzen",
|
||||
"visitsEmpty": "Besuch nachtragen",
|
||||
"documentsUndated": "Ansehen",
|
||||
"labsEmpty": "Befund erfassen",
|
||||
"vaccinationsEmpty": "Impfpass erfassen",
|
||||
"lifeEventsEmpty": "Hinzufügen"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} Medikament ohne Startdatum",
|
||||
"medicationsWithoutStartFew": "{count} Medikamente ohne Startdatum",
|
||||
"medicationsWithoutStartOther": "{count} Medikamente ohne Startdatum",
|
||||
"documentsUndatedOne": "{count} Dokument ohne Datum",
|
||||
"documentsUndatedFew": "{count} Dokumente ohne Datum",
|
||||
"documentsUndatedOther": "{count} Dokumente ohne Datum",
|
||||
"valuesEmpty": "Noch keine Werte über längere Zeit",
|
||||
"illnessEmpty": "Noch keine Erkrankungen mit Beginn",
|
||||
"visitsEmpty": "Noch keine erledigten Arztbesuche",
|
||||
"labsEmpty": "Noch keine Laborbefunde",
|
||||
"vaccinationsEmpty": "Noch keine Impfungen",
|
||||
"lifeEventsEmpty": "Noch keine Lebensereignisse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Zeitraum",
|
||||
"legendEvent": "Ereignis",
|
||||
"legendMean": "Monatsmittel",
|
||||
"today": "Heute",
|
||||
"noDataYet": "Noch keine Daten",
|
||||
"layers": "Ebenen",
|
||||
"layersLanes": "Spuren",
|
||||
"zoomLabel": "Zeitraum der Ansicht",
|
||||
"chartLabel": "Zeitleiste von {from} bis {to}",
|
||||
"keyboardHint": "Pfeiltasten verschieben die Auswahl, Pos1 und Ende springen an die Enden, Eingabe öffnet den Tag.",
|
||||
"loadFailed": "Die Zeitleiste konnte nicht geladen werden.",
|
||||
"empty": {
|
||||
"title": "Noch nichts auf der Zeitleiste",
|
||||
"description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Bis zu drei Werte",
|
||||
"none": "Keine Werte",
|
||||
"noneInWindow": "Keine Werte in diesem Zeitraum",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Blutdruck sys.",
|
||||
"BLOOD_PRESSURE_DIA": "Blutdruck dia."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Einträge der Zeitleiste",
|
||||
"lane": "Spur",
|
||||
"entry": "Eintrag",
|
||||
"start": "Beginn",
|
||||
"end": "Ende"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Keine Einträge in diesem Monat",
|
||||
"ongoing": "laufend",
|
||||
"range": "{from} bis {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Monate",
|
||||
"years": "Jahre",
|
||||
"groupingLabel": "Gruppierung",
|
||||
"gap": "{from} bis {to} · keine Einträge",
|
||||
"gapSingle": "{period} · keine Einträge",
|
||||
"ended": "{label} vorbei",
|
||||
"afterDaysOne": "nach einem Tag",
|
||||
"afterDaysFew": "nach {count} Tagen",
|
||||
"afterDaysOther": "nach {count} Tagen",
|
||||
"notable": {
|
||||
"extremeHigh": "Höchster Wert seit Langem",
|
||||
"extremeLow": "Niedrigster Wert seit Langem",
|
||||
"firstValue": "Erster Wert einer Messgröße",
|
||||
"gap": "Längere Lücke in den Werten"
|
||||
},
|
||||
"notableMeta": "Tag öffnen für Einzelheiten"
|
||||
},
|
||||
"standingAllergiesOne": "{count} Allergie",
|
||||
"standingAllergiesFew": "{count} Allergien",
|
||||
"standingAllergiesOther": "{count} Allergien"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Lebensereignis",
|
||||
"add": "Lebensereignis",
|
||||
"subtitle": "Ein Anker für die Zeitleiste, kein Messwert.",
|
||||
"privacy": "Titel und Notiz liegen verschlüsselt. Sie gehen an keine KI.",
|
||||
"empty": "Noch keine Lebensereignisse",
|
||||
"emptyHint": "Umzug, Geburt oder eine neue Stelle geben den Jahren einen Anker.",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Bis",
|
||||
"note": "Notiz",
|
||||
"optionalTag": "optional",
|
||||
"notePlaceholder": "Etwas, das du später wissen willst"
|
||||
"notePlaceholder": "Etwas, das du später wissen willst",
|
||||
"removeEnd": "Enddatum entfernen"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Familie",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Tag",
|
||||
"MONTH": "Monat",
|
||||
"YEAR": "Jahr"
|
||||
},
|
||||
"edit": "Lebensereignis bearbeiten",
|
||||
"errors": {
|
||||
"titleRequired": "Gib dem Ereignis einen Namen.",
|
||||
"titleTooLong": "Höchstens 120 Zeichen.",
|
||||
"categoryRequired": "Wähle eine Art.",
|
||||
"dateRequired": "Gib ein gültiges Datum an.",
|
||||
"endBeforeStart": "Das Ende liegt vor dem Beginn.",
|
||||
"noteTooLong": "Höchstens 2000 Zeichen."
|
||||
},
|
||||
"saveFailed": "Speichern hat nicht geklappt.",
|
||||
"deleteFailed": "Löschen hat nicht geklappt.",
|
||||
"deleteConfirm": {
|
||||
"title": "Lebensereignis löschen?",
|
||||
"body": "Titel und Notiz sind danach nicht mehr da."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"subtitle": "Years at a glance.",
|
||||
"legend": "Shown side by side, not as cause and effect.",
|
||||
"subtitle": "What happened when, beside your values.",
|
||||
"legend": "Stacked means at the same time, not cause and effect.",
|
||||
"standing": "Ongoing",
|
||||
"startMissing": "Start date missing",
|
||||
"openDay": "Open {date}",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Values",
|
||||
"mood": "Mood",
|
||||
"environment": "Environment"
|
||||
"environment": "Environment",
|
||||
"life": "Life events"
|
||||
},
|
||||
"meter": "{carrying} of {total} lanes carry",
|
||||
"carriesDetail": "{carrying} of {total} lanes give an overview from {since}.",
|
||||
"loadFailed": "The inventory could not be loaded.",
|
||||
"card": {
|
||||
"title": "What the timeline already carries",
|
||||
"summary": "It reaches back to {since}; {carrying} of {total} lanes already carry.",
|
||||
"dismiss": "Hide"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} measure since {since}",
|
||||
"valuesFew": "{count} measures since {since}",
|
||||
"valuesOther": "{count} measures since {since}",
|
||||
"illnessOne": "{count} episode, {chronic} of them chronic",
|
||||
"illnessFew": "{count} episodes, {chronic} of them chronic",
|
||||
"illnessOther": "{count} episodes, {chronic} of them chronic",
|
||||
"allergiesOne": "{count} allergy",
|
||||
"allergiesFew": "{count} allergies",
|
||||
"allergiesOther": "{count} allergies",
|
||||
"medicationsOne": "{count} medication, {missingStart} without a start date",
|
||||
"medicationsFew": "{count} medications, {missingStart} without a start date",
|
||||
"medicationsOther": "{count} medications, {missingStart} without a start date",
|
||||
"medicationsDatedOne": "{count} medication with a start date",
|
||||
"medicationsDatedFew": "{count} medications with a start date",
|
||||
"medicationsDatedOther": "{count} medications with a start date",
|
||||
"vaccinationsOne": "{count} vaccination",
|
||||
"vaccinationsFew": "{count} vaccinations",
|
||||
"vaccinationsOther": "{count} vaccinations",
|
||||
"visitsOne": "{count} visit · {procedures} with a procedure",
|
||||
"visitsFew": "{count} visits · {procedures} with a procedure",
|
||||
"visitsOther": "{count} visits · {procedures} with a procedure",
|
||||
"visitsPlannedOne": "{count} visit, planned only",
|
||||
"visitsPlannedFew": "{count} visits, planned only",
|
||||
"visitsPlannedOther": "{count} visits, planned only",
|
||||
"labsOne": "{count} day with results",
|
||||
"labsFew": "{count} days with results",
|
||||
"labsOther": "{count} days with results",
|
||||
"documentsOne": "{count} dated document",
|
||||
"documentsFew": "{count} dated documents",
|
||||
"documentsOther": "{count} dated documents",
|
||||
"lifeOne": "{count} life event",
|
||||
"lifeFew": "{count} life events",
|
||||
"lifeOther": "{count} life events",
|
||||
"cycleOne": "{count} cycle",
|
||||
"cycleFew": "{count} cycles",
|
||||
"cycleOther": "{count} cycles",
|
||||
"moodOne": "{count} entry since {since}",
|
||||
"moodFew": "{count} entries since {since}",
|
||||
"moodOther": "{count} entries since {since}",
|
||||
"environmentOne": "{count} day with environment data",
|
||||
"environmentFew": "{count} days with environment data",
|
||||
"environmentOther": "{count} days with environment data",
|
||||
"empty": "None yet"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Connect sources",
|
||||
"illnessEmpty": "Add an episode",
|
||||
"medicationsWithoutStart": "Complete",
|
||||
"visitsEmpty": "Add a visit",
|
||||
"documentsUndated": "Review",
|
||||
"labsEmpty": "Add a result",
|
||||
"vaccinationsEmpty": "Add vaccinations",
|
||||
"lifeEventsEmpty": "Add"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} medication without a start date",
|
||||
"medicationsWithoutStartFew": "{count} medications without a start date",
|
||||
"medicationsWithoutStartOther": "{count} medications without a start date",
|
||||
"documentsUndatedOne": "{count} document without a date",
|
||||
"documentsUndatedFew": "{count} documents without a date",
|
||||
"documentsUndatedOther": "{count} documents without a date",
|
||||
"valuesEmpty": "No values over a longer period yet",
|
||||
"illnessEmpty": "No conditions with a start date yet",
|
||||
"visitsEmpty": "No completed doctor visits yet",
|
||||
"labsEmpty": "No lab results yet",
|
||||
"vaccinationsEmpty": "No vaccinations yet",
|
||||
"lifeEventsEmpty": "No life events yet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Period",
|
||||
"legendEvent": "Event",
|
||||
"legendMean": "Monthly mean",
|
||||
"today": "Today",
|
||||
"noDataYet": "No data yet",
|
||||
"layers": "Layers",
|
||||
"layersLanes": "Lanes",
|
||||
"zoomLabel": "View range",
|
||||
"chartLabel": "Timeline from {from} to {to}",
|
||||
"keyboardHint": "Arrow keys move the selection, Home and End jump to either end, Enter opens the day.",
|
||||
"loadFailed": "The timeline could not be loaded.",
|
||||
"empty": {
|
||||
"title": "Nothing on the timeline yet",
|
||||
"description": "Dated entries appear here as soon as there are some."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Up to three values",
|
||||
"none": "No values",
|
||||
"noneInWindow": "No values in this range",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Blood pressure sys.",
|
||||
"BLOOD_PRESSURE_DIA": "Blood pressure dia."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Timeline entries",
|
||||
"lane": "Lane",
|
||||
"entry": "Entry",
|
||||
"start": "Start",
|
||||
"end": "End"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "No entries this month",
|
||||
"ongoing": "ongoing",
|
||||
"range": "{from} to {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Months",
|
||||
"years": "Years",
|
||||
"groupingLabel": "Grouping",
|
||||
"gap": "{from} to {to} · no entries",
|
||||
"gapSingle": "{period} · no entries",
|
||||
"ended": "{label} ended",
|
||||
"afterDaysOne": "after 1 day",
|
||||
"afterDaysFew": "after {count} days",
|
||||
"afterDaysOther": "after {count} days",
|
||||
"notable": {
|
||||
"extremeHigh": "Highest value in a long while",
|
||||
"extremeLow": "Lowest value in a long while",
|
||||
"firstValue": "First value of a measure",
|
||||
"gap": "A longer gap in the values"
|
||||
},
|
||||
"notableMeta": "Open the day for details"
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergy",
|
||||
"standingAllergiesFew": "{count} allergies",
|
||||
"standingAllergiesOther": "{count} allergies"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Life event",
|
||||
"add": "Life event",
|
||||
"subtitle": "An anchor for the timeline, not a reading.",
|
||||
"privacy": "Title and note are stored encrypted. They go to no AI.",
|
||||
"empty": "No life events yet",
|
||||
"emptyHint": "A move, a birth or a new job gives the years an anchor.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Until",
|
||||
"note": "Note",
|
||||
"optionalTag": "optional",
|
||||
"notePlaceholder": "Something you want to know later"
|
||||
"notePlaceholder": "Something you want to know later",
|
||||
"removeEnd": "Remove end date"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Family",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Day",
|
||||
"MONTH": "Month",
|
||||
"YEAR": "Year"
|
||||
},
|
||||
"edit": "Edit life event",
|
||||
"errors": {
|
||||
"titleRequired": "Give the event a name.",
|
||||
"titleTooLong": "At most 120 characters.",
|
||||
"categoryRequired": "Choose a kind.",
|
||||
"dateRequired": "Enter a valid date.",
|
||||
"endBeforeStart": "The end is before the start.",
|
||||
"noteTooLong": "At most 2000 characters."
|
||||
},
|
||||
"saveFailed": "Saving did not work.",
|
||||
"deleteFailed": "Deleting did not work.",
|
||||
"deleteConfirm": {
|
||||
"title": "Delete this life event?",
|
||||
"body": "Its title and note will be gone."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología",
|
||||
"subtitle": "Años de un vistazo.",
|
||||
"legend": "Mostrado uno junto a otro, no como causa y efecto.",
|
||||
"subtitle": "Qué pasó y cuándo, junto a tus valores.",
|
||||
"legend": "Uno debajo de otro significa al mismo tiempo, no causa y efecto.",
|
||||
"standing": "Permanente",
|
||||
"startMissing": "Falta la fecha de inicio",
|
||||
"openDay": "Abrir {date}",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Valores",
|
||||
"mood": "Estado de ánimo",
|
||||
"environment": "Entorno"
|
||||
"environment": "Entorno",
|
||||
"life": "Acontecimientos vitales"
|
||||
},
|
||||
"meter": "{carrying} de {total} pistas tienen contenido",
|
||||
"carriesDetail": "{carrying} de {total} pistas dan una visión general desde {since}.",
|
||||
"loadFailed": "No se pudo cargar el inventario.",
|
||||
"card": {
|
||||
"title": "Lo que la cronología ya muestra",
|
||||
"summary": "Llega hasta {since}; {carrying} de {total} pistas ya tienen contenido.",
|
||||
"dismiss": "Ocultar"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} medida desde {since}",
|
||||
"valuesFew": "{count} medidas desde {since}",
|
||||
"valuesOther": "{count} medidas desde {since}",
|
||||
"illnessOne": "{count} episodio; crónicos: {chronic}",
|
||||
"illnessFew": "{count} episodios; crónicos: {chronic}",
|
||||
"illnessOther": "{count} episodios; crónicos: {chronic}",
|
||||
"allergiesOne": "{count} alergia",
|
||||
"allergiesFew": "{count} alergias",
|
||||
"allergiesOther": "{count} alergias",
|
||||
"medicationsOne": "{count} medicamento; sin fecha de inicio: {missingStart}",
|
||||
"medicationsFew": "{count} medicamentos; sin fecha de inicio: {missingStart}",
|
||||
"medicationsOther": "{count} medicamentos; sin fecha de inicio: {missingStart}",
|
||||
"medicationsDatedOne": "{count} medicamento con fecha de inicio",
|
||||
"medicationsDatedFew": "{count} medicamentos con fecha de inicio",
|
||||
"medicationsDatedOther": "{count} medicamentos con fecha de inicio",
|
||||
"vaccinationsOne": "{count} vacuna",
|
||||
"vaccinationsFew": "{count} vacunas",
|
||||
"vaccinationsOther": "{count} vacunas",
|
||||
"visitsOne": "{count} visita · con intervención: {procedures}",
|
||||
"visitsFew": "{count} visitas · con intervención: {procedures}",
|
||||
"visitsOther": "{count} visitas · con intervención: {procedures}",
|
||||
"visitsPlannedOne": "{count} visita, solo planificada",
|
||||
"visitsPlannedFew": "{count} visitas, solo planificadas",
|
||||
"visitsPlannedOther": "{count} visitas, solo planificadas",
|
||||
"labsOne": "{count} día con resultados",
|
||||
"labsFew": "{count} días con resultados",
|
||||
"labsOther": "{count} días con resultados",
|
||||
"documentsOne": "{count} documento con fecha",
|
||||
"documentsFew": "{count} documentos con fecha",
|
||||
"documentsOther": "{count} documentos con fecha",
|
||||
"lifeOne": "{count} acontecimiento vital",
|
||||
"lifeFew": "{count} acontecimientos vitales",
|
||||
"lifeOther": "{count} acontecimientos vitales",
|
||||
"cycleOne": "{count} ciclo",
|
||||
"cycleFew": "{count} ciclos",
|
||||
"cycleOther": "{count} ciclos",
|
||||
"moodOne": "{count} entrada desde {since}",
|
||||
"moodFew": "{count} entradas desde {since}",
|
||||
"moodOther": "{count} entradas desde {since}",
|
||||
"environmentOne": "{count} día con datos ambientales",
|
||||
"environmentFew": "{count} días con datos ambientales",
|
||||
"environmentOther": "{count} días con datos ambientales",
|
||||
"empty": "Aún ninguno"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Conectar fuentes",
|
||||
"illnessEmpty": "Añadir episodio",
|
||||
"medicationsWithoutStart": "Completar",
|
||||
"visitsEmpty": "Añadir visita",
|
||||
"documentsUndated": "Revisar",
|
||||
"labsEmpty": "Añadir resultado",
|
||||
"vaccinationsEmpty": "Añadir vacunas",
|
||||
"lifeEventsEmpty": "Añadir"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} medicamento sin fecha de inicio",
|
||||
"medicationsWithoutStartFew": "{count} medicamentos sin fecha de inicio",
|
||||
"medicationsWithoutStartOther": "{count} medicamentos sin fecha de inicio",
|
||||
"documentsUndatedOne": "{count} documento sin fecha",
|
||||
"documentsUndatedFew": "{count} documentos sin fecha",
|
||||
"documentsUndatedOther": "{count} documentos sin fecha",
|
||||
"valuesEmpty": "Aún no hay valores durante un periodo largo",
|
||||
"illnessEmpty": "Aún no hay enfermedades con fecha de inicio",
|
||||
"visitsEmpty": "Aún no hay visitas médicas realizadas",
|
||||
"labsEmpty": "Aún no hay resultados de laboratorio",
|
||||
"vaccinationsEmpty": "Aún no hay vacunas",
|
||||
"lifeEventsEmpty": "Aún no hay acontecimientos vitales"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Periodo",
|
||||
"legendEvent": "Evento",
|
||||
"legendMean": "Media mensual",
|
||||
"today": "Hoy",
|
||||
"noDataYet": "Aún sin datos",
|
||||
"layers": "Capas",
|
||||
"layersLanes": "Pistas",
|
||||
"zoomLabel": "Rango de la vista",
|
||||
"chartLabel": "Cronología de {from} a {to}",
|
||||
"keyboardHint": "Las flechas mueven la selección, Inicio y Fin saltan a los extremos, Intro abre el día.",
|
||||
"loadFailed": "No se pudo cargar la cronología.",
|
||||
"empty": {
|
||||
"title": "Aún no hay nada en la cronología",
|
||||
"description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Hasta tres valores",
|
||||
"none": "Sin valores",
|
||||
"noneInWindow": "Sin valores en este periodo",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Presión sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "Presión diastólica"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Entradas de la cronología",
|
||||
"lane": "Pista",
|
||||
"entry": "Entrada",
|
||||
"start": "Inicio",
|
||||
"end": "Fin"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Sin entradas este mes",
|
||||
"ongoing": "en curso",
|
||||
"range": "{from} a {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Meses",
|
||||
"years": "Años",
|
||||
"groupingLabel": "Agrupación",
|
||||
"gap": "{from} a {to} · sin entradas",
|
||||
"gapSingle": "{period} · sin entradas",
|
||||
"ended": "Fin: {label}",
|
||||
"afterDaysOne": "después de 1 día",
|
||||
"afterDaysFew": "después de {count} días",
|
||||
"afterDaysOther": "después de {count} días",
|
||||
"notable": {
|
||||
"extremeHigh": "El valor más alto en mucho tiempo",
|
||||
"extremeLow": "El valor más bajo en mucho tiempo",
|
||||
"firstValue": "Primer valor de una medida",
|
||||
"gap": "Un hueco largo en los valores"
|
||||
},
|
||||
"notableMeta": "Abre el día para ver detalles"
|
||||
},
|
||||
"standingAllergiesOne": "{count} alergia",
|
||||
"standingAllergiesFew": "{count} alergias",
|
||||
"standingAllergiesOther": "{count} alergias"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Acontecimiento vital",
|
||||
"add": "Acontecimiento vital",
|
||||
"subtitle": "Un ancla para la cronología, no una medición.",
|
||||
"privacy": "El título y la nota se guardan cifrados. No se envían a ninguna IA.",
|
||||
"empty": "Aún no hay acontecimientos vitales",
|
||||
"emptyHint": "Una mudanza, un nacimiento o un nuevo trabajo dan un ancla a los años.",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Hasta",
|
||||
"note": "Nota",
|
||||
"optionalTag": "opcional",
|
||||
"notePlaceholder": "Algo que quieras saber más adelante"
|
||||
"notePlaceholder": "Algo que quieras saber más adelante",
|
||||
"removeEnd": "Quitar fecha de fin"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Familia",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Día",
|
||||
"MONTH": "Mes",
|
||||
"YEAR": "Año"
|
||||
},
|
||||
"edit": "Editar acontecimiento vital",
|
||||
"errors": {
|
||||
"titleRequired": "Ponle un nombre al acontecimiento.",
|
||||
"titleTooLong": "Máximo 120 caracteres.",
|
||||
"categoryRequired": "Elige un tipo.",
|
||||
"dateRequired": "Introduce una fecha válida.",
|
||||
"endBeforeStart": "El final es anterior al inicio.",
|
||||
"noteTooLong": "Máximo 2000 caracteres."
|
||||
},
|
||||
"saveFailed": "No se pudo guardar.",
|
||||
"deleteFailed": "No se pudo eliminar.",
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar este acontecimiento vital?",
|
||||
"body": "Su título y su nota desaparecerán."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie",
|
||||
"subtitle": "Des années d’un coup d’œil.",
|
||||
"legend": "Présentés côte à côte, pas comme cause et effet.",
|
||||
"subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.",
|
||||
"legend": "Superposé signifie simultané, pas cause et effet.",
|
||||
"standing": "Permanent",
|
||||
"startMissing": "Date de début manquante",
|
||||
"openDay": "Ouvrir le {date}",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Valeurs",
|
||||
"mood": "Humeur",
|
||||
"environment": "Environnement"
|
||||
"environment": "Environnement",
|
||||
"life": "Événements de vie"
|
||||
},
|
||||
"meter": "{carrying} pistes sur {total} sont remplies",
|
||||
"carriesDetail": "{carrying} pistes sur {total} donnent une vue d’ensemble depuis {since}.",
|
||||
"loadFailed": "L’inventaire n’a pas pu être chargé.",
|
||||
"card": {
|
||||
"title": "Ce que la chronologie contient déjà",
|
||||
"summary": "Elle remonte à {since} ; {carrying} pistes sur {total} sont déjà remplies.",
|
||||
"dismiss": "Masquer"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} mesure depuis {since}",
|
||||
"valuesFew": "{count} mesures depuis {since}",
|
||||
"valuesOther": "{count} mesures depuis {since}",
|
||||
"illnessOne": "{count} épisode ; chroniques : {chronic}",
|
||||
"illnessFew": "{count} épisodes ; chroniques : {chronic}",
|
||||
"illnessOther": "{count} épisodes ; chroniques : {chronic}",
|
||||
"allergiesOne": "{count} allergie",
|
||||
"allergiesFew": "{count} allergies",
|
||||
"allergiesOther": "{count} allergies",
|
||||
"medicationsOne": "{count} médicament ; sans date de début : {missingStart}",
|
||||
"medicationsFew": "{count} médicaments ; sans date de début : {missingStart}",
|
||||
"medicationsOther": "{count} médicaments ; sans date de début : {missingStart}",
|
||||
"medicationsDatedOne": "{count} médicament avec date de début",
|
||||
"medicationsDatedFew": "{count} médicaments avec date de début",
|
||||
"medicationsDatedOther": "{count} médicaments avec date de début",
|
||||
"vaccinationsOne": "{count} vaccin",
|
||||
"vaccinationsFew": "{count} vaccins",
|
||||
"vaccinationsOther": "{count} vaccins",
|
||||
"visitsOne": "{count} consultation · avec intervention : {procedures}",
|
||||
"visitsFew": "{count} consultations · avec intervention : {procedures}",
|
||||
"visitsOther": "{count} consultations · avec intervention : {procedures}",
|
||||
"visitsPlannedOne": "{count} consultation, seulement prévue",
|
||||
"visitsPlannedFew": "{count} consultations, seulement prévues",
|
||||
"visitsPlannedOther": "{count} consultations, seulement prévues",
|
||||
"labsOne": "{count} jour avec résultats",
|
||||
"labsFew": "{count} jours avec résultats",
|
||||
"labsOther": "{count} jours avec résultats",
|
||||
"documentsOne": "{count} document daté",
|
||||
"documentsFew": "{count} documents datés",
|
||||
"documentsOther": "{count} documents datés",
|
||||
"lifeOne": "{count} événement de vie",
|
||||
"lifeFew": "{count} événements de vie",
|
||||
"lifeOther": "{count} événements de vie",
|
||||
"cycleOne": "{count} cycle",
|
||||
"cycleFew": "{count} cycles",
|
||||
"cycleOther": "{count} cycles",
|
||||
"moodOne": "{count} entrée depuis {since}",
|
||||
"moodFew": "{count} entrées depuis {since}",
|
||||
"moodOther": "{count} entrées depuis {since}",
|
||||
"environmentOne": "{count} jour avec données d’environnement",
|
||||
"environmentFew": "{count} jours avec données d’environnement",
|
||||
"environmentOther": "{count} jours avec données d’environnement",
|
||||
"empty": "Rien pour l’instant"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Connecter des sources",
|
||||
"illnessEmpty": "Ajouter un épisode",
|
||||
"medicationsWithoutStart": "Compléter",
|
||||
"visitsEmpty": "Ajouter une visite",
|
||||
"documentsUndated": "Vérifier",
|
||||
"labsEmpty": "Ajouter un résultat",
|
||||
"vaccinationsEmpty": "Ajouter des vaccins",
|
||||
"lifeEventsEmpty": "Ajouter"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} médicament sans date de début",
|
||||
"medicationsWithoutStartFew": "{count} médicaments sans date de début",
|
||||
"medicationsWithoutStartOther": "{count} médicaments sans date de début",
|
||||
"documentsUndatedOne": "{count} document sans date",
|
||||
"documentsUndatedFew": "{count} documents sans date",
|
||||
"documentsUndatedOther": "{count} documents sans date",
|
||||
"valuesEmpty": "Pas encore de valeurs sur une longue période",
|
||||
"illnessEmpty": "Pas encore de maladies avec une date de début",
|
||||
"visitsEmpty": "Pas encore de consultations effectuées",
|
||||
"labsEmpty": "Pas encore de résultats d’analyses",
|
||||
"vaccinationsEmpty": "Pas encore de vaccins",
|
||||
"lifeEventsEmpty": "Aucun événement de vie pour l’instant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Période",
|
||||
"legendEvent": "Événement",
|
||||
"legendMean": "Moyenne mensuelle",
|
||||
"today": "Aujourd’hui",
|
||||
"noDataYet": "Pas encore de données",
|
||||
"layers": "Calques",
|
||||
"layersLanes": "Pistes",
|
||||
"zoomLabel": "Période affichée",
|
||||
"chartLabel": "Chronologie de {from} à {to}",
|
||||
"keyboardHint": "Les flèches déplacent la sélection, Début et Fin vont aux extrémités, Entrée ouvre le jour.",
|
||||
"loadFailed": "La chronologie n’a pas pu être chargée.",
|
||||
"empty": {
|
||||
"title": "Rien dans la chronologie pour l’instant",
|
||||
"description": "Les entrées datées apparaîtront ici dès qu’il y en aura."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Jusqu’à trois valeurs",
|
||||
"none": "Aucune valeur",
|
||||
"noneInWindow": "Aucune valeur sur cette période",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Tension systolique",
|
||||
"BLOOD_PRESSURE_DIA": "Tension diastolique"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Entrées de la chronologie",
|
||||
"lane": "Piste",
|
||||
"entry": "Entrée",
|
||||
"start": "Début",
|
||||
"end": "Fin"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Aucune entrée ce mois-ci",
|
||||
"ongoing": "en cours",
|
||||
"range": "{from} au {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Mois",
|
||||
"years": "Années",
|
||||
"groupingLabel": "Regroupement",
|
||||
"gap": "{from} à {to} · aucune entrée",
|
||||
"gapSingle": "{period} · aucune entrée",
|
||||
"ended": "Fin : {label}",
|
||||
"afterDaysOne": "après 1 jour",
|
||||
"afterDaysFew": "après {count} jours",
|
||||
"afterDaysOther": "après {count} jours",
|
||||
"notable": {
|
||||
"extremeHigh": "Valeur la plus haute depuis longtemps",
|
||||
"extremeLow": "Valeur la plus basse depuis longtemps",
|
||||
"firstValue": "Première valeur d’une mesure",
|
||||
"gap": "Une longue interruption des valeurs"
|
||||
},
|
||||
"notableMeta": "Ouvrez le jour pour les détails"
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergie",
|
||||
"standingAllergiesFew": "{count} allergies",
|
||||
"standingAllergiesOther": "{count} allergies"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Événement de vie",
|
||||
"add": "Événement de vie",
|
||||
"subtitle": "Un repère pour la chronologie, pas une mesure.",
|
||||
"privacy": "Le titre et la note sont chiffrés. Ils ne sont envoyés à aucune IA.",
|
||||
"empty": "Aucun événement de vie pour l’instant",
|
||||
"emptyHint": "Un déménagement, une naissance ou un nouvel emploi donnent un repère aux années.",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Jusqu’au",
|
||||
"note": "Note",
|
||||
"optionalTag": "facultatif",
|
||||
"notePlaceholder": "Quelque chose que vous voudrez savoir plus tard"
|
||||
"notePlaceholder": "Quelque chose que vous voudrez savoir plus tard",
|
||||
"removeEnd": "Retirer la date de fin"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Famille",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Jour",
|
||||
"MONTH": "Mois",
|
||||
"YEAR": "Année"
|
||||
},
|
||||
"edit": "Modifier l’événement de vie",
|
||||
"errors": {
|
||||
"titleRequired": "Donnez un nom à l’événement.",
|
||||
"titleTooLong": "120 caractères au maximum.",
|
||||
"categoryRequired": "Choisissez un type.",
|
||||
"dateRequired": "Indiquez une date valide.",
|
||||
"endBeforeStart": "La fin précède le début.",
|
||||
"noteTooLong": "2000 caractères au maximum."
|
||||
},
|
||||
"saveFailed": "L’enregistrement a échoué.",
|
||||
"deleteFailed": "La suppression a échoué.",
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer cet événement de vie ?",
|
||||
"body": "Son titre et sa note disparaîtront."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia",
|
||||
"subtitle": "Anni a colpo d’occhio.",
|
||||
"legend": "Mostrati uno accanto all’altro, non come causa ed effetto.",
|
||||
"subtitle": "Cosa è successo e quando, accanto ai tuoi valori.",
|
||||
"legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.",
|
||||
"standing": "Permanente",
|
||||
"startMissing": "Data di inizio mancante",
|
||||
"openDay": "Apri {date}",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Valori",
|
||||
"mood": "Umore",
|
||||
"environment": "Ambiente"
|
||||
"environment": "Ambiente",
|
||||
"life": "Eventi di vita"
|
||||
},
|
||||
"meter": "{carrying} corsie su {total} hanno contenuto",
|
||||
"carriesDetail": "{carrying} corsie su {total} danno una panoramica da {since}.",
|
||||
"loadFailed": "Impossibile caricare l’inventario.",
|
||||
"card": {
|
||||
"title": "Cosa contiene già la cronologia",
|
||||
"summary": "Risale fino a {since}; {carrying} corsie su {total} hanno già contenuto.",
|
||||
"dismiss": "Nascondi"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} misura da {since}",
|
||||
"valuesFew": "{count} misure da {since}",
|
||||
"valuesOther": "{count} misure da {since}",
|
||||
"illnessOne": "{count} episodio; cronici: {chronic}",
|
||||
"illnessFew": "{count} episodi; cronici: {chronic}",
|
||||
"illnessOther": "{count} episodi; cronici: {chronic}",
|
||||
"allergiesOne": "{count} allergia",
|
||||
"allergiesFew": "{count} allergie",
|
||||
"allergiesOther": "{count} allergie",
|
||||
"medicationsOne": "{count} farmaco; senza data di inizio: {missingStart}",
|
||||
"medicationsFew": "{count} farmaci; senza data di inizio: {missingStart}",
|
||||
"medicationsOther": "{count} farmaci; senza data di inizio: {missingStart}",
|
||||
"medicationsDatedOne": "{count} farmaco con data di inizio",
|
||||
"medicationsDatedFew": "{count} farmaci con data di inizio",
|
||||
"medicationsDatedOther": "{count} farmaci con data di inizio",
|
||||
"vaccinationsOne": "{count} vaccinazione",
|
||||
"vaccinationsFew": "{count} vaccinazioni",
|
||||
"vaccinationsOther": "{count} vaccinazioni",
|
||||
"visitsOne": "{count} visita · con intervento: {procedures}",
|
||||
"visitsFew": "{count} visite · con intervento: {procedures}",
|
||||
"visitsOther": "{count} visite · con intervento: {procedures}",
|
||||
"visitsPlannedOne": "{count} visita, solo pianificata",
|
||||
"visitsPlannedFew": "{count} visite, solo pianificate",
|
||||
"visitsPlannedOther": "{count} visite, solo pianificate",
|
||||
"labsOne": "{count} giorno con referti",
|
||||
"labsFew": "{count} giorni con referti",
|
||||
"labsOther": "{count} giorni con referti",
|
||||
"documentsOne": "{count} documento con data",
|
||||
"documentsFew": "{count} documenti con data",
|
||||
"documentsOther": "{count} documenti con data",
|
||||
"lifeOne": "{count} evento di vita",
|
||||
"lifeFew": "{count} eventi di vita",
|
||||
"lifeOther": "{count} eventi di vita",
|
||||
"cycleOne": "{count} ciclo",
|
||||
"cycleFew": "{count} cicli",
|
||||
"cycleOther": "{count} cicli",
|
||||
"moodOne": "{count} voce da {since}",
|
||||
"moodFew": "{count} voci da {since}",
|
||||
"moodOther": "{count} voci da {since}",
|
||||
"environmentOne": "{count} giorno con dati ambientali",
|
||||
"environmentFew": "{count} giorni con dati ambientali",
|
||||
"environmentOther": "{count} giorni con dati ambientali",
|
||||
"empty": "Ancora nulla"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Collega le fonti",
|
||||
"illnessEmpty": "Aggiungi episodio",
|
||||
"medicationsWithoutStart": "Completa",
|
||||
"visitsEmpty": "Aggiungi visita",
|
||||
"documentsUndated": "Controlla",
|
||||
"labsEmpty": "Aggiungi referto",
|
||||
"vaccinationsEmpty": "Aggiungi vaccinazioni",
|
||||
"lifeEventsEmpty": "Aggiungi"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} farmaco senza data di inizio",
|
||||
"medicationsWithoutStartFew": "{count} farmaci senza data di inizio",
|
||||
"medicationsWithoutStartOther": "{count} farmaci senza data di inizio",
|
||||
"documentsUndatedOne": "{count} documento senza data",
|
||||
"documentsUndatedFew": "{count} documenti senza data",
|
||||
"documentsUndatedOther": "{count} documenti senza data",
|
||||
"valuesEmpty": "Ancora nessun valore su un periodo lungo",
|
||||
"illnessEmpty": "Ancora nessuna malattia con data di inizio",
|
||||
"visitsEmpty": "Ancora nessuna visita medica svolta",
|
||||
"labsEmpty": "Ancora nessun referto di laboratorio",
|
||||
"vaccinationsEmpty": "Ancora nessuna vaccinazione",
|
||||
"lifeEventsEmpty": "Ancora nessun evento di vita"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Periodo",
|
||||
"legendEvent": "Evento",
|
||||
"legendMean": "Media mensile",
|
||||
"today": "Oggi",
|
||||
"noDataYet": "Ancora nessun dato",
|
||||
"layers": "Livelli",
|
||||
"layersLanes": "Corsie",
|
||||
"zoomLabel": "Intervallo visualizzato",
|
||||
"chartLabel": "Cronologia da {from} a {to}",
|
||||
"keyboardHint": "Le frecce spostano la selezione, Inizio e Fine vanno agli estremi, Invio apre il giorno.",
|
||||
"loadFailed": "Impossibile caricare la cronologia.",
|
||||
"empty": {
|
||||
"title": "Ancora niente nella cronologia",
|
||||
"description": "Le voci con data compariranno qui appena ce ne saranno."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Fino a tre valori",
|
||||
"none": "Nessun valore",
|
||||
"noneInWindow": "Nessun valore in questo periodo",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "Pressione diastolica"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Voci della cronologia",
|
||||
"lane": "Corsia",
|
||||
"entry": "Voce",
|
||||
"start": "Inizio",
|
||||
"end": "Fine"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Nessuna voce in questo mese",
|
||||
"ongoing": "in corso",
|
||||
"range": "dal {from} al {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Mesi",
|
||||
"years": "Anni",
|
||||
"groupingLabel": "Raggruppamento",
|
||||
"gap": "Da {from} a {to} · nessuna voce",
|
||||
"gapSingle": "{period} · nessuna voce",
|
||||
"ended": "Fine: {label}",
|
||||
"afterDaysOne": "dopo 1 giorno",
|
||||
"afterDaysFew": "dopo {count} giorni",
|
||||
"afterDaysOther": "dopo {count} giorni",
|
||||
"notable": {
|
||||
"extremeHigh": "Valore più alto da molto tempo",
|
||||
"extremeLow": "Valore più basso da molto tempo",
|
||||
"firstValue": "Primo valore di una misura",
|
||||
"gap": "Una lunga pausa nei valori"
|
||||
},
|
||||
"notableMeta": "Apri il giorno per i dettagli"
|
||||
},
|
||||
"standingAllergiesOne": "{count} allergia",
|
||||
"standingAllergiesFew": "{count} allergie",
|
||||
"standingAllergiesOther": "{count} allergie"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento di vita",
|
||||
"add": "Evento di vita",
|
||||
"subtitle": "Un punto di riferimento per la cronologia, non una misurazione.",
|
||||
"privacy": "Titolo e nota sono salvati cifrati. Non vanno a nessuna IA.",
|
||||
"empty": "Ancora nessun evento di vita",
|
||||
"emptyHint": "Un trasloco, una nascita o un nuovo lavoro danno agli anni un punto di riferimento.",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Fino al",
|
||||
"note": "Nota",
|
||||
"optionalTag": "facoltativo",
|
||||
"notePlaceholder": "Qualcosa che vorrai sapere più avanti"
|
||||
"notePlaceholder": "Qualcosa che vorrai sapere più avanti",
|
||||
"removeEnd": "Rimuovi data di fine"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Famiglia",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Giorno",
|
||||
"MONTH": "Mese",
|
||||
"YEAR": "Anno"
|
||||
},
|
||||
"edit": "Modifica evento di vita",
|
||||
"errors": {
|
||||
"titleRequired": "Dai un nome all’evento.",
|
||||
"titleTooLong": "Al massimo 120 caratteri.",
|
||||
"categoryRequired": "Scegli un tipo.",
|
||||
"dateRequired": "Inserisci una data valida.",
|
||||
"endBeforeStart": "La fine è prima dell’inizio.",
|
||||
"noteTooLong": "Al massimo 2000 caratteri."
|
||||
},
|
||||
"saveFailed": "Salvataggio non riuscito.",
|
||||
"deleteFailed": "Eliminazione non riuscita.",
|
||||
"deleteConfirm": {
|
||||
"title": "Eliminare questo evento di vita?",
|
||||
"body": "Titolo e nota non ci saranno più."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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": "복용 기록",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
"subtitle": "몇 년을 한눈에.",
|
||||
"legend": "나란히 보여줄 뿐, 원인과 결과를 뜻하지 않아요.",
|
||||
"subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.",
|
||||
"legend": "위아래로 놓인 것은 같은 시기라는 뜻이지, 원인과 결과가 아니에요.",
|
||||
"standing": "지속",
|
||||
"startMissing": "시작일 없음",
|
||||
"openDay": "{date} 열기",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "수치",
|
||||
"mood": "기분",
|
||||
"environment": "환경"
|
||||
"environment": "환경",
|
||||
"life": "삶의 사건"
|
||||
},
|
||||
"meter": "{total}개 중 {carrying}개 줄이 채워졌어요",
|
||||
"carriesDetail": "{total}개 중 {carrying}개 줄로 {since}부터 한눈에 볼 수 있어요.",
|
||||
"loadFailed": "현황을 불러오지 못했어요.",
|
||||
"card": {
|
||||
"title": "타임라인에 이미 담긴 것",
|
||||
"summary": "{since}까지 거슬러 올라가며, {total}개 중 {carrying}개 줄이 이미 채워졌어요.",
|
||||
"dismiss": "숨기기"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{since}부터 측정 항목 {count}개",
|
||||
"valuesFew": "{since}부터 측정 항목 {count}개",
|
||||
"valuesOther": "{since}부터 측정 항목 {count}개",
|
||||
"illnessOne": "에피소드 {count}개, 만성 {chronic}개",
|
||||
"illnessFew": "에피소드 {count}개, 만성 {chronic}개",
|
||||
"illnessOther": "에피소드 {count}개, 만성 {chronic}개",
|
||||
"allergiesOne": "알레르기 {count}개",
|
||||
"allergiesFew": "알레르기 {count}개",
|
||||
"allergiesOther": "알레르기 {count}개",
|
||||
"medicationsOne": "약 {count}개, 시작일 없음 {missingStart}개",
|
||||
"medicationsFew": "약 {count}개, 시작일 없음 {missingStart}개",
|
||||
"medicationsOther": "약 {count}개, 시작일 없음 {missingStart}개",
|
||||
"medicationsDatedOne": "시작일 있는 약 {count}개",
|
||||
"medicationsDatedFew": "시작일 있는 약 {count}개",
|
||||
"medicationsDatedOther": "시작일 있는 약 {count}개",
|
||||
"vaccinationsOne": "예방접종 {count}건",
|
||||
"vaccinationsFew": "예방접종 {count}건",
|
||||
"vaccinationsOther": "예방접종 {count}건",
|
||||
"visitsOne": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsFew": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsOther": "진료 {count}건 · 시술 {procedures}건",
|
||||
"visitsPlannedOne": "예정된 진료만 {count}건",
|
||||
"visitsPlannedFew": "예정된 진료만 {count}건",
|
||||
"visitsPlannedOther": "예정된 진료만 {count}건",
|
||||
"labsOne": "결과가 있는 날 {count}일",
|
||||
"labsFew": "결과가 있는 날 {count}일",
|
||||
"labsOther": "결과가 있는 날 {count}일",
|
||||
"documentsOne": "날짜 있는 문서 {count}개",
|
||||
"documentsFew": "날짜 있는 문서 {count}개",
|
||||
"documentsOther": "날짜 있는 문서 {count}개",
|
||||
"lifeOne": "삶의 사건 {count}개",
|
||||
"lifeFew": "삶의 사건 {count}개",
|
||||
"lifeOther": "삶의 사건 {count}개",
|
||||
"cycleOne": "주기 {count}개",
|
||||
"cycleFew": "주기 {count}개",
|
||||
"cycleOther": "주기 {count}개",
|
||||
"moodOne": "{since}부터 기록 {count}개",
|
||||
"moodFew": "{since}부터 기록 {count}개",
|
||||
"moodOther": "{since}부터 기록 {count}개",
|
||||
"environmentOne": "환경 데이터가 있는 날 {count}일",
|
||||
"environmentFew": "환경 데이터가 있는 날 {count}일",
|
||||
"environmentOther": "환경 데이터가 있는 날 {count}일",
|
||||
"empty": "아직 없음"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "소스 연결",
|
||||
"illnessEmpty": "에피소드 추가",
|
||||
"medicationsWithoutStart": "채우기",
|
||||
"visitsEmpty": "진료 추가",
|
||||
"documentsUndated": "확인하기",
|
||||
"labsEmpty": "결과 추가",
|
||||
"vaccinationsEmpty": "예방접종 추가",
|
||||
"lifeEventsEmpty": "추가"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "시작일 없는 약 {count}개",
|
||||
"medicationsWithoutStartFew": "시작일 없는 약 {count}개",
|
||||
"medicationsWithoutStartOther": "시작일 없는 약 {count}개",
|
||||
"documentsUndatedOne": "날짜 없는 문서 {count}개",
|
||||
"documentsUndatedFew": "날짜 없는 문서 {count}개",
|
||||
"documentsUndatedOther": "날짜 없는 문서 {count}개",
|
||||
"valuesEmpty": "아직 오랜 기간의 수치가 없어요",
|
||||
"illnessEmpty": "아직 시작일이 있는 질환이 없어요",
|
||||
"visitsEmpty": "아직 완료된 진료가 없어요",
|
||||
"labsEmpty": "아직 검사 결과가 없어요",
|
||||
"vaccinationsEmpty": "아직 예방접종이 없어요",
|
||||
"lifeEventsEmpty": "아직 삶의 사건이 없어요"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "기간",
|
||||
"legendEvent": "사건",
|
||||
"legendMean": "월평균",
|
||||
"today": "오늘",
|
||||
"noDataYet": "아직 데이터 없음",
|
||||
"layers": "레이어",
|
||||
"layersLanes": "줄",
|
||||
"zoomLabel": "보기 범위",
|
||||
"chartLabel": "{from}부터 {to}까지의 타임라인",
|
||||
"keyboardHint": "화살표 키로 선택을 옮기고, Home과 End로 양 끝으로 이동하며, Enter로 그날을 열어요.",
|
||||
"loadFailed": "타임라인을 불러오지 못했어요.",
|
||||
"empty": {
|
||||
"title": "아직 타임라인에 아무것도 없어요",
|
||||
"description": "날짜가 있는 기록이 생기면 여기에 나타나요."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "최대 세 가지 수치",
|
||||
"none": "수치 없음",
|
||||
"noneInWindow": "이 기간에 수치 없음",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "수축기 혈압",
|
||||
"BLOOD_PRESSURE_DIA": "이완기 혈압"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "타임라인 기록",
|
||||
"lane": "줄",
|
||||
"entry": "기록",
|
||||
"start": "시작",
|
||||
"end": "끝"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "이번 달 기록 없음",
|
||||
"ongoing": "진행 중",
|
||||
"range": "{from}~{to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "월",
|
||||
"years": "연도",
|
||||
"groupingLabel": "묶기",
|
||||
"gap": "{from}~{to} · 기록 없음",
|
||||
"gapSingle": "{period} · 기록 없음",
|
||||
"ended": "{label} 끝남",
|
||||
"afterDaysOne": "{count}일 후",
|
||||
"afterDaysFew": "{count}일 후",
|
||||
"afterDaysOther": "{count}일 후",
|
||||
"notable": {
|
||||
"extremeHigh": "오랜만의 최고값",
|
||||
"extremeLow": "오랜만의 최저값",
|
||||
"firstValue": "측정 항목의 첫 수치",
|
||||
"gap": "수치에 긴 공백"
|
||||
},
|
||||
"notableMeta": "자세한 내용은 그날을 열어 보세요"
|
||||
},
|
||||
"standingAllergiesOne": "알레르기 {count}개",
|
||||
"standingAllergiesFew": "알레르기 {count}개",
|
||||
"standingAllergiesOther": "알레르기 {count}개"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "삶의 사건",
|
||||
"add": "삶의 사건",
|
||||
"subtitle": "측정값이 아니라 타임라인의 기준점이에요.",
|
||||
"privacy": "제목과 메모는 암호화되어 저장돼요. 어떤 AI에도 보내지 않아요.",
|
||||
"empty": "아직 삶의 사건이 없어요",
|
||||
"emptyHint": "이사, 출산, 새 직장 같은 일이 몇 년에 기준점을 만들어 줘요.",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "종료",
|
||||
"note": "메모",
|
||||
"optionalTag": "선택",
|
||||
"notePlaceholder": "나중에 알고 싶은 내용"
|
||||
"notePlaceholder": "나중에 알고 싶은 내용",
|
||||
"removeEnd": "종료일 삭제"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "가족",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "일",
|
||||
"MONTH": "월",
|
||||
"YEAR": "연"
|
||||
},
|
||||
"edit": "삶의 사건 편집",
|
||||
"errors": {
|
||||
"titleRequired": "사건의 이름을 적어 주세요.",
|
||||
"titleTooLong": "최대 120자까지예요.",
|
||||
"categoryRequired": "종류를 골라 주세요.",
|
||||
"dateRequired": "올바른 날짜를 입력해 주세요.",
|
||||
"endBeforeStart": "끝이 시작보다 앞서요.",
|
||||
"noteTooLong": "최대 2000자까지예요."
|
||||
},
|
||||
"saveFailed": "저장하지 못했어요.",
|
||||
"deleteFailed": "삭제하지 못했어요.",
|
||||
"deleteConfirm": {
|
||||
"title": "이 삶의 사건을 삭제할까요?",
|
||||
"body": "제목과 메모가 사라져요."
|
||||
}
|
||||
}
|
||||
}
|
||||
+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",
|
||||
@@ -12214,8 +12217,8 @@
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś czasu",
|
||||
"subtitle": "Lata w jednym widoku.",
|
||||
"legend": "Pokazane obok siebie, nie jako przyczyna i skutek.",
|
||||
"subtitle": "Co i kiedy się działo, obok Twoich wartości.",
|
||||
"legend": "Jedno pod drugim oznacza ten sam czas, nie przyczynę i skutek.",
|
||||
"standing": "Stałe",
|
||||
"startMissing": "Brak daty rozpoczęcia",
|
||||
"openDay": "Otwórz {date}",
|
||||
@@ -12254,16 +12257,151 @@
|
||||
"lanes": {
|
||||
"values": "Wartości",
|
||||
"mood": "Nastrój",
|
||||
"environment": "Otoczenie"
|
||||
"environment": "Otoczenie",
|
||||
"life": "Wydarzenia życiowe"
|
||||
},
|
||||
"meter": "{carrying} z {total} ścieżek ma treść",
|
||||
"carriesDetail": "{carrying} z {total} ścieżek daje przegląd od {since}.",
|
||||
"loadFailed": "Nie udało się wczytać przeglądu.",
|
||||
"card": {
|
||||
"title": "Co już zawiera oś czasu",
|
||||
"summary": "Sięga do {since}; {carrying} z {total} ścieżek ma już treść.",
|
||||
"dismiss": "Ukryj"
|
||||
},
|
||||
"detail": {
|
||||
"valuesOne": "{count} pomiar od {since}",
|
||||
"valuesFew": "{count} pomiary od {since}",
|
||||
"valuesOther": "{count} pomiarów od {since}",
|
||||
"illnessOne": "{count} epizod; przewlekłe: {chronic}",
|
||||
"illnessFew": "{count} epizody; przewlekłe: {chronic}",
|
||||
"illnessOther": "{count} epizodów; przewlekłe: {chronic}",
|
||||
"allergiesOne": "{count} alergia",
|
||||
"allergiesFew": "{count} alergie",
|
||||
"allergiesOther": "{count} alergii",
|
||||
"medicationsOne": "{count} lek; bez daty rozpoczęcia: {missingStart}",
|
||||
"medicationsFew": "{count} leki; bez daty rozpoczęcia: {missingStart}",
|
||||
"medicationsOther": "{count} leków; bez daty rozpoczęcia: {missingStart}",
|
||||
"medicationsDatedOne": "{count} lek z datą rozpoczęcia",
|
||||
"medicationsDatedFew": "{count} leki z datą rozpoczęcia",
|
||||
"medicationsDatedOther": "{count} leków z datą rozpoczęcia",
|
||||
"vaccinationsOne": "{count} szczepienie",
|
||||
"vaccinationsFew": "{count} szczepienia",
|
||||
"vaccinationsOther": "{count} szczepień",
|
||||
"visitsOne": "{count} wizyta · z zabiegiem: {procedures}",
|
||||
"visitsFew": "{count} wizyty · z zabiegiem: {procedures}",
|
||||
"visitsOther": "{count} wizyt · z zabiegiem: {procedures}",
|
||||
"visitsPlannedOne": "{count} wizyta, tylko zaplanowana",
|
||||
"visitsPlannedFew": "{count} wizyty, tylko zaplanowane",
|
||||
"visitsPlannedOther": "{count} wizyt, tylko zaplanowane",
|
||||
"labsOne": "{count} dzień z wynikami",
|
||||
"labsFew": "{count} dni z wynikami",
|
||||
"labsOther": "{count} dni z wynikami",
|
||||
"documentsOne": "{count} dokument z datą",
|
||||
"documentsFew": "{count} dokumenty z datą",
|
||||
"documentsOther": "{count} dokumentów z datą",
|
||||
"lifeOne": "{count} wydarzenie życiowe",
|
||||
"lifeFew": "{count} wydarzenia życiowe",
|
||||
"lifeOther": "{count} wydarzeń życiowych",
|
||||
"cycleOne": "{count} cykl",
|
||||
"cycleFew": "{count} cykle",
|
||||
"cycleOther": "{count} cykli",
|
||||
"moodOne": "{count} wpis od {since}",
|
||||
"moodFew": "{count} wpisy od {since}",
|
||||
"moodOther": "{count} wpisów od {since}",
|
||||
"environmentOne": "{count} dzień z danymi o środowisku",
|
||||
"environmentFew": "{count} dni z danymi o środowisku",
|
||||
"environmentOther": "{count} dni z danymi o środowisku",
|
||||
"empty": "Jeszcze brak"
|
||||
},
|
||||
"gap": {
|
||||
"valuesEmpty": "Połącz źródła",
|
||||
"illnessEmpty": "Dodaj epizod",
|
||||
"medicationsWithoutStart": "Uzupełnij",
|
||||
"visitsEmpty": "Dodaj wizytę",
|
||||
"documentsUndated": "Przejrzyj",
|
||||
"labsEmpty": "Dodaj wynik",
|
||||
"vaccinationsEmpty": "Dodaj szczepienia",
|
||||
"lifeEventsEmpty": "Dodaj"
|
||||
},
|
||||
"gapLine": {
|
||||
"medicationsWithoutStartOne": "{count} lek bez daty rozpoczęcia",
|
||||
"medicationsWithoutStartFew": "{count} leki bez daty rozpoczęcia",
|
||||
"medicationsWithoutStartOther": "{count} leków bez daty rozpoczęcia",
|
||||
"documentsUndatedOne": "{count} dokument bez daty",
|
||||
"documentsUndatedFew": "{count} dokumenty bez daty",
|
||||
"documentsUndatedOther": "{count} dokumentów bez daty",
|
||||
"valuesEmpty": "Jeszcze brak wartości z dłuższego okresu",
|
||||
"illnessEmpty": "Jeszcze brak chorób z datą początku",
|
||||
"visitsEmpty": "Jeszcze brak odbytych wizyt",
|
||||
"labsEmpty": "Jeszcze brak wyników badań",
|
||||
"vaccinationsEmpty": "Jeszcze brak szczepień",
|
||||
"lifeEventsEmpty": "Brak wydarzeń życiowych"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legendSpan": "Okres",
|
||||
"legendEvent": "Zdarzenie",
|
||||
"legendMean": "Średnia miesięczna",
|
||||
"today": "Dziś",
|
||||
"noDataYet": "Jeszcze brak danych",
|
||||
"layers": "Warstwy",
|
||||
"layersLanes": "Ścieżki",
|
||||
"zoomLabel": "Zakres widoku",
|
||||
"chartLabel": "Oś czasu od {from} do {to}",
|
||||
"keyboardHint": "Strzałki przesuwają wybór, Home i End przeskakują na krańce, Enter otwiera dzień.",
|
||||
"loadFailed": "Nie udało się wczytać osi czasu.",
|
||||
"empty": {
|
||||
"title": "Na osi czasu jeszcze nic nie ma",
|
||||
"description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą."
|
||||
},
|
||||
"values": {
|
||||
"menuLabel": "Do trzech wartości",
|
||||
"none": "Brak wartości",
|
||||
"noneInWindow": "Brak wartości w tym okresie",
|
||||
"series": {
|
||||
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"caption": "Wpisy na osi czasu",
|
||||
"lane": "Ścieżka",
|
||||
"entry": "Wpis",
|
||||
"start": "Początek",
|
||||
"end": "Koniec"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Brak wpisów w tym miesiącu",
|
||||
"ongoing": "trwa",
|
||||
"range": "od {from} do {to}",
|
||||
"mean": "Ø {values}"
|
||||
},
|
||||
"chronicle": {
|
||||
"months": "Miesiące",
|
||||
"years": "Lata",
|
||||
"groupingLabel": "Grupowanie",
|
||||
"gap": "Od {from} do {to} · brak wpisów",
|
||||
"gapSingle": "{period} · brak wpisów",
|
||||
"ended": "Koniec: {label}",
|
||||
"afterDaysOne": "po 1 dniu",
|
||||
"afterDaysFew": "po {count} dniach",
|
||||
"afterDaysOther": "po {count} dniach",
|
||||
"notable": {
|
||||
"extremeHigh": "Najwyższa wartość od dawna",
|
||||
"extremeLow": "Najniższa wartość od dawna",
|
||||
"firstValue": "Pierwsza wartość pomiaru",
|
||||
"gap": "Dłuższa przerwa w wartościach"
|
||||
},
|
||||
"notableMeta": "Otwórz dzień, aby zobaczyć szczegóły"
|
||||
},
|
||||
"standingAllergiesOne": "{count} alergia",
|
||||
"standingAllergiesFew": "{count} alergie",
|
||||
"standingAllergiesOther": "{count} alergii"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Wydarzenie życiowe",
|
||||
"add": "Wydarzenie życiowe",
|
||||
"subtitle": "Punkt odniesienia na osi czasu, nie pomiar.",
|
||||
"privacy": "Tytuł i notatka są zapisywane w postaci zaszyfrowanej. Nie trafiają do żadnej AI.",
|
||||
"empty": "Brak wydarzeń życiowych",
|
||||
"emptyHint": "Przeprowadzka, narodziny dziecka czy nowa praca dają latom punkt odniesienia.",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
@@ -12279,7 +12417,8 @@
|
||||
"endDate": "Do",
|
||||
"note": "Notatka",
|
||||
"optionalTag": "opcjonalnie",
|
||||
"notePlaceholder": "Coś, co zechcesz wiedzieć później"
|
||||
"notePlaceholder": "Coś, co zechcesz wiedzieć później",
|
||||
"removeEnd": "Usuń datę zakończenia"
|
||||
},
|
||||
"category": {
|
||||
"FAMILY": "Rodzina",
|
||||
@@ -12292,6 +12431,21 @@
|
||||
"DAY": "Dzień",
|
||||
"MONTH": "Miesiąc",
|
||||
"YEAR": "Rok"
|
||||
},
|
||||
"edit": "Edytuj wydarzenie życiowe",
|
||||
"errors": {
|
||||
"titleRequired": "Nadaj wydarzeniu nazwę.",
|
||||
"titleTooLong": "Najwyżej 120 znaków.",
|
||||
"categoryRequired": "Wybierz rodzaj.",
|
||||
"dateRequired": "Podaj prawidłową datę.",
|
||||
"endBeforeStart": "Koniec jest przed początkiem.",
|
||||
"noteTooLong": "Najwyżej 2000 znaków."
|
||||
},
|
||||
"saveFailed": "Nie udało się zapisać.",
|
||||
"deleteFailed": "Nie udało się usunąć.",
|
||||
"deleteConfirm": {
|
||||
"title": "Usunąć to wydarzenie życiowe?",
|
||||
"body": "Tytuł i notatka znikną."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -80,8 +80,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
|
||||
day: "v1.42 day view and its entry points (#613)",
|
||||
timeline: "v1.42 timeline and its readiness inventory (#613)",
|
||||
lifeEvents: "v1.42 life-event form and list (#613)",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -231,6 +245,12 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
description: t("nav.capture.workoutDescription"),
|
||||
icon: Footprints,
|
||||
},
|
||||
{
|
||||
kind: "lifeEvent",
|
||||
label: t("nav.capture.lifeEvent"),
|
||||
description: t("nav.capture.lifeEventDescription"),
|
||||
icon: Flag,
|
||||
},
|
||||
];
|
||||
const options = allOptions.filter((opt) => offered.includes(opt.kind));
|
||||
|
||||
@@ -240,6 +260,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
mood: t("mood.addEntry"),
|
||||
symptom: t("symptoms.entry.sheetTitle"),
|
||||
workout: t("insights.workouts.manual.sheetTitle"),
|
||||
lifeEvent: t("lifeEvents.title"),
|
||||
};
|
||||
// `openKind === null` keeps the form sheet closed (the title is unread
|
||||
// then); the mood label is the harmless default, matching the prior
|
||||
@@ -316,6 +337,13 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{openKind === "lifeEvent" && (
|
||||
<LifeEventForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{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,161 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
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())}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `?day=` value the timeline accepts: a real date, not in the future. */
|
||||
export function acceptableDayParam(
|
||||
raw: string | null | undefined,
|
||||
today: string,
|
||||
): string | null {
|
||||
if (!raw || !isCalendarDateKey(raw)) return null;
|
||||
return raw <= today ? raw : null;
|
||||
}
|
||||
|
||||
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,480 @@
|
||||
"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 { 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 { acceptableDayParam, 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 `?day=` URL for the current page, other parameters kept. */
|
||||
export function dayHref(
|
||||
pathname: string,
|
||||
search: string,
|
||||
date: string,
|
||||
): string {
|
||||
const next = new URLSearchParams(search);
|
||||
next.set(DAY_QUERY_PARAM, date);
|
||||
return `${pathname}?${next.toString()}`;
|
||||
}
|
||||
|
||||
/** 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 = acceptableDayParam(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;
|
||||
|
||||
function openDay(date: string) {
|
||||
if (date > today) return;
|
||||
setPicked({ date, param: date });
|
||||
window.history.pushState(
|
||||
{ __healthlogTimelineDay: date },
|
||||
"",
|
||||
dayHref(pathname, searchParams.toString(), 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