mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge release/v1.42.0 with the day view into v142/t3-timeline
The timeline now opens a day through the shell's day layer (openDay) and reads ?day= with its parser instead of a copy of both; the capture picker's life-event form starts on the picker's defaultDate like the other forms.
This commit is contained in:
commit
2b89e627ab
68 files changed
+4733
-272
No files matched your search
@@ -54766,6 +54766,11 @@ components:
|
||||
description: The table this step produced (`r1`..), when it produced one.
|
||||
type: string
|
||||
pattern: ^r[1-8]$
|
||||
day:
|
||||
description: The calendar day a `get_day` step read, `YYYY-MM-DD`. A settled answer lists these days as links to each
|
||||
day; set only on `get_day` steps.
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
required:
|
||||
- id
|
||||
- tool
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
|
||||
/**
|
||||
* v1.42 — the day view and its doors.
|
||||
*
|
||||
* The record is the phone-sweep account `globalSetup` seeds: a week of blood
|
||||
* pressure, two lab readings, mood and an episode. The day itself
|
||||
* (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
|
||||
* the contract's shape, so the journeys pin the client: which door opens which
|
||||
* day, what the history does, and where no door is.
|
||||
*
|
||||
* - a chart point opens the day beside the chart (desktop);
|
||||
* - on a touch screen the first tap shows the value and the tooltip offers
|
||||
* the day, the second opens it as a bottom sheet;
|
||||
* - a date in a list opens the day while the row keeps its own target;
|
||||
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
|
||||
* the future is dropped;
|
||||
* - stepping to the neighbouring days never adds history entries;
|
||||
* - the dashboard's today area offers no door.
|
||||
*
|
||||
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`.
|
||||
*/
|
||||
|
||||
function dayFixture(date: string) {
|
||||
return {
|
||||
date,
|
||||
tz: "Europe/Berlin",
|
||||
counts: { values: 2, entries: 1 },
|
||||
running: [
|
||||
{
|
||||
kind: "illness",
|
||||
section: "illness",
|
||||
id: "ep1",
|
||||
title: "Common cold",
|
||||
sub: null,
|
||||
since: date,
|
||||
until: null,
|
||||
dayIndex: 1,
|
||||
dayCount: null,
|
||||
href: "/illness",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 124,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 118, hi: 128, n: 20 },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 79,
|
||||
unit: "mmHg",
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
source: "MANUAL",
|
||||
band: { lo: 76, hi: 82, n: 20 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
at: `${date}T07:00:00.000Z`,
|
||||
kind: "mood",
|
||||
section: "mood",
|
||||
id: "m1",
|
||||
title: "Mood: good",
|
||||
meta: null,
|
||||
note: null,
|
||||
docs: [],
|
||||
href: "/mood",
|
||||
},
|
||||
],
|
||||
notable: [],
|
||||
sections: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function mockDay(page: Page) {
|
||||
await page.route("**/api/day/**", async (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/api/day/index") {
|
||||
const from = url.searchParams.get("from") ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
from,
|
||||
to: url.searchParams.get("to") ?? "",
|
||||
days: {},
|
||||
notable: [],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const date = url.pathname.split("/").pop() ?? "";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: dayFixture(date), error: null }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test.describe("the day view", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page);
|
||||
});
|
||||
|
||||
test("a chart point opens its day beside the chart; Back closes it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/insights/blood-pressure");
|
||||
const plot = page.locator(
|
||||
'[data-slot="chart-plot"][data-day-links="true"]',
|
||||
);
|
||||
await expect(plot).toBeVisible();
|
||||
// The plot's right half holds the most recent points.
|
||||
const box = (await plot.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
|
||||
// A landmark beside the page, named by its date.
|
||||
await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
|
||||
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
// The chart beside it stays usable: the panel is not modal.
|
||||
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
|
||||
|
||||
await page.goBack();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).not.toHaveURL(/[?&]day=/);
|
||||
});
|
||||
|
||||
test("a date in a list opens the day; the row keeps its own target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/labs");
|
||||
await page
|
||||
.locator('[data-slot="lab-list-analyte"]')
|
||||
.filter({ hasText: MOBILE_ROUTES_ANALYTE })
|
||||
.first()
|
||||
.click();
|
||||
// The marker's readings, each dated.
|
||||
await page.locator('a[href$="/values"]').first().click();
|
||||
const link = page.locator('[data-slot="day-link"]:visible').first();
|
||||
await expect(link).toBeVisible();
|
||||
const day = await link.getAttribute("data-day");
|
||||
const before = page.url();
|
||||
await link.click();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day!,
|
||||
);
|
||||
// Same page underneath: the click opened the day, not the reading.
|
||||
expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
|
||||
// The reading the person came from sits on top, with the one before it.
|
||||
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("?day= opens the layer on any page; Escape closes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const day = isoDaysAgo(3);
|
||||
await page.goto(`/mood?day=${day}`);
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("a future or malformed ?day= is dropped without a word", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood?day=2999-01-01");
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await page.goto("/mood?day=2026-02-30");
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("stepping between days never adds a history entry", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-slot="day-link"]:visible').first().click();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
const start = await page.evaluate(() => window.history.length);
|
||||
const first = await page
|
||||
.locator('[data-slot="day-view"]')
|
||||
.getAttribute("data-day");
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.locator('[data-slot="day-prev"]').click();
|
||||
}
|
||||
await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
|
||||
"data-day",
|
||||
first!,
|
||||
);
|
||||
expect(await page.evaluate(() => window.history.length)).toBe(start);
|
||||
// One Back leaves the layer, however far it stepped.
|
||||
await page.goBack();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("on a phone the first tap shows the value, the second opens the day", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
|
||||
await page.goto("/insights/blood-pressure");
|
||||
const plot = page.locator(
|
||||
'[data-slot="chart-plot"][data-day-links="true"]',
|
||||
);
|
||||
await plot.scrollIntoViewIfNeeded();
|
||||
await expect(plot).toBeVisible();
|
||||
const box = (await plot.boundingBox())!;
|
||||
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
});
|
||||
|
||||
test("the dashboard's today area offers no day door", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.locator('[data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="chart-plot"][data-day-links="true"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Tag",
|
||||
"previousDay": "Vortag",
|
||||
"nextDay": "Folgetag",
|
||||
"close": "Tag schließen",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "In der Zeitleiste",
|
||||
"askCoach": "Im Coach nach diesem Tag fragen",
|
||||
"chartHint": "Klick auf einen Punkt öffnet den Tag.",
|
||||
"daysWithEntries": "Tage mit Einträgen",
|
||||
"loadFailed": "Dieser Tag konnte nicht geladen werden.",
|
||||
"dayOf": "Tag {n} von {m}",
|
||||
"dayN": "Tag {n}",
|
||||
"since": "Seit {date}",
|
||||
"since": "seit {date}",
|
||||
"usualRange": "Üblich {lo} bis {hi}",
|
||||
"groups": {
|
||||
"running": "Laufend",
|
||||
"running": "Laufend an diesem Tag",
|
||||
"values": "Werte",
|
||||
"happened": "Passiert"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Werte",
|
||||
"sleep": "Schlaf",
|
||||
"mood": "Stimmung",
|
||||
"assessments": "Fragebögen",
|
||||
"medications": "Medikamente",
|
||||
"illness": "Krankheit",
|
||||
"symptoms": "Symptome",
|
||||
"allergies": "Allergien",
|
||||
"labs": "Labor",
|
||||
"visits": "Arzt und Eingriffe",
|
||||
"vaccinations": "Impfungen",
|
||||
"checkups": "Vorsorge",
|
||||
"documents": "Dokumente",
|
||||
"workouts": "Workouts",
|
||||
"cycle": "Zyklus",
|
||||
"environment": "Umwelt",
|
||||
"lifestyle": "Lebensstil",
|
||||
"lifeEvents": "Lebensereignisse"
|
||||
"happened": "Was war"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Höchster Tageswert seit {since}",
|
||||
"extremeLow": "Niedrigster Tageswert seit {since}",
|
||||
"firstValue": "Erster Wert dieser Art",
|
||||
"gap": "{days} Tage ohne Wert"
|
||||
},
|
||||
"previousDayShortcut": "Vortag (Alt + Pfeil links)",
|
||||
"nextDayShortcut": "Folgetag (Alt + Pfeil rechts)",
|
||||
"captureShort": "Erfassen",
|
||||
"inTimelineShort": "Zeitleiste",
|
||||
"askCoachPrompt": "Was war am {date}?",
|
||||
"chartHintTouch": "Antippen zeigt den Wert.",
|
||||
"coachChips": "Tage in dieser Antwort",
|
||||
"description": "Alles, was deine Einträge für diesen Tag enthalten.",
|
||||
"openDocument": "Dokument öffnen",
|
||||
"previousReading": "Vorher, {date}",
|
||||
"rugLegend": "Punkte unter der Achse: Tage mit Einträgen · Ring: auffälliger Tag",
|
||||
"sheetExpand": "Auf volle Höhe aufziehen",
|
||||
"sheetShrink": "Verkleinern",
|
||||
"usualLabel": "Deine 30 Tage davor",
|
||||
"valuesLegend": "Punkt: dieser Tag · Strich: deine 30 Tage davor",
|
||||
"valuesAllOne": "Alle Werte",
|
||||
"valuesAllFew": "Alle {count} Werte",
|
||||
"valuesAllOther": "Alle {count} Werte",
|
||||
"valuesFewer": "Weniger Werte",
|
||||
"countValuesOne": "1 Wert",
|
||||
"countValuesFew": "{count} Werte",
|
||||
"countValuesOther": "{count} Werte",
|
||||
"countEntriesOne": "1 Eintrag",
|
||||
"countEntriesFew": "{count} Einträge",
|
||||
"countEntriesOther": "{count} Einträge",
|
||||
"sinceLastVisit": {
|
||||
"title": "Seit dem letzten Besuch am {date}",
|
||||
"countOne": "1 Eintrag",
|
||||
"countFew": "{count} Einträge",
|
||||
"countOther": "{count} Einträge",
|
||||
"allOne": "Alle anzeigen",
|
||||
"allFew": "Alle {count} anzeigen",
|
||||
"allOther": "Alle {count} anzeigen",
|
||||
"fewer": "Weniger anzeigen",
|
||||
"note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.",
|
||||
"report": "Arztbericht {date} bis heute"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Day",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"close": "Close day",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "In the timeline",
|
||||
"askCoach": "Ask the Coach about this day",
|
||||
"chartHint": "Click a point to open its day.",
|
||||
"daysWithEntries": "Days with entries",
|
||||
"loadFailed": "This day could not be loaded.",
|
||||
"dayOf": "Day {n} of {m}",
|
||||
"dayN": "Day {n}",
|
||||
"since": "Since {date}",
|
||||
"since": "since {date}",
|
||||
"usualRange": "Usually {lo} to {hi}",
|
||||
"groups": {
|
||||
"running": "Running",
|
||||
"running": "Running that day",
|
||||
"values": "Values",
|
||||
"happened": "Happened"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Values",
|
||||
"sleep": "Sleep",
|
||||
"mood": "Mood",
|
||||
"assessments": "Check-ins",
|
||||
"medications": "Medications",
|
||||
"illness": "Illness",
|
||||
"symptoms": "Symptoms",
|
||||
"allergies": "Allergies",
|
||||
"labs": "Labs",
|
||||
"visits": "Doctor and procedures",
|
||||
"vaccinations": "Vaccinations",
|
||||
"checkups": "Check-ups",
|
||||
"documents": "Documents",
|
||||
"workouts": "Workouts",
|
||||
"cycle": "Cycle",
|
||||
"environment": "Environment",
|
||||
"lifestyle": "Lifestyle",
|
||||
"lifeEvents": "Life events"
|
||||
"happened": "What happened"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Highest daily value since {since}",
|
||||
"extremeLow": "Lowest daily value since {since}",
|
||||
"firstValue": "First reading of this kind",
|
||||
"gap": "No reading for {days} days"
|
||||
},
|
||||
"previousDayShortcut": "Previous day (Alt + Left arrow)",
|
||||
"nextDayShortcut": "Next day (Alt + Right arrow)",
|
||||
"captureShort": "Log",
|
||||
"inTimelineShort": "Timeline",
|
||||
"askCoachPrompt": "What happened on {date}?",
|
||||
"chartHintTouch": "Tap a point to see its value.",
|
||||
"coachChips": "Days in this answer",
|
||||
"description": "Everything your records hold for this day.",
|
||||
"openDocument": "Open document",
|
||||
"previousReading": "Before, {date}",
|
||||
"rugLegend": "Dots under the axis: days with entries · ring: notable day",
|
||||
"sheetExpand": "Expand to full height",
|
||||
"sheetShrink": "Shrink",
|
||||
"usualLabel": "Your 30 days before",
|
||||
"valuesLegend": "Dot: this day · bar: your 30 days before",
|
||||
"valuesAllOne": "All values",
|
||||
"valuesAllFew": "All {count} values",
|
||||
"valuesAllOther": "All {count} values",
|
||||
"valuesFewer": "Fewer values",
|
||||
"countValuesOne": "1 value",
|
||||
"countValuesFew": "{count} values",
|
||||
"countValuesOther": "{count} values",
|
||||
"countEntriesOne": "1 entry",
|
||||
"countEntriesFew": "{count} entries",
|
||||
"countEntriesOther": "{count} entries",
|
||||
"sinceLastVisit": {
|
||||
"title": "Since the last visit on {date}",
|
||||
"countOne": "1 entry",
|
||||
"countFew": "{count} entries",
|
||||
"countOther": "{count} entries",
|
||||
"allOne": "Show all",
|
||||
"allFew": "Show all {count}",
|
||||
"allOther": "Show all {count}",
|
||||
"fewer": "Show fewer",
|
||||
"note": "Compiled from your entries, without judgement. Every date opens the whole day.",
|
||||
"report": "Doctor report from {date} to today"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Día",
|
||||
"previousDay": "Día anterior",
|
||||
"nextDay": "Día siguiente",
|
||||
"close": "Cerrar día",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "En la cronología",
|
||||
"askCoach": "Preguntar al Coach por este día",
|
||||
"chartHint": "Haz clic en un punto para abrir su día.",
|
||||
"daysWithEntries": "Días con entradas",
|
||||
"loadFailed": "No se pudo cargar este día.",
|
||||
"dayOf": "Día {n} de {m}",
|
||||
"dayN": "Día {n}",
|
||||
"since": "Desde {date}",
|
||||
"since": "desde {date}",
|
||||
"usualRange": "Habitual de {lo} a {hi}",
|
||||
"groups": {
|
||||
"running": "En curso",
|
||||
"running": "En curso ese día",
|
||||
"values": "Valores",
|
||||
"happened": "Sucedió"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Valores",
|
||||
"sleep": "Sueño",
|
||||
"mood": "Estado de ánimo",
|
||||
"assessments": "Cuestionarios",
|
||||
"medications": "Medicamentos",
|
||||
"illness": "Enfermedad",
|
||||
"symptoms": "Síntomas",
|
||||
"allergies": "Alergias",
|
||||
"labs": "Laboratorio",
|
||||
"visits": "Médico e intervenciones",
|
||||
"vaccinations": "Vacunas",
|
||||
"checkups": "Chequeos",
|
||||
"documents": "Documentos",
|
||||
"workouts": "Entrenamientos",
|
||||
"cycle": "Ciclo",
|
||||
"environment": "Entorno",
|
||||
"lifestyle": "Estilo de vida",
|
||||
"lifeEvents": "Acontecimientos vitales"
|
||||
"happened": "Qué pasó"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valor diario más alto desde {since}",
|
||||
"extremeLow": "Valor diario más bajo desde {since}",
|
||||
"firstValue": "Primera medición de este tipo",
|
||||
"gap": "{days} días sin medición"
|
||||
},
|
||||
"previousDayShortcut": "Día anterior (Alt + flecha izquierda)",
|
||||
"nextDayShortcut": "Día siguiente (Alt + flecha derecha)",
|
||||
"captureShort": "Registrar",
|
||||
"inTimelineShort": "Cronología",
|
||||
"askCoachPrompt": "¿Qué pasó el {date}?",
|
||||
"chartHintTouch": "Toca un punto para ver su valor.",
|
||||
"coachChips": "Días en esta respuesta",
|
||||
"description": "Todo lo que tus registros contienen para este día.",
|
||||
"openDocument": "Abrir documento",
|
||||
"previousReading": "Antes, {date}",
|
||||
"rugLegend": "Puntos bajo el eje: días con entradas · anillo: día destacado",
|
||||
"sheetExpand": "Ampliar a toda la altura",
|
||||
"sheetShrink": "Reducir",
|
||||
"usualLabel": "Tus 30 días anteriores",
|
||||
"valuesLegend": "Punto: este día · barra: tus 30 días anteriores",
|
||||
"valuesAllOne": "Todos los valores",
|
||||
"valuesAllFew": "Los {count} valores",
|
||||
"valuesAllOther": "Los {count} valores",
|
||||
"valuesFewer": "Menos valores",
|
||||
"countValuesOne": "1 valor",
|
||||
"countValuesFew": "{count} valores",
|
||||
"countValuesOther": "{count} valores",
|
||||
"countEntriesOne": "1 entrada",
|
||||
"countEntriesFew": "{count} entradas",
|
||||
"countEntriesOther": "{count} entradas",
|
||||
"sinceLastVisit": {
|
||||
"title": "Desde la última visita el {date}",
|
||||
"countOne": "1 entrada",
|
||||
"countFew": "{count} entradas",
|
||||
"countOther": "{count} entradas",
|
||||
"allOne": "Mostrar todo",
|
||||
"allFew": "Mostrar las {count}",
|
||||
"allOther": "Mostrar las {count}",
|
||||
"fewer": "Mostrar menos",
|
||||
"note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.",
|
||||
"report": "Informe médico del {date} hasta hoy"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Jour",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"close": "Fermer le jour",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "Dans la chronologie",
|
||||
"askCoach": "Interroger le Coach sur ce jour",
|
||||
"chartHint": "Cliquez sur un point pour ouvrir son jour.",
|
||||
"daysWithEntries": "Jours avec des entrées",
|
||||
"loadFailed": "Impossible de charger ce jour.",
|
||||
"dayOf": "Jour {n} sur {m}",
|
||||
"dayN": "Jour {n}",
|
||||
"since": "Depuis le {date}",
|
||||
"since": "depuis le {date}",
|
||||
"usualRange": "Habituellement de {lo} à {hi}",
|
||||
"groups": {
|
||||
"running": "En cours",
|
||||
"running": "En cours ce jour-là",
|
||||
"values": "Valeurs",
|
||||
"happened": "Événements"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Valeurs",
|
||||
"sleep": "Sommeil",
|
||||
"mood": "Humeur",
|
||||
"assessments": "Questionnaires",
|
||||
"medications": "Médicaments",
|
||||
"illness": "Maladie",
|
||||
"symptoms": "Symptômes",
|
||||
"allergies": "Allergies",
|
||||
"labs": "Analyses",
|
||||
"visits": "Médecin et interventions",
|
||||
"vaccinations": "Vaccinations",
|
||||
"checkups": "Dépistages",
|
||||
"documents": "Documents",
|
||||
"workouts": "Entraînements",
|
||||
"cycle": "Cycle",
|
||||
"environment": "Environnement",
|
||||
"lifestyle": "Mode de vie",
|
||||
"lifeEvents": "Événements de vie"
|
||||
"happened": "Ce qui s’est passé"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valeur journalière la plus haute depuis {since}",
|
||||
"extremeLow": "Valeur journalière la plus basse depuis {since}",
|
||||
"firstValue": "Première mesure de ce type",
|
||||
"gap": "{days} jours sans mesure"
|
||||
},
|
||||
"previousDayShortcut": "Jour précédent (Alt + flèche gauche)",
|
||||
"nextDayShortcut": "Jour suivant (Alt + flèche droite)",
|
||||
"captureShort": "Enregistrer",
|
||||
"inTimelineShort": "Chronologie",
|
||||
"askCoachPrompt": "Que s’est-il passé le {date} ?",
|
||||
"chartHintTouch": "Touchez un point pour voir sa valeur.",
|
||||
"coachChips": "Jours dans cette réponse",
|
||||
"description": "Tout ce que vos entrées contiennent pour ce jour.",
|
||||
"openDocument": "Ouvrir le document",
|
||||
"previousReading": "Avant, {date}",
|
||||
"rugLegend": "Points sous l’axe : jours avec entrées · anneau : jour remarquable",
|
||||
"sheetExpand": "Agrandir en pleine hauteur",
|
||||
"sheetShrink": "Réduire",
|
||||
"usualLabel": "Vos 30 jours précédents",
|
||||
"valuesLegend": "Point : ce jour · barre : vos 30 jours précédents",
|
||||
"valuesAllOne": "Toutes les valeurs",
|
||||
"valuesAllFew": "Les {count} valeurs",
|
||||
"valuesAllOther": "Les {count} valeurs",
|
||||
"valuesFewer": "Moins de valeurs",
|
||||
"countValuesOne": "1 valeur",
|
||||
"countValuesFew": "{count} valeurs",
|
||||
"countValuesOther": "{count} valeurs",
|
||||
"countEntriesOne": "1 entrée",
|
||||
"countEntriesFew": "{count} entrées",
|
||||
"countEntriesOther": "{count} entrées",
|
||||
"sinceLastVisit": {
|
||||
"title": "Depuis la dernière visite le {date}",
|
||||
"countOne": "1 entrée",
|
||||
"countFew": "{count} entrées",
|
||||
"countOther": "{count} entrées",
|
||||
"allOne": "Tout afficher",
|
||||
"allFew": "Afficher les {count}",
|
||||
"allOther": "Afficher les {count}",
|
||||
"fewer": "Afficher moins",
|
||||
"note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.",
|
||||
"report": "Rapport médical du {date} à aujourd’hui"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Giorno",
|
||||
"previousDay": "Giorno precedente",
|
||||
"nextDay": "Giorno successivo",
|
||||
"close": "Chiudi giorno",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "Nella cronologia",
|
||||
"askCoach": "Chiedi al Coach di questo giorno",
|
||||
"chartHint": "Fai clic su un punto per aprire il suo giorno.",
|
||||
"daysWithEntries": "Giorni con voci",
|
||||
"loadFailed": "Impossibile caricare questo giorno.",
|
||||
"dayOf": "Giorno {n} di {m}",
|
||||
"dayN": "Giorno {n}",
|
||||
"since": "Dal {date}",
|
||||
"since": "dal {date}",
|
||||
"usualRange": "Di solito da {lo} a {hi}",
|
||||
"groups": {
|
||||
"running": "In corso",
|
||||
"running": "In corso quel giorno",
|
||||
"values": "Valori",
|
||||
"happened": "Accaduto"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Valori",
|
||||
"sleep": "Sonno",
|
||||
"mood": "Umore",
|
||||
"assessments": "Questionari",
|
||||
"medications": "Farmaci",
|
||||
"illness": "Malattia",
|
||||
"symptoms": "Sintomi",
|
||||
"allergies": "Allergie",
|
||||
"labs": "Esami",
|
||||
"visits": "Medico e interventi",
|
||||
"vaccinations": "Vaccinazioni",
|
||||
"checkups": "Controlli",
|
||||
"documents": "Documenti",
|
||||
"workouts": "Allenamenti",
|
||||
"cycle": "Ciclo",
|
||||
"environment": "Ambiente",
|
||||
"lifestyle": "Stile di vita",
|
||||
"lifeEvents": "Eventi di vita"
|
||||
"happened": "Cosa è successo"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valore giornaliero più alto dal {since}",
|
||||
"extremeLow": "Valore giornaliero più basso dal {since}",
|
||||
"firstValue": "Prima misurazione di questo tipo",
|
||||
"gap": "{days} giorni senza misurazione"
|
||||
},
|
||||
"previousDayShortcut": "Giorno precedente (Alt + freccia sinistra)",
|
||||
"nextDayShortcut": "Giorno successivo (Alt + freccia destra)",
|
||||
"captureShort": "Registra",
|
||||
"inTimelineShort": "Cronologia",
|
||||
"askCoachPrompt": "Cosa è successo il {date}?",
|
||||
"chartHintTouch": "Tocca un punto per vederne il valore.",
|
||||
"coachChips": "Giorni in questa risposta",
|
||||
"description": "Tutto ciò che le tue voci contengono per questo giorno.",
|
||||
"openDocument": "Apri documento",
|
||||
"previousReading": "Prima, {date}",
|
||||
"rugLegend": "Punti sotto l’asse: giorni con voci · anello: giorno rilevante",
|
||||
"sheetExpand": "Espandi a tutta altezza",
|
||||
"sheetShrink": "Riduci",
|
||||
"usualLabel": "I tuoi 30 giorni precedenti",
|
||||
"valuesLegend": "Punto: questo giorno · barra: i tuoi 30 giorni precedenti",
|
||||
"valuesAllOne": "Tutti i valori",
|
||||
"valuesAllFew": "Tutti i {count} valori",
|
||||
"valuesAllOther": "Tutti i {count} valori",
|
||||
"valuesFewer": "Meno valori",
|
||||
"countValuesOne": "1 valore",
|
||||
"countValuesFew": "{count} valori",
|
||||
"countValuesOther": "{count} valori",
|
||||
"countEntriesOne": "1 voce",
|
||||
"countEntriesFew": "{count} voci",
|
||||
"countEntriesOther": "{count} voci",
|
||||
"sinceLastVisit": {
|
||||
"title": "Dall’ultima visita del {date}",
|
||||
"countOne": "1 voce",
|
||||
"countFew": "{count} voci",
|
||||
"countOther": "{count} voci",
|
||||
"allOne": "Mostra tutto",
|
||||
"allFew": "Mostra tutte le {count}",
|
||||
"allOther": "Mostra tutte le {count}",
|
||||
"fewer": "Mostra meno",
|
||||
"note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.",
|
||||
"report": "Referto medico dal {date} a oggi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+38
-23
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "하루",
|
||||
"previousDay": "전날",
|
||||
"nextDay": "다음 날",
|
||||
"close": "하루 닫기",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "타임라인에서 보기",
|
||||
"askCoach": "이 날에 대해 코치에게 묻기",
|
||||
"chartHint": "점을 클릭하면 그날이 열려요.",
|
||||
"daysWithEntries": "기록이 있는 날",
|
||||
"loadFailed": "이 날을 불러오지 못했어요.",
|
||||
"dayOf": "{m}일 중 {n}일째",
|
||||
"dayN": "{n}일째",
|
||||
"since": "{date}부터",
|
||||
"usualRange": "평소 {lo}~{hi}",
|
||||
"groups": {
|
||||
"running": "진행 중",
|
||||
"running": "그날 진행 중",
|
||||
"values": "수치",
|
||||
"happened": "있었던 일"
|
||||
},
|
||||
"sections": {
|
||||
"values": "수치",
|
||||
"sleep": "수면",
|
||||
"mood": "기분",
|
||||
"assessments": "설문",
|
||||
"medications": "약",
|
||||
"illness": "질환",
|
||||
"symptoms": "증상",
|
||||
"allergies": "알레르기",
|
||||
"labs": "검사",
|
||||
"visits": "진료와 시술",
|
||||
"vaccinations": "예방접종",
|
||||
"checkups": "건강검진",
|
||||
"documents": "문서",
|
||||
"workouts": "운동",
|
||||
"cycle": "생리 주기",
|
||||
"environment": "환경",
|
||||
"lifestyle": "생활 습관",
|
||||
"lifeEvents": "삶의 사건"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "{since} 이후 가장 높은 하루 값",
|
||||
"extremeLow": "{since} 이후 가장 낮은 하루 값",
|
||||
"firstValue": "이 종류의 첫 측정",
|
||||
"gap": "{days}일 동안 측정 없음"
|
||||
},
|
||||
"previousDayShortcut": "전날 (Alt + 왼쪽 화살표)",
|
||||
"nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)",
|
||||
"captureShort": "기록",
|
||||
"inTimelineShort": "타임라인",
|
||||
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
|
||||
"chartHintTouch": "점을 탭하면 값이 보여요.",
|
||||
"coachChips": "이 답변에 쓰인 날",
|
||||
"description": "이 날에 대한 모든 기록이에요.",
|
||||
"openDocument": "문서 열기",
|
||||
"previousReading": "이전, {date}",
|
||||
"rugLegend": "축 아래 점: 기록이 있는 날 · 고리: 눈에 띄는 날",
|
||||
"sheetExpand": "전체 높이로 펼치기",
|
||||
"sheetShrink": "줄이기",
|
||||
"usualLabel": "직전 30일",
|
||||
"valuesLegend": "점: 이 날 · 막대: 직전 30일",
|
||||
"valuesAllOne": "모든 수치",
|
||||
"valuesAllFew": "수치 {count}개 모두",
|
||||
"valuesAllOther": "수치 {count}개 모두",
|
||||
"valuesFewer": "수치 줄여 보기",
|
||||
"countValuesOne": "수치 1개",
|
||||
"countValuesFew": "수치 {count}개",
|
||||
"countValuesOther": "수치 {count}개",
|
||||
"countEntriesOne": "기록 1개",
|
||||
"countEntriesFew": "기록 {count}개",
|
||||
"countEntriesOther": "기록 {count}개",
|
||||
"sinceLastVisit": {
|
||||
"title": "{date} 마지막 방문 이후",
|
||||
"countOne": "기록 1개",
|
||||
"countFew": "기록 {count}개",
|
||||
"countOther": "기록 {count}개",
|
||||
"allOne": "모두 보기",
|
||||
"allFew": "{count}개 모두 보기",
|
||||
"allOther": "{count}개 모두 보기",
|
||||
"fewer": "줄여 보기",
|
||||
"note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.",
|
||||
"report": "{date}부터 오늘까지 진료 보고서"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
+40
-25
@@ -12165,7 +12165,6 @@
|
||||
}
|
||||
},
|
||||
"day": {
|
||||
"title": "Dzień",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
"nextDay": "Następny dzień",
|
||||
"close": "Zamknij dzień",
|
||||
@@ -12177,42 +12176,58 @@
|
||||
"inTimeline": "Na osi czasu",
|
||||
"askCoach": "Zapytaj Coacha o ten dzień",
|
||||
"chartHint": "Kliknij punkt, aby otworzyć jego dzień.",
|
||||
"daysWithEntries": "Dni z wpisami",
|
||||
"loadFailed": "Nie udało się wczytać tego dnia.",
|
||||
"dayOf": "Dzień {n} z {m}",
|
||||
"dayN": "Dzień {n}",
|
||||
"since": "Od {date}",
|
||||
"since": "od {date}",
|
||||
"usualRange": "Zwykle od {lo} do {hi}",
|
||||
"groups": {
|
||||
"running": "W toku",
|
||||
"running": "W toku tego dnia",
|
||||
"values": "Wartości",
|
||||
"happened": "Wydarzyło się"
|
||||
},
|
||||
"sections": {
|
||||
"values": "Wartości",
|
||||
"sleep": "Sen",
|
||||
"mood": "Nastrój",
|
||||
"assessments": "Kwestionariusze",
|
||||
"medications": "Leki",
|
||||
"illness": "Choroba",
|
||||
"symptoms": "Objawy",
|
||||
"allergies": "Alergie",
|
||||
"labs": "Badania",
|
||||
"visits": "Lekarz i zabiegi",
|
||||
"vaccinations": "Szczepienia",
|
||||
"checkups": "Badania profilaktyczne",
|
||||
"documents": "Dokumenty",
|
||||
"workouts": "Treningi",
|
||||
"cycle": "Cykl",
|
||||
"environment": "Otoczenie",
|
||||
"lifestyle": "Styl życia",
|
||||
"lifeEvents": "Wydarzenia życiowe"
|
||||
"happened": "Co się działo"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Najwyższa wartość dzienna od {since}",
|
||||
"extremeLow": "Najniższa wartość dzienna od {since}",
|
||||
"firstValue": "Pierwszy pomiar tego rodzaju",
|
||||
"gap": "{days} dni bez pomiaru"
|
||||
},
|
||||
"previousDayShortcut": "Poprzedni dzień (Alt + strzałka w lewo)",
|
||||
"nextDayShortcut": "Następny dzień (Alt + strzałka w prawo)",
|
||||
"captureShort": "Zapisz",
|
||||
"inTimelineShort": "Oś czasu",
|
||||
"askCoachPrompt": "Co się działo {date}?",
|
||||
"chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.",
|
||||
"coachChips": "Dni w tej odpowiedzi",
|
||||
"description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
|
||||
"openDocument": "Otwórz dokument",
|
||||
"previousReading": "Wcześniej, {date}",
|
||||
"rugLegend": "Punkty pod osią: dni z wpisami · pierścień: dzień godny uwagi",
|
||||
"sheetExpand": "Rozwiń na pełną wysokość",
|
||||
"sheetShrink": "Zmniejsz",
|
||||
"usualLabel": "Twoje 30 dni wcześniej",
|
||||
"valuesLegend": "Punkt: ten dzień · pasek: Twoje 30 dni wcześniej",
|
||||
"valuesAllOne": "Wszystkie wartości",
|
||||
"valuesAllFew": "Wszystkie {count} wartości",
|
||||
"valuesAllOther": "Wszystkie {count} wartości",
|
||||
"valuesFewer": "Mniej wartości",
|
||||
"countValuesOne": "1 wartość",
|
||||
"countValuesFew": "{count} wartości",
|
||||
"countValuesOther": "{count} wartości",
|
||||
"countEntriesOne": "1 wpis",
|
||||
"countEntriesFew": "{count} wpisy",
|
||||
"countEntriesOther": "{count} wpisów",
|
||||
"sinceLastVisit": {
|
||||
"title": "Od ostatniej wizyty {date}",
|
||||
"countOne": "1 wpis",
|
||||
"countFew": "{count} wpisy",
|
||||
"countOther": "{count} wpisów",
|
||||
"allOne": "Pokaż wszystko",
|
||||
"allFew": "Pokaż wszystkie {count}",
|
||||
"allOther": "Pokaż wszystkie {count}",
|
||||
"fewer": "Pokaż mniej",
|
||||
"note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.",
|
||||
"report": "Raport lekarski od {date} do dziś"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
|
||||
@@ -79,7 +79,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)",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function InsightsBlutdruckPage() {
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
|
||||
{/* v1.12.4 — target card sits between the chart and the assessment on
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function InsightsBmiPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="bmi" />
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function InsightsPulsPage() {
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
|
||||
{/* S11 — the intraday "shape of the day" layer: 10-minute mean heart
|
||||
|
||||
@@ -203,6 +203,7 @@ export default function InsightsGewichtPage() {
|
||||
valueScale={weightScale}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* v1.42 — the chart as a door to its days, and where it is NOT one.
|
||||
*
|
||||
* A metric sub-page (`dayLinks`) gets the plot click, the caption saying so,
|
||||
* the row of day dots and a day link in every daily row of its data table.
|
||||
* A tile's mini chart and every mount that does not ask (the dashboard) get
|
||||
* none of it: too small to hit, and the tile has its own destination.
|
||||
*/
|
||||
|
||||
function series(count: number) {
|
||||
const base = Date.now() - 12 * 3_600_000;
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: `p${i}`,
|
||||
timestamp: base - (count - 1 - i) * 86_400_000,
|
||||
PULSE: 60 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
async function renderChart(extraProps: Record<string, unknown>) {
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
keepPreviousData: (previous: unknown) => previous,
|
||||
useQuery: () => ({ data: series(10), isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" {...extraProps} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
return html;
|
||||
}
|
||||
|
||||
describe("<HealthChart> day links", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("a metric page's chart opens its days: plot, caption and table", async () => {
|
||||
const html = await renderChart({ dayLinks: true, showDataTable: true });
|
||||
expect(html).toContain('data-day-links="true"');
|
||||
expect(html).toContain('data-slot="chart-day-caption"');
|
||||
expect(html).toContain("Click a point to open its day.");
|
||||
const links = html.match(/data-slot="day-link"/g) ?? [];
|
||||
expect(links).toHaveLength(10);
|
||||
expect(html).toMatch(/href="[^"]*\?day=\d{4}-\d{2}-\d{2}"/);
|
||||
});
|
||||
|
||||
it("a chart that does not ask stays a chart", async () => {
|
||||
const html = await renderChart({ showDataTable: true });
|
||||
expect(html).not.toContain('data-day-links="true"');
|
||||
expect(html).not.toContain('data-slot="chart-day-caption"');
|
||||
expect(html).not.toContain('data-slot="day-link"');
|
||||
});
|
||||
|
||||
it("a mini chart in a tile never opens a day, even when asked", async () => {
|
||||
const html = await renderChart({ dayLinks: true, mini: true });
|
||||
expect(html).not.toContain('data-day-links="true"');
|
||||
expect(html).not.toContain('data-slot="chart-day-caption"');
|
||||
expect(html).not.toContain('data-slot="day-link"');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
|
||||
import { chartPointDayKey } from "@/components/day/chart-day";
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,12 @@ export interface ChartDataTableProps {
|
||||
bucket: ChartBucketType;
|
||||
/** Metric name for the table caption. */
|
||||
metricLabel: string;
|
||||
/**
|
||||
* v1.42 — each date cell opens its day: the keyboard and screen-reader
|
||||
* way to a day from a chart, which has no keyboard of its own. Daily rows
|
||||
* only; a week or month row has no single day.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
const BUCKET_CAPTION_KEY: Record<ChartBucketType, string> = {
|
||||
@@ -93,6 +101,7 @@ export function ChartDataTable({
|
||||
formatDate,
|
||||
bucket,
|
||||
metricLabel,
|
||||
dayLinks = false,
|
||||
}: ChartDataTableProps) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -100,6 +109,21 @@ export function ChartDataTable({
|
||||
|
||||
if (points.length === 0 || columns.length === 0) return null;
|
||||
|
||||
// The row's values, carried to the top of the day it opens.
|
||||
const dayFocusOf = (point: ChartTablePoint) => {
|
||||
const values = columns
|
||||
.map((column) => point[column.key])
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
return values.length > 0
|
||||
? {
|
||||
label: metricLabel,
|
||||
value: values.map(formatValue).join("/"),
|
||||
unit,
|
||||
types: columns.map((column) => column.key),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Newest first, matching the measurement list — the two places the app
|
||||
// prints the same readings must not disagree on reading order.
|
||||
const rows = [...points].reverse();
|
||||
@@ -170,7 +194,16 @@ export function ChartDataTable({
|
||||
scope="row"
|
||||
className="text-foreground h-auto p-2 font-normal"
|
||||
>
|
||||
{formatDate(new Date(point.timestamp))}
|
||||
{dayLinks && bucket === "day" ? (
|
||||
<DayLink
|
||||
date={chartPointDayKey(point.timestamp)}
|
||||
focus={dayFocusOf(point)}
|
||||
>
|
||||
{formatDate(new Date(point.timestamp))}
|
||||
</DayLink>
|
||||
) : (
|
||||
formatDate(new Date(point.timestamp))
|
||||
)}
|
||||
</TableHead>
|
||||
{columns.map((column) => {
|
||||
const value = point[column.key];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Apple-Health-style rich tooltip for chart wrappers (B1a v1.4.16).
|
||||
@@ -51,6 +51,12 @@ export interface RichChartTooltipProps {
|
||||
label?: string;
|
||||
/** Already-shaped, sorted rows. The tooltip never re-orders. */
|
||||
rows: RichTooltipRow[];
|
||||
/**
|
||||
* v1.42 — one action under the rows, e.g. "View the whole day" on a touch
|
||||
* screen, where the tooltip is pinned by a tap and is the way onward. The
|
||||
* chart makes the tooltip take pointer events only when it passes one.
|
||||
*/
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +73,7 @@ export function RichChartTooltip({
|
||||
active,
|
||||
label,
|
||||
rows,
|
||||
action,
|
||||
}: RichChartTooltipProps): ReactElement | null {
|
||||
if (!active || rows.length === 0) return null;
|
||||
|
||||
@@ -120,6 +127,7 @@ export function RichChartTooltip({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{action ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { isOpenableDay } from "@/components/day/day-url";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -100,7 +103,13 @@ export function ComplianceHeatmap({
|
||||
* tooltip; a tap outside any cell clears it (wired below).
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/** v1.42 — the day a pinned (touch) tooltip offers to open. */
|
||||
day?: string;
|
||||
} | null>(null);
|
||||
// v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
|
||||
// which then offers the day. The pointer type of the press decides.
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
|
||||
// v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
|
||||
// touch user can clear the per-cell detail without scrolling the
|
||||
@@ -350,6 +359,7 @@ export function ComplianceHeatmap({
|
||||
return (
|
||||
<rect
|
||||
key={cell.dateKey}
|
||||
data-day={cell.dateKey}
|
||||
x={labelWidth + cell.col * step}
|
||||
y={headerHeight + cell.row * step}
|
||||
width={cellSize}
|
||||
@@ -379,14 +389,27 @@ export function ComplianceHeatmap({
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.data.expected > 0 &&
|
||||
isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.data.expected === 0) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -408,7 +431,10 @@ export function ComplianceHeatmap({
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
data-slot="compliance-heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
@@ -420,6 +446,20 @@ export function ComplianceHeatmap({
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="heatmap-open-day"
|
||||
onClick={() => {
|
||||
const day = tooltip.day;
|
||||
setTooltip(null);
|
||||
if (day) openDay(day, { trigger: containerRef.current });
|
||||
}}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.openDay")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -77,6 +77,16 @@ import {
|
||||
} from "@/lib/charts/window-stats";
|
||||
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
|
||||
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
|
||||
import {
|
||||
ChartDayCaption,
|
||||
DayRug,
|
||||
TooltipDayAction,
|
||||
chartPointDayKey,
|
||||
openChartDay,
|
||||
useCoarsePointer,
|
||||
} from "@/components/day/chart-day";
|
||||
import { useOpenDay } from "@/components/day/day-layer-controller";
|
||||
import { isWholeNumberType } from "@/components/day/use-day-value-format";
|
||||
|
||||
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
|
||||
// "the last 7 days", not "the last 7 readings". The labels said "points"
|
||||
@@ -321,6 +331,15 @@ interface HealthChartProps {
|
||||
* bury the dashboard it is meant to summarise.
|
||||
*/
|
||||
showDataTable?: boolean;
|
||||
/**
|
||||
* v1.42 — the chart is a door to its days. A click on a point opens that
|
||||
* day over the page (on a touch screen the tooltip offers it), the open day
|
||||
* is marked with a dashed line, and a row of dots under the axis marks the
|
||||
* days that hold anything. Only while the chart is drawn in days, and never
|
||||
* in `mini` mode: a tile's sparkline has its own destination and targets
|
||||
* too small to hit. The metric sub-pages turn it on; the dashboard does not.
|
||||
*/
|
||||
dayLinks?: boolean;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -532,15 +551,22 @@ export interface ResolvedVerticalMarker {
|
||||
|
||||
export function resolveVerticalMarkerPositions(
|
||||
markers: Array<{ date: string; label?: string; color?: string }> | undefined,
|
||||
chartData: Array<{ date: string }> | undefined,
|
||||
chartData: Array<{ date: string; timestamp?: number }> | undefined,
|
||||
): ResolvedVerticalMarker[] {
|
||||
if (!markers || !chartData || chartData.length === 0) return [];
|
||||
const indexByDate = new Map<string, number>();
|
||||
for (const [i, point] of chartData.entries()) {
|
||||
// Last-write-wins — multiple bucket-aggregated points should never
|
||||
// share the same day key, but defensively keep the latest if they
|
||||
// do.
|
||||
indexByDate.set(point.date, i);
|
||||
// do. A drawn point's `date` is its display label ("12.03."), so the
|
||||
// day key comes from its timestamp, which a daily point holds at noon
|
||||
// UTC of its day; a bare `{ date }` (the pure-helper tests) is the key.
|
||||
indexByDate.set(
|
||||
typeof point.timestamp === "number"
|
||||
? chartPointDayKey(point.timestamp)
|
||||
: point.date,
|
||||
i,
|
||||
);
|
||||
}
|
||||
const out: ResolvedVerticalMarker[] = [];
|
||||
for (const marker of markers) {
|
||||
@@ -656,6 +682,7 @@ export function HealthChart({
|
||||
preloadedSeries,
|
||||
preloadedCoverageDays,
|
||||
showDataTable = false,
|
||||
dayLinks = false,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// A mount without the prop used to pin Europe/Berlin, so those charts
|
||||
@@ -1702,6 +1729,46 @@ export function HealthChart({
|
||||
chartData,
|
||||
);
|
||||
|
||||
// v1.42 — the chart as a door to its days (`dayLinks`). Daily points only:
|
||||
// a week or month point averages many days and has no one day to open.
|
||||
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
|
||||
const openDayKey = useOpenDay();
|
||||
const coarsePointer = useCoarsePointer();
|
||||
const plotRef = useRef<HTMLDivElement>(null);
|
||||
const openDayIndex =
|
||||
dayLinksActive && openDayKey
|
||||
? resolveVerticalMarkerPositions([{ date: openDayKey }], chartData)[0]
|
||||
?.pointIndex
|
||||
: undefined;
|
||||
const openDayPoint =
|
||||
openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
|
||||
// What the day shows at its top when it is opened from a point: the
|
||||
// chart's own name and the point's value as the tooltip reads it.
|
||||
const dayFocusFor = (point: ChartDataPoint) => {
|
||||
const parts = types
|
||||
.map((type) => ({ type, v: point[type] }))
|
||||
.filter((p): p is { type: string; v: number } => typeof p.v === "number");
|
||||
if (parts.length === 0) return null;
|
||||
return {
|
||||
label: title,
|
||||
// A pressure or a pulse reads in whole numbers on the day, as in every
|
||||
// list; the rest keep the tooltip's one decimal.
|
||||
value: parts
|
||||
.map(({ type, v }) =>
|
||||
valueMode === "raw" && isWholeNumberType(type)
|
||||
? fmt.number(Math.round(v), 0)
|
||||
: formatTooltipValue(v),
|
||||
)
|
||||
.join("/"),
|
||||
unit,
|
||||
types: valueMode === "raw" ? types : [],
|
||||
};
|
||||
};
|
||||
const openPointDay = (point: ChartDataPoint | undefined) => {
|
||||
if (!point) return;
|
||||
openChartDay(point.timestamp, dayFocusFor(point), plotRef.current);
|
||||
};
|
||||
|
||||
const showContextDetails = showMA || showTrend || showBands;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
@@ -1958,7 +2025,11 @@ export function HealthChart({
|
||||
// gate on; this slot is rendered by the data branch only, so it
|
||||
// says the same thing without depending on Recharts' markup.
|
||||
data-slot="chart-plot"
|
||||
className="relative z-10 h-full touch-pan-y"
|
||||
data-day-links={dayLinksActive ? "true" : undefined}
|
||||
ref={plotRef}
|
||||
className={`relative z-10 h-full touch-pan-y ${
|
||||
dayLinksActive && !coarsePointer ? "cursor-pointer" : ""
|
||||
}`}
|
||||
role="img"
|
||||
aria-label={chartAriaLabel}
|
||||
>
|
||||
@@ -1967,6 +2038,17 @@ export function HealthChart({
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
accessibilityLayer
|
||||
onClick={
|
||||
dayLinksActive && !coarsePointer
|
||||
? (state) => {
|
||||
const index = Number(state?.activeTooltipIndex);
|
||||
if (!Number.isInteger(index)) return;
|
||||
openPointDay(
|
||||
(chartDataWithCompare ?? chartData)?.[index],
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -2129,6 +2211,37 @@ export function HealthChart({
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
{/* v1.42 — the open day: a dashed line through it and a
|
||||
ring on each of its values, so the point the day panel
|
||||
describes stays in sight beside it. */}
|
||||
{openDayIndex !== undefined ? (
|
||||
<ReferenceLine
|
||||
key="open-day"
|
||||
x={openDayIndex}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.7}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
) : null}
|
||||
{openDayIndex !== undefined && openDayPoint
|
||||
? types.map((type, i) => {
|
||||
const v = openDayPoint[type];
|
||||
if (typeof v !== "number") return null;
|
||||
return (
|
||||
<ReferenceDot
|
||||
key={`open-day-${type}`}
|
||||
x={openDayIndex}
|
||||
y={v}
|
||||
r={6.5}
|
||||
fill="var(--card)"
|
||||
stroke={colors[i % colors.length]}
|
||||
strokeWidth={2.5}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{/* v1.4.18 — personal-baseline reference line is now
|
||||
opt-in via the Trend toggle. the maintainer rejected the
|
||||
always-on dashed mean line; it now only paints when
|
||||
@@ -2166,6 +2279,16 @@ export function HealthChart({
|
||||
})}
|
||||
<Tooltip
|
||||
filterNull={false}
|
||||
// v1.42 — on a touch screen the tooltip is the way to the
|
||||
// day: it stays where the tap put it and takes taps.
|
||||
trigger={
|
||||
dayLinksActive && coarsePointer ? "click" : "hover"
|
||||
}
|
||||
wrapperStyle={
|
||||
dayLinksActive && coarsePointer
|
||||
? { pointerEvents: "auto", zIndex: 20 }
|
||||
: undefined
|
||||
}
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
@@ -2309,6 +2432,13 @@ export function HealthChart({
|
||||
: dateLabel
|
||||
}
|
||||
rows={rows}
|
||||
action={
|
||||
dayLinksActive && coarsePointer && hoverPoint ? (
|
||||
<TooltipDayAction
|
||||
onOpen={() => openPointDay(hoverPoint)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -2444,6 +2574,18 @@ export function HealthChart({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{dayLinksActive && chartData && chartData.length > 0 ? (
|
||||
<>
|
||||
<DayRug
|
||||
points={chartData}
|
||||
// The plot's margin (8) + the y axis + the x axis padding
|
||||
// (10) on the left, margin + padding on the right.
|
||||
insetLeft={8 + yAxisWidth + 10}
|
||||
insetRight={8 + 10}
|
||||
/>
|
||||
<ChartDayCaption coarse={coarsePointer} />
|
||||
</>
|
||||
) : null}
|
||||
{/* The points the chart just drew, as a table. Reads the SAME
|
||||
expression `<ComposedChart data>` is handed above, so the two
|
||||
cannot disagree. Sits inside this branch on purpose: an empty
|
||||
@@ -2459,6 +2601,7 @@ export function HealthChart({
|
||||
formatDate={tzFmt.date}
|
||||
bucket={visibleSlice?.bucketType ?? "day"}
|
||||
metricLabel={getTypeLabel(primaryType, valueMode, t)}
|
||||
dayLinks={dayLinksActive}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { isOpenableDay } from "@/components/day/day-url";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -75,7 +78,13 @@ export function MoodHeatmap({
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
/** v1.42 — the day a pinned (touch) tooltip offers to open. */
|
||||
day?: string;
|
||||
} | null>(null);
|
||||
// v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
|
||||
// which then offers the day. The pointer type of the press decides.
|
||||
const lastPointer = useRef<string>("mouse");
|
||||
const today = useTodayKey();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
@@ -299,6 +308,7 @@ export function MoodHeatmap({
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
data-day={cell.dateKey}
|
||||
// 2026-07-17 a11y audit (M2) — the per-day values reach
|
||||
// assistive tech through the visually-hidden day list below
|
||||
// the grid, not through the rects: a cell subtree nested
|
||||
@@ -318,14 +328,26 @@ export function MoodHeatmap({
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: buildText(),
|
||||
pinned: true,
|
||||
day:
|
||||
cell.cell !== null && isOpenableDay(cell.dateKey, today)
|
||||
? cell.dateKey
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
onClick={() => {
|
||||
if (lastPointer.current === "touch") return;
|
||||
if (cell.cell === null) return;
|
||||
if (!isOpenableDay(cell.dateKey, today)) return;
|
||||
setTooltip(null);
|
||||
openDay(cell.dateKey, { trigger: containerRef.current });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -346,7 +368,10 @@ export function MoodHeatmap({
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
data-slot="mood-heatmap-tooltip"
|
||||
className={`bg-popover text-popover-foreground border-border fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md ${
|
||||
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
|
||||
}`}
|
||||
// Clamp the left edge so a tap near the right border doesn't push
|
||||
// the pinned label off-screen on a narrow viewport.
|
||||
style={{
|
||||
@@ -358,6 +383,20 @@ export function MoodHeatmap({
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
{tooltip.day ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="heatmap-open-day"
|
||||
onClick={() => {
|
||||
const day = tooltip.day;
|
||||
setTooltip(null);
|
||||
if (day) openDay(day, { trigger: containerRef.current });
|
||||
}}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-1.5 flex min-h-11 w-full items-center justify-center rounded-md px-3 text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.openDay")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { localDateTimeValue } from "@/components/day/prefill";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -102,19 +103,18 @@ interface MedicationIntakeQuickAddProps {
|
||||
* attribute on the portalled Save button.
|
||||
*/
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
function getDefaultIntakeAtValue(): string {
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
const local = new Date(now.getTime() - offset * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
/**
|
||||
* v1.42 — a calendar day (`YYYY-MM-DD`) to start the date on, when the
|
||||
* form is opened from that day. Defaults to now.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
export function MedicationIntakeQuickAdd({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
defaultDate,
|
||||
}: MedicationIntakeQuickAddProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -178,7 +178,9 @@ export function MedicationIntakeQuickAdd({
|
||||
// and nothing extra is sent; an edit that differs from the configured
|
||||
// dose is persisted as the per-intake override.
|
||||
const [doseOverride, setDoseOverride] = useState<string | null>(null);
|
||||
const [takenAt, setTakenAt] = useState<string>(getDefaultIntakeAtValue);
|
||||
const [takenAt, setTakenAt] = useState<string>(() =>
|
||||
localDateTimeValue(defaultDate),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectionNow] = useState(() => new Date());
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chartPointDayKey, rugPosition } from "../chart-day";
|
||||
import { daysFromCoachSteps } from "../coach-day-chips";
|
||||
import { pickPreparationDays } from "../since-last-visit";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
|
||||
const noon = (key: string) => Date.parse(`${key}T12:00:00.000Z`);
|
||||
|
||||
describe("chart day helpers", () => {
|
||||
it("reads a daily point's day from its noon-UTC anchor", () => {
|
||||
expect(chartPointDayKey(noon("2026-01-03"))).toBe("2026-01-03");
|
||||
});
|
||||
|
||||
it("places a rug day between the points around it", () => {
|
||||
const points = [
|
||||
{ timestamp: noon("2026-01-01") },
|
||||
{ timestamp: noon("2026-01-03") },
|
||||
{ timestamp: noon("2026-01-04") },
|
||||
];
|
||||
expect(rugPosition("2026-01-01", points)).toBe(0);
|
||||
expect(rugPosition("2026-01-02", points)).toBeCloseTo(0.5);
|
||||
expect(rugPosition("2026-01-04", points)).toBe(2);
|
||||
// Outside the drawn span: no dot.
|
||||
expect(rugPosition("2025-12-31", points)).toBeNull();
|
||||
expect(rugPosition("2026-01-05", points)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach day chips", () => {
|
||||
const step = (over: Partial<CoachStep>): CoachStep => ({
|
||||
id: "s1",
|
||||
tool: "snapshot",
|
||||
labelKey: "coach.step.read",
|
||||
label: "Read",
|
||||
status: "done",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("takes days from get_day calls only, never from anything else", () => {
|
||||
const days = daysFromCoachSteps([
|
||||
step({
|
||||
id: "s1",
|
||||
tool: "get_day" as CoachStep["tool"],
|
||||
day: "2026-01-05",
|
||||
}),
|
||||
step({
|
||||
id: "s2",
|
||||
tool: "get_day" as CoachStep["tool"],
|
||||
day: "2026-01-03",
|
||||
}),
|
||||
step({
|
||||
id: "s3",
|
||||
tool: "get_day" as CoachStep["tool"],
|
||||
day: "2026-01-03",
|
||||
}),
|
||||
// A failed read read nothing.
|
||||
step({
|
||||
id: "s4",
|
||||
tool: "get_day" as CoachStep["tool"],
|
||||
day: "2026-01-04",
|
||||
status: "failed",
|
||||
}),
|
||||
// A day field on another tool is not a day this answer read.
|
||||
step({ id: "s5", tool: "snapshot", day: "2026-01-06" }),
|
||||
step({
|
||||
id: "s6",
|
||||
tool: "get_day" as CoachStep["tool"],
|
||||
day: "not-a-day",
|
||||
}),
|
||||
]);
|
||||
expect(days).toEqual(["2026-01-03", "2026-01-05"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visit preparation", () => {
|
||||
it("reads notable days first, then context days, newest kept, in date order", () => {
|
||||
const days = pickPreparationDays(
|
||||
{
|
||||
days: {
|
||||
"2026-08-03": ["medications", "values"],
|
||||
"2026-08-19": ["illness", "values"],
|
||||
"2026-09-14": ["labs"],
|
||||
"2026-09-20": ["values", "mood"],
|
||||
},
|
||||
notable: ["2026-09-02"],
|
||||
},
|
||||
3,
|
||||
);
|
||||
// A day with only values and mood is not a context change.
|
||||
expect(days).toEqual(["2026-08-19", "2026-09-02", "2026-09-14"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The history contract of the day layer, against a small stand-in for the
|
||||
* browser's session history:
|
||||
*
|
||||
* - opening adds exactly one entry, so Back closes the layer;
|
||||
* - opening another day while one is open swaps it in place;
|
||||
* - stepping to the neighbouring day never adds an entry, however often;
|
||||
* - closing a day the layer opened goes Back (the next Back leaves the
|
||||
* page); closing a deep link only drops the parameter and stays.
|
||||
*/
|
||||
|
||||
interface Entry {
|
||||
state: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function installFakeBrowser(initialUrl: string) {
|
||||
const entries: Entry[] = [{ state: null, url: initialUrl }];
|
||||
let index = 0;
|
||||
const location = {
|
||||
get pathname() {
|
||||
return new URL(entries[index]!.url, "http://x").pathname;
|
||||
},
|
||||
get search() {
|
||||
return new URL(entries[index]!.url, "http://x").search;
|
||||
},
|
||||
};
|
||||
const history = {
|
||||
get state() {
|
||||
return entries[index]!.state;
|
||||
},
|
||||
get length() {
|
||||
return entries.length;
|
||||
},
|
||||
pushState(state: unknown, _unused: string, url: string) {
|
||||
entries.splice(index + 1);
|
||||
entries.push({ state, url });
|
||||
index = entries.length - 1;
|
||||
},
|
||||
replaceState(state: unknown, _unused: string, url: string) {
|
||||
entries[index] = { state, url };
|
||||
},
|
||||
back() {
|
||||
if (index > 0) index -= 1;
|
||||
},
|
||||
};
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
g.window = { location, history };
|
||||
g.document = { activeElement: null, body: {} };
|
||||
return {
|
||||
current: () => entries[index]!.url,
|
||||
length: () => entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
let browser: ReturnType<typeof installFakeBrowser>;
|
||||
|
||||
beforeEach(() => {
|
||||
browser = installFakeBrowser("/insights/blood-pressure?range=90");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
delete g.window;
|
||||
delete g.document;
|
||||
});
|
||||
|
||||
describe("day layer history", () => {
|
||||
it("opens with one entry and keeps the page's query", async () => {
|
||||
const { openDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
expect(browser.current()).toBe(
|
||||
"/insights/blood-pressure?range=90&day=2026-01-03",
|
||||
);
|
||||
expect(browser.length()).toBe(2);
|
||||
});
|
||||
|
||||
it("swaps the day in place when another day opens beside it", async () => {
|
||||
const { openDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
openDay("2026-01-05");
|
||||
expect(browser.length()).toBe(2);
|
||||
expect(browser.current()).toContain("day=2026-01-05");
|
||||
});
|
||||
|
||||
it("steps without adding history entries", async () => {
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", -1);
|
||||
stepDay("2026-01-02", -1);
|
||||
stepDay("2026-01-01", 1);
|
||||
expect(browser.length()).toBe(2);
|
||||
expect(browser.current()).toContain("day=2026-01-02");
|
||||
});
|
||||
|
||||
it("closes a pushed day with Back, landing on the page without it", async () => {
|
||||
const { closeDay, openDay, stepDay } =
|
||||
await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", 1);
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
|
||||
});
|
||||
|
||||
it("closes a deep link in place: the parameter goes, the page stays", async () => {
|
||||
browser = installFakeBrowser("/?day=2026-01-03");
|
||||
const { closeDay, stepDay } = await import("../day-layer-controller");
|
||||
stepDay("2026-01-03", -1);
|
||||
expect(browser.current()).toBe("/?day=2026-01-02");
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/");
|
||||
expect(browser.length()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* A date that opens its day, and the dates that must not: a future day has
|
||||
* nothing to show, so it stays plain text.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => "/labs" }));
|
||||
vi.mock("../use-today-key", () => ({ useTodayKey: () => "2026-10-08" }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function render(
|
||||
node: (m: typeof import("../day-link")) => React.ReactNode,
|
||||
) {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const mod = await import("../day-link");
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages}>
|
||||
{node(mod)}
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DayLink>", () => {
|
||||
it("links a past day to ?day= on the current page", async () => {
|
||||
const html = await render(({ DayLink }) => (
|
||||
<DayLink date="2025-12-12">12 Dec 2025</DayLink>
|
||||
));
|
||||
expect(html).toContain('data-slot="day-link"');
|
||||
expect(html).toContain('href="/labs?day=2025-12-12"');
|
||||
expect(html).toContain('data-day="2025-12-12"');
|
||||
});
|
||||
|
||||
it("leaves a future day as plain text", async () => {
|
||||
const html = await render(({ DayLink }) => (
|
||||
<DayLink date="2026-10-09">tomorrow</DayLink>
|
||||
));
|
||||
expect(html).not.toContain('data-slot="day-link"');
|
||||
expect(html).toContain('data-slot="day-link-plain"');
|
||||
});
|
||||
|
||||
it("reads a stated date as its calendar day in every zone", async () => {
|
||||
const html = await render(({ DayLinkStated }) => (
|
||||
<>
|
||||
<DayLinkStated at="2025-12-12T12:00:00.000Z">a</DayLinkStated>
|
||||
<DayLinkStated at="2025-06-03">b</DayLinkStated>
|
||||
</>
|
||||
));
|
||||
expect(html).toContain('data-day="2025-12-12"');
|
||||
expect(html).toContain('data-day="2025-06-03"');
|
||||
});
|
||||
|
||||
it("puts a link where a sentence names its date", async () => {
|
||||
const html = await render(({ DAY_LINK_SLOT, DayLink, withDayLinkSlot }) =>
|
||||
withDayLinkSlot(
|
||||
`Started on ${DAY_LINK_SLOT} at home`,
|
||||
<DayLink date="2025-12-31">31 Dec</DayLink>,
|
||||
),
|
||||
);
|
||||
expect(html).toMatch(/^Started on <a [^>]*>31 Dec<\/a> at home$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Where a date must NOT open the day (integration plan §1.2), frozen.
|
||||
*
|
||||
* - the dashboard's today area: the dashboard is today; past days open as
|
||||
* a layer over it, never as date navigation inside it;
|
||||
* - a tile's mini chart: too small a target, and the tile has its own
|
||||
* destination (the chart side is pinned in `health-chart-day-links`);
|
||||
* - the cycle calendar: a tap there has meant "log this day" for years;
|
||||
* - model-written text (the briefing, the Coach's prose): never parsed for
|
||||
* dates; the Coach's days come from its tool calls as chips;
|
||||
* - the clinician share link and the doctor-report PDF: outside the
|
||||
* session, a frozen window, another reader;
|
||||
* - future dates: `DayLink` renders them as text (pinned in `day-link`).
|
||||
*
|
||||
* The check is an import of the day module (or the link markup) in any of
|
||||
* those files. Its own limit: a surface that builds `?day=` by hand would slip
|
||||
* it, which is why the second test looks for the parameter itself.
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
const NOT_HERE: readonly string[] = [
|
||||
"src/components/daily/today-hero.tsx",
|
||||
"src/components/charts/trend-card.tsx",
|
||||
"src/components/cycle/cycle-calendar.tsx",
|
||||
"src/components/insights/daily-briefing.tsx",
|
||||
"src/components/insights/coach-panel/streamed-prose.tsx",
|
||||
"src/components/clinician",
|
||||
"src/app/c",
|
||||
"src/lib/doctor-report-pdf",
|
||||
];
|
||||
|
||||
function files(path: string): string[] {
|
||||
const abs = join(ROOT, path);
|
||||
if (statSync(abs).isFile()) return [abs];
|
||||
return readdirSync(abs).flatMap((name) => files(join(path, name)));
|
||||
}
|
||||
|
||||
const sources = NOT_HERE.flatMap(files).filter((f) => /\.tsx?$/.test(f));
|
||||
|
||||
describe("the day view stays out of the surfaces it must not reach", () => {
|
||||
it("covers every listed surface", () => {
|
||||
// A listed path that vanished or moved would leave the guard green by
|
||||
// matching nothing.
|
||||
expect(sources.length).toBeGreaterThanOrEqual(NOT_HERE.length);
|
||||
});
|
||||
|
||||
it("none of them imports the day module", () => {
|
||||
const offenders = sources.filter((file) =>
|
||||
/from\s+["']@\/components\/day\//.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders.map((f) => relative(ROOT, f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("none of them spells a ?day= link by hand", () => {
|
||||
const offenders = sources.filter((file) =>
|
||||
/[?&]day=|DAY_QUERY_PARAM/.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders.map((f) => relative(ROOT, f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the dashboard's charts never ask for day links", () => {
|
||||
const dashboard = readFileSync(
|
||||
join(ROOT, "src/app/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(dashboard).not.toMatch(/\bdayLinks\b/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
daysBetween,
|
||||
externalDayHref,
|
||||
isOpenableDay,
|
||||
parseDayParam,
|
||||
shiftDateKey,
|
||||
todayKeyInZone,
|
||||
withDayHref,
|
||||
} from "../day-url";
|
||||
|
||||
/**
|
||||
* The day layer's address rules: which `?day=` values open, how a step to the
|
||||
* neighbouring day is spelled, and that the parameter travels beside a page's
|
||||
* own query instead of replacing it.
|
||||
*/
|
||||
describe("day URL rules", () => {
|
||||
it("opens a real calendar date up to today, nothing else", () => {
|
||||
const today = "2026-10-08";
|
||||
expect(parseDayParam("2026-01-03", today)).toBe("2026-01-03");
|
||||
expect(parseDayParam(today, today)).toBe(today);
|
||||
// Future days have no content: dropped.
|
||||
expect(parseDayParam("2026-10-09", today)).toBeNull();
|
||||
// Shape-valid but not a date.
|
||||
expect(parseDayParam("2026-02-30", today)).toBeNull();
|
||||
expect(parseDayParam("03.01.2026", today)).toBeNull();
|
||||
expect(parseDayParam("", today)).toBeNull();
|
||||
expect(parseDayParam(null, today)).toBeNull();
|
||||
expect(isOpenableDay(undefined, today)).toBe(false);
|
||||
});
|
||||
|
||||
it("steps calendar days across month, year and DST boundaries", () => {
|
||||
expect(shiftDateKey("2026-01-01", -1)).toBe("2025-12-31");
|
||||
expect(shiftDateKey("2026-02-28", 1)).toBe("2026-03-01");
|
||||
expect(shiftDateKey("2024-02-28", 1)).toBe("2024-02-29");
|
||||
// Europe's spring-forward and fall-back days are ordinary calendar steps.
|
||||
expect(shiftDateKey("2026-03-28", 1)).toBe("2026-03-29");
|
||||
expect(shiftDateKey("2026-03-29", 1)).toBe("2026-03-30");
|
||||
expect(shiftDateKey("2026-10-25", -1)).toBe("2026-10-24");
|
||||
expect(daysBetween("2026-03-28", "2026-03-30")).toBe(2);
|
||||
expect(daysBetween("2026-10-26", "2026-10-24")).toBe(-2);
|
||||
});
|
||||
|
||||
it("keeps the page's own query when setting or removing the day", () => {
|
||||
expect(withDayHref("/labs", "?analyte=ldl", "2025-12-12")).toBe(
|
||||
"/labs?analyte=ldl&day=2025-12-12",
|
||||
);
|
||||
expect(withDayHref("/labs", "?analyte=ldl&day=2025-12-12", null)).toBe(
|
||||
"/labs?analyte=ldl",
|
||||
);
|
||||
expect(withDayHref("/", "", null)).toBe("/");
|
||||
expect(externalDayHref("2026-01-03")).toBe("/?day=2026-01-03");
|
||||
});
|
||||
|
||||
it("reads today in the record's zone, not the host's", () => {
|
||||
// 23:30 UTC on 7 Oct is already 8 Oct in Berlin and still 7 Oct in
|
||||
// New York.
|
||||
const now = new Date("2026-10-07T23:30:00Z");
|
||||
expect(todayKeyInZone("Europe/Berlin", now)).toBe("2026-10-08");
|
||||
expect(todayKeyInZone("America/New_York", now)).toBe("2026-10-07");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DayValue } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
CURATED_VALUE_COUNT,
|
||||
curateDayValues,
|
||||
numberLinePositions,
|
||||
} from "../day-values-model";
|
||||
|
||||
function value(type: string, v = 1): DayValue {
|
||||
return {
|
||||
type,
|
||||
value: v,
|
||||
unit: "x",
|
||||
at: "2026-01-03T07:12:00.000Z",
|
||||
source: "MANUAL",
|
||||
band: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("curateDayValues", () => {
|
||||
it("folds a blood pressure into one tile, systolic first", () => {
|
||||
const { curated } = curateDayValues([
|
||||
value("BLOOD_PRESSURE_DIA", 88),
|
||||
value("BLOOD_PRESSURE_SYS", 138),
|
||||
]);
|
||||
expect(curated).toHaveLength(1);
|
||||
expect(curated[0]!.key).toBe("BLOOD_PRESSURE");
|
||||
expect(curated[0]!.values.map((v) => v.value)).toEqual([138, 88]);
|
||||
});
|
||||
|
||||
it("shows eight tiles in reading order and keeps the rest for All", () => {
|
||||
const types = [
|
||||
"WALKING_SPEED",
|
||||
"ACTIVITY_STEPS",
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"RESTING_HEART_RATE",
|
||||
"SLEEP_DURATION",
|
||||
"BODY_TEMPERATURE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"BLOOD_GLUCOSE",
|
||||
"VO2_MAX",
|
||||
];
|
||||
const { curated, rest } = curateDayValues(types.map((t) => value(t)));
|
||||
expect(curated).toHaveLength(CURATED_VALUE_COUNT);
|
||||
expect(curated.map((t) => t.key).slice(0, 4)).toEqual([
|
||||
"BLOOD_PRESSURE",
|
||||
"RESTING_HEART_RATE",
|
||||
"WEIGHT",
|
||||
"BODY_TEMPERATURE",
|
||||
]);
|
||||
expect(rest.map((t) => t.key)).toEqual(["WALKING_SPEED", "VO2_MAX"]);
|
||||
});
|
||||
|
||||
it("always shows the value the person came from among the first tiles", () => {
|
||||
const types = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"RESTING_HEART_RATE",
|
||||
"PULSE",
|
||||
"WEIGHT",
|
||||
"BODY_TEMPERATURE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"BLOOD_GLUCOSE",
|
||||
"VO2_MAX",
|
||||
];
|
||||
const { curated, rest } = curateDayValues(
|
||||
types.map((t) => value(t)),
|
||||
["VO2_MAX"],
|
||||
);
|
||||
expect(curated.map((t) => t.key)).toContain("VO2_MAX");
|
||||
expect(rest.map((t) => t.key)).not.toContain("VO2_MAX");
|
||||
expect(curated).toHaveLength(CURATED_VALUE_COUNT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numberLinePositions", () => {
|
||||
it("places a value inside its usual range inside the band", () => {
|
||||
const pos = numberLinePositions(130, { lo: 124, hi: 134 });
|
||||
expect(pos.point).toBeGreaterThan(pos.lo);
|
||||
expect(pos.point).toBeLessThan(pos.hi);
|
||||
expect(pos.lo).toBeGreaterThan(0);
|
||||
expect(pos.hi).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("stretches the line to a value outside the band, never past 100", () => {
|
||||
const pos = numberLinePositions(180, { lo: 124, hi: 134 });
|
||||
expect(pos.point).toBeGreaterThan(pos.hi);
|
||||
expect(pos.point).toBeLessThanOrEqual(100);
|
||||
const low = numberLinePositions(-5, { lo: 3, hi: 4 });
|
||||
expect(low.point).toBeGreaterThanOrEqual(0);
|
||||
expect(low.point).toBeLessThan(low.lo);
|
||||
});
|
||||
|
||||
it("copes with a band of zero width", () => {
|
||||
const pos = numberLinePositions(70, { lo: 70, hi: 70 });
|
||||
expect(Number.isFinite(pos.point)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
|
||||
import type { DayResponse } from "@/lib/day/contract";
|
||||
|
||||
/**
|
||||
* The day's content in every shell: header, sections in the mockup's order,
|
||||
* the footer's two ways onward, and the honest states (loading, failed,
|
||||
* empty, partly shared). Rendered through SSR against a `GET /api/day/{date}`
|
||||
* answer shaped by the contract, as the server will send it.
|
||||
*/
|
||||
|
||||
const DAY: DayResponse = {
|
||||
date: "2026-01-03",
|
||||
tz: "Europe/Berlin",
|
||||
counts: { values: 3, entries: 2 },
|
||||
running: [
|
||||
{
|
||||
kind: "illness",
|
||||
section: "illness",
|
||||
id: "ep1",
|
||||
title: "Common cold",
|
||||
sub: null,
|
||||
since: "2025-12-31",
|
||||
until: null,
|
||||
dayIndex: 4,
|
||||
dayCount: null,
|
||||
href: "/illness/ep1",
|
||||
},
|
||||
{
|
||||
kind: "medicationCourse",
|
||||
section: "medications",
|
||||
id: "c1",
|
||||
title: "Vitamin D 1,000 IU",
|
||||
sub: "Winter course",
|
||||
since: "2025-11-01",
|
||||
until: "2026-03-31",
|
||||
dayIndex: 64,
|
||||
dayCount: 151,
|
||||
href: "/medications/m1?tab=verlauf",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 88,
|
||||
unit: "mmHg",
|
||||
at: "2026-01-03T06:12:00.000Z",
|
||||
source: "MANUAL",
|
||||
band: { lo: 78, hi: 86, n: 28 },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 138,
|
||||
unit: "mmHg",
|
||||
at: "2026-01-03T06:12:00.000Z",
|
||||
source: "MANUAL",
|
||||
band: { lo: 121, hi: 134, n: 28 },
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 82.6,
|
||||
unit: "kg",
|
||||
at: "2026-01-03T06:20:00.000Z",
|
||||
source: "WITHINGS",
|
||||
band: null,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
at: "2026-01-03T10:20:00.000Z",
|
||||
kind: "visit",
|
||||
section: "visits",
|
||||
id: "v1",
|
||||
title: "GP practice",
|
||||
meta: "Acute appointment",
|
||||
note: null,
|
||||
docs: [{ id: "d1", name: "sick-note.pdf" }],
|
||||
href: "/checkups?visit=v1",
|
||||
},
|
||||
{
|
||||
at: "2026-01-03T20:10:00.000Z",
|
||||
kind: "mood",
|
||||
section: "mood",
|
||||
id: "m1",
|
||||
title: "Mood: rather low",
|
||||
meta: "tired",
|
||||
note: "Fever gone by the evening.",
|
||||
docs: [],
|
||||
href: "/mood",
|
||||
},
|
||||
],
|
||||
notable: [
|
||||
{
|
||||
kind: "extremeHigh",
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
params: { since: "2025-03-01", value: 138 },
|
||||
},
|
||||
],
|
||||
sections: {},
|
||||
};
|
||||
|
||||
let dayQuery: { data?: DayResponse; isError?: boolean } = { data: DAY };
|
||||
let timelineOn = true;
|
||||
let pathname = "/insights/blood-pressure";
|
||||
let canWrite = true;
|
||||
|
||||
vi.mock("../use-day", () => ({
|
||||
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-module-enabled", () => ({
|
||||
useModuleEnabled: (key: string) => (key === "timeline" ? timelineOn : true),
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: false, reason: "check_failed" }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-unit-display", () => ({
|
||||
useUnitDisplay: () => ({
|
||||
toDisplay: (_t: string, v: number) => v,
|
||||
unitFor: (t: string) => (t === "WEIGHT" ? "kg" : "mmHg"),
|
||||
decimalsFor: () => 1,
|
||||
isTransformed: (t: string) => t === "WEIGHT",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: false,
|
||||
canWriteDomain: () => canWrite,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/layout/capture-picker", () => ({
|
||||
CAPTURE_KIND_ORDER: ["measurement", "mood"],
|
||||
CapturePicker: () => null,
|
||||
visibleCaptureKinds: (caps: { canWriteDomain: () => boolean }) =>
|
||||
caps.canWriteDomain() ? ["measurement"] : [],
|
||||
}));
|
||||
|
||||
async function render(
|
||||
props: Partial<{
|
||||
date: string;
|
||||
today: string;
|
||||
focus: unknown;
|
||||
}> = {},
|
||||
) {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { DayView } = await import("../day-view");
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en" initialMessages={enMessages}>
|
||||
<DayView
|
||||
date={props.date ?? "2026-01-03"}
|
||||
today={props.today ?? "2026-10-08"}
|
||||
focus={(props.focus as never) ?? null}
|
||||
shell="docked"
|
||||
onClose={() => undefined}
|
||||
onStep={() => undefined}
|
||||
Title={(p) => <h2 {...p} />}
|
||||
titleId="t"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
dayQuery = { data: DAY };
|
||||
timelineOn = true;
|
||||
pathname = "/insights/blood-pressure";
|
||||
canWrite = true;
|
||||
});
|
||||
|
||||
describe("<DayView>", () => {
|
||||
it("names the day and counts what it holds", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("Saturday, January 3, 2026");
|
||||
expect(html).toContain("3 values · 2 entries");
|
||||
});
|
||||
|
||||
it("lists what ran through the day with its running count", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("Running that day");
|
||||
expect(html).toContain("Common cold");
|
||||
expect(html).toContain("Day 4 · since");
|
||||
expect(html).toContain("Winter course · Day 64 of 151");
|
||||
expect(html).toContain('href="/illness/ep1"');
|
||||
});
|
||||
|
||||
it("folds the blood pressure into one tile on its number line", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain('data-type="BLOOD_PRESSURE"');
|
||||
expect(html).toContain("138/88");
|
||||
expect(html).toContain('data-slot="day-number-line"');
|
||||
// A value without a band has no line, never an invented one.
|
||||
const weightTile = html.split('data-type="WEIGHT"')[1] ?? "";
|
||||
expect(weightTile.split("</li>")[0]).not.toContain("day-number-line");
|
||||
});
|
||||
|
||||
it("lists what happened, each entry linking to its source", async () => {
|
||||
const html = await render();
|
||||
expect(html).toContain("What happened");
|
||||
expect(html).toContain('href="/checkups?visit=v1"');
|
||||
expect(html).toContain('href="/documents?doc=d1"');
|
||||
// The person's own note, in the content colour.
|
||||
expect(html).toContain("Fever gone by the evening.");
|
||||
});
|
||||
|
||||
it("puts the value the person came from on top and marks its tile", async () => {
|
||||
const html = await render({
|
||||
focus: {
|
||||
date: "2026-01-03",
|
||||
types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
label: "Blood pressure",
|
||||
value: "138/88",
|
||||
unit: "mmHg",
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-slot="day-focus"');
|
||||
expect(html).toContain("Your 30 days before");
|
||||
expect(html).toContain("121–134/78–86");
|
||||
expect(html).toContain('data-selected="true"');
|
||||
expect(html).toContain("Highest daily value since March 2025");
|
||||
});
|
||||
|
||||
it("offers the timeline only with its module on, and not on the timeline", async () => {
|
||||
expect(await render()).toContain('data-slot="day-in-timeline"');
|
||||
timelineOn = false;
|
||||
expect(await render()).not.toContain('data-slot="day-in-timeline"');
|
||||
timelineOn = true;
|
||||
pathname = "/timeline";
|
||||
expect(await render()).not.toContain('data-slot="day-in-timeline"');
|
||||
});
|
||||
|
||||
it("offers capture only to someone who may write", async () => {
|
||||
expect(await render()).toContain('data-slot="day-capture"');
|
||||
canWrite = false;
|
||||
expect(await render()).not.toContain('data-slot="day-capture"');
|
||||
});
|
||||
|
||||
it("never steps past today", async () => {
|
||||
const html = await render({ date: "2026-10-08", today: "2026-10-08" });
|
||||
expect(html).toMatch(/data-slot="day-next"[^>]*disabled=""/);
|
||||
});
|
||||
|
||||
it("keeps a failed read apart from an empty day", async () => {
|
||||
dayQuery = { isError: true };
|
||||
const failed = await render();
|
||||
expect(failed).toContain('data-slot="day-error"');
|
||||
expect(failed).not.toContain('data-slot="day-empty"');
|
||||
|
||||
dayQuery = {
|
||||
data: {
|
||||
...DAY,
|
||||
counts: { values: 0, entries: 0 },
|
||||
running: [],
|
||||
values: [],
|
||||
events: [],
|
||||
notable: [],
|
||||
},
|
||||
};
|
||||
const empty = await render();
|
||||
expect(empty).toContain("There are no entries for this day.");
|
||||
expect(empty).not.toContain('data-slot="day-error"');
|
||||
});
|
||||
|
||||
it("paints the frame while loading", async () => {
|
||||
dayQuery = {};
|
||||
const html = await render();
|
||||
expect(html).toContain('data-slot="day-loading"');
|
||||
expect(html).toContain("Saturday, January 3, 2026");
|
||||
});
|
||||
|
||||
it("says once that some areas are not shared", async () => {
|
||||
dayQuery = {
|
||||
data: {
|
||||
...DAY,
|
||||
sections: { labs: { available: false, reason: "not_shared" } },
|
||||
},
|
||||
};
|
||||
expect(await render()).toContain("Some areas are not shared with you.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
|
||||
import { isOpenableDay } from "./day-url";
|
||||
import { useDayIndex } from "./use-day";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* The day view's entry points on a value chart.
|
||||
*
|
||||
* - On a fine pointer, a click on the plot opens the day of the point under
|
||||
* the cursor; hovering keeps showing the tooltip as before.
|
||||
* - On a touch screen a tap shows the value, as it always did, and the
|
||||
* tooltip carries "View the whole day". Two steps on purpose: one tap
|
||||
* that opened a sheet would fire on every scroll that grazed the chart.
|
||||
* - Under the axis, a quiet row of dots marks the days that hold anything,
|
||||
* a ring the days with a notable observation. Decoration for the eye; the
|
||||
* keyboard and screen-reader way to a day is the chart's data table.
|
||||
*
|
||||
* Only a chart drawn in days takes part: a week or month point is an average
|
||||
* of many days and has no single day to open.
|
||||
*/
|
||||
|
||||
const COARSE_QUERY = "(pointer: coarse)";
|
||||
|
||||
function subscribeCoarse(callback: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(COARSE_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
/** True on a touch-first device, where a tap must not open a sheet. */
|
||||
export function useCoarsePointer(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeCoarse,
|
||||
() =>
|
||||
typeof window !== "undefined" && !!window.matchMedia
|
||||
? window.matchMedia(COARSE_QUERY).matches
|
||||
: false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar day of a daily chart point. Daily points sit at noon UTC of
|
||||
* their day key, so the UTC date is the day the chart bucketed them into,
|
||||
* whatever zone the reader is in.
|
||||
*/
|
||||
export function chartPointDayKey(timestamp: number): DateKey {
|
||||
return dateOnlyKey(new Date(timestamp));
|
||||
}
|
||||
|
||||
/** Open the day of a chart point, carrying the value as the day's focus. */
|
||||
export function openChartDay(
|
||||
timestamp: number,
|
||||
focus: Omit<DayFocus, "date"> | null,
|
||||
trigger?: HTMLElement | null,
|
||||
): void {
|
||||
const date = chartPointDayKey(timestamp);
|
||||
openDay(date, {
|
||||
focus: focus ? { ...focus, date } : null,
|
||||
trigger: trigger ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** The tooltip's way to the day on a touch screen. */
|
||||
export function TooltipDayAction({ onOpen }: { onOpen: () => void }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="chart-tooltip-open-day"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-2.5 flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("day.viewWholeDay")}
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface RugPoint {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a day sits along a chart whose x axis is the point index: the
|
||||
* fractional index between the two points around it. Null outside the drawn
|
||||
* span (and so for a day the chart has no neighbours for).
|
||||
*/
|
||||
export function rugPosition(
|
||||
day: DateKey,
|
||||
points: readonly RugPoint[],
|
||||
): number | null {
|
||||
if (points.length === 0) return null;
|
||||
const t = Date.parse(`${day}T12:00:00.000Z`);
|
||||
const first = points[0]!.timestamp;
|
||||
const last = points[points.length - 1]!.timestamp;
|
||||
if (t < first || t > last) return null;
|
||||
if (points.length === 1) return 0;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
const a = points[i]!.timestamp;
|
||||
const b = points[i + 1]!.timestamp;
|
||||
if (t >= a && t <= b) return b === a ? i : i + (t - a) / (b - a);
|
||||
}
|
||||
return points.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of day dots under a daily chart. `insetLeft` / `insetRight` are the
|
||||
* pixels between the chart box and the first and last point (margin, axis,
|
||||
* padding), so a dot lands under its day.
|
||||
*/
|
||||
export function DayRug({
|
||||
points,
|
||||
insetLeft,
|
||||
insetRight,
|
||||
}: {
|
||||
points: readonly RugPoint[];
|
||||
insetLeft: number;
|
||||
insetRight: number;
|
||||
}) {
|
||||
const today = useTodayKey();
|
||||
const open = useOpenDay();
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const from = first ? chartPointDayKey(first.timestamp) : null;
|
||||
const to = last ? chartPointDayKey(last.timestamp) : null;
|
||||
const index = useDayIndex(from, to);
|
||||
const data = index.data;
|
||||
if (!data?.days || points.length === 0) return null;
|
||||
const notable = new Set(data.notable ?? []);
|
||||
const maxIndex = Math.max(points.length - 1, 1);
|
||||
const days = Object.keys(data.days)
|
||||
.filter((day) => isOpenableDay(day, today))
|
||||
.map((day) => ({ day, at: rugPosition(day, points) }))
|
||||
.filter((entry): entry is { day: string; at: number } => entry.at !== null);
|
||||
if (days.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="day-rug"
|
||||
className="pointer-events-none relative h-3"
|
||||
style={{ marginLeft: insetLeft, marginRight: insetRight }}
|
||||
>
|
||||
{days.map(({ day, at }) => {
|
||||
const isOpen = day === open;
|
||||
const isNotable = notable.has(day);
|
||||
return (
|
||||
<span
|
||||
key={day}
|
||||
data-day={day}
|
||||
data-notable={isNotable ? "true" : undefined}
|
||||
data-open={isOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full",
|
||||
isOpen
|
||||
? "bg-foreground size-[7px]"
|
||||
: isNotable
|
||||
? "border-muted-foreground size-[7px] border-[1.5px]"
|
||||
: "bg-muted-foreground/55 size-1",
|
||||
)}
|
||||
style={{
|
||||
left: `${(points.length === 1 ? 0.5 : at / maxIndex) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one line under a day-linked chart: what the dots mean, and the click. */
|
||||
export function ChartDayCaption({ coarse }: { coarse: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<p
|
||||
data-slot="chart-day-caption"
|
||||
className="text-muted-foreground mt-1 flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs"
|
||||
>
|
||||
<span>{t("day.rugLegend")}</span>
|
||||
<span>{coarse ? t("day.chartHintTouch") : t("day.chartHint")}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { DAY_TOOL_NAME } from "@/lib/day/contract";
|
||||
import type { CoachStep } from "@/lib/ai/coach/types";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
import { DayLink } from "./day-link";
|
||||
|
||||
/**
|
||||
* The days a Coach answer read, as chips under it: "Days in this answer".
|
||||
*
|
||||
* Taken from the turn's tool calls, never from the answer's words. A date
|
||||
* the model writes can be wrong or ambiguous; the day a `get_day` call read
|
||||
* is exactly the day the answer stands on. A step that failed read nothing
|
||||
* and gives no chip.
|
||||
*/
|
||||
export function daysFromCoachSteps(steps: readonly CoachStep[]): string[] {
|
||||
const days = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if ((step.tool as string) !== DAY_TOOL_NAME) continue;
|
||||
if (step.status !== "done" && step.status !== "empty") continue;
|
||||
if (step.day && isCalendarDateKey(step.day)) days.add(step.day);
|
||||
}
|
||||
return [...days].sort();
|
||||
}
|
||||
|
||||
export function CoachDayChips({ steps }: { steps: readonly CoachStep[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const days = daysFromCoachSteps(steps);
|
||||
if (days.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-day-chips"
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1.5"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("day.coachChips")}
|
||||
</span>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{days.map((day) => (
|
||||
<li key={day}>
|
||||
<DayLink
|
||||
date={day}
|
||||
size="xs"
|
||||
className="border-border hover:bg-muted inline-flex min-h-7 items-center rounded-full border px-2.5 no-underline"
|
||||
>
|
||||
{fmt.dateShortSmartCalendar(day)}
|
||||
</DayLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
dayHistoryState,
|
||||
historyEntryOwnedByLayer,
|
||||
shiftDateKey,
|
||||
withDayHref,
|
||||
} from "./day-url";
|
||||
|
||||
/**
|
||||
* Opening, stepping and closing the day layer from anywhere in the app.
|
||||
*
|
||||
* The URL is the state. `openDay` writes `?day=` with `history.pushState`, so
|
||||
* the browser's Back closes the layer and Forward opens it again; Next keeps
|
||||
* `useSearchParams` in step with those writes, and the layer mounted in the
|
||||
* shell reads the parameter and nothing else. Stepping to the neighbouring day
|
||||
* replaces the entry instead of adding one, so five steps still need a single
|
||||
* Back to leave. Opening another day while one is open (a second chart point
|
||||
* beside the docked panel) swaps the day the same way.
|
||||
*
|
||||
* Two things do not belong in the URL and live here for the length of one
|
||||
* open: the value the person tapped to get here (the focus card at the top of
|
||||
* the day) and the element that opened the layer, which gets the focus back
|
||||
* when it closes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the person was looking at when they opened the day: a chart point or a
|
||||
* list row. Shown at the top of the day with the comparison the opening
|
||||
* surface already knows. Formatted by that surface, in its own words.
|
||||
*/
|
||||
export interface DayFocus {
|
||||
date: DateKey;
|
||||
/** The `MeasurementType` the value is, to mark its tile and its notes. */
|
||||
types?: readonly string[];
|
||||
/** What it is and where it came from, e.g. "Blood pressure · 07:12". */
|
||||
label: string;
|
||||
/** The value, formatted. */
|
||||
value: string;
|
||||
unit?: string;
|
||||
/** The comparison the opening surface knows (an earlier result). */
|
||||
compare?: { label: string; value: string };
|
||||
}
|
||||
|
||||
interface LayerMemory {
|
||||
focus: DayFocus | null;
|
||||
trigger: HTMLElement | null;
|
||||
/** The day the layer shows, as the mounted layer resolved it. */
|
||||
open: DateKey | null;
|
||||
}
|
||||
|
||||
const memory: LayerMemory = { focus: null, trigger: null, open: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The focus of the open day, or null when the day was opened without one. */
|
||||
export function useDayFocus(date: DateKey | null): DayFocus | null {
|
||||
const focus = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => memory.focus,
|
||||
() => null,
|
||||
);
|
||||
return focus !== null && focus.date === date ? focus : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open day, for the surfaces that mark it (the dashed line in a chart,
|
||||
* the highlighted row in a list). Written by the mounted layer only, so a
|
||||
* surface never reads `useSearchParams` and needs no Suspense boundary.
|
||||
*/
|
||||
export function useOpenDay(): DateKey | null {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => memory.open,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Called by the mounted layer whenever the day it shows changes. */
|
||||
export function publishOpenDay(date: DateKey | null): void {
|
||||
if (memory.open === date) return;
|
||||
memory.open = date;
|
||||
emit();
|
||||
}
|
||||
|
||||
/** The element focus returns to when the layer closes. */
|
||||
export function takeDayTrigger(): HTMLElement | null {
|
||||
const trigger = memory.trigger;
|
||||
memory.trigger = null;
|
||||
return trigger;
|
||||
}
|
||||
|
||||
export interface OpenDayOptions {
|
||||
focus?: DayFocus | null;
|
||||
/** Defaults to the element that has focus now. */
|
||||
trigger?: HTMLElement | null;
|
||||
}
|
||||
|
||||
function currentLocation() {
|
||||
return { pathname: window.location.pathname, search: window.location.search };
|
||||
}
|
||||
|
||||
/** Open `date` over the current page. */
|
||||
export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
|
||||
if (typeof window === "undefined") return;
|
||||
memory.focus = options.focus?.date === date ? options.focus : null;
|
||||
const active = document.activeElement;
|
||||
const focused =
|
||||
typeof HTMLElement !== "undefined" &&
|
||||
active instanceof HTMLElement &&
|
||||
active !== document.body
|
||||
? active
|
||||
: null;
|
||||
memory.trigger = options.trigger ?? focused ?? memory.trigger;
|
||||
emit();
|
||||
const { pathname, search } = currentLocation();
|
||||
const href = withDayHref(pathname, search, date);
|
||||
if (historyEntryOwnedByLayer(window.history.state)) {
|
||||
window.history.replaceState(dayHistoryState(date), "", href);
|
||||
} else {
|
||||
window.history.pushState(dayHistoryState(date), "", href);
|
||||
}
|
||||
}
|
||||
|
||||
/** Move the open day by `delta` calendar days, without a new history entry. */
|
||||
export function stepDay(from: DateKey, delta: number): DateKey {
|
||||
const next = shiftDateKey(from, delta);
|
||||
memory.focus = null;
|
||||
emit();
|
||||
const { pathname, search } = currentLocation();
|
||||
const owned = historyEntryOwnedByLayer(window.history.state);
|
||||
window.history.replaceState(
|
||||
// A deep link stays a deep link: marking it as pushed would make a later
|
||||
// close go Back off the page the person arrived on.
|
||||
owned ? dayHistoryState(next) : null,
|
||||
"",
|
||||
withDayHref(pathname, search, next),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the layer. An entry the layer pushed is consumed with Back, so the
|
||||
* next Back reaches the page before; a deep link only loses its parameter.
|
||||
*/
|
||||
export function closeDay(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
memory.focus = null;
|
||||
emit();
|
||||
if (historyEntryOwnedByLayer(window.history.state)) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
const { pathname, search } = currentLocation();
|
||||
window.history.replaceState(null, "", withDayHref(pathname, search, null));
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
closeDay,
|
||||
publishOpenDay,
|
||||
stepDay,
|
||||
takeDayTrigger,
|
||||
useDayFocus,
|
||||
} from "./day-layer-controller";
|
||||
import { parseDayParam, withDayHref } from "./day-url";
|
||||
import { DayView, useLongDayLabel } from "./day-view";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* The day layer, mounted once by the shell so `?day=` opens a day over any
|
||||
* page.
|
||||
*
|
||||
* Three frames, one content (`DayView`):
|
||||
*
|
||||
* - from 1280 px, a column docked beside the page, the same place and the
|
||||
* same breakpoint as the Coach's conversations panel. Not modal: the
|
||||
* chart beside it stays usable, and another point swaps the day. It is a
|
||||
* landmark (`complementary`) named by the date.
|
||||
* - from 768 px, a sheet from the right, modal.
|
||||
* - on a phone, a sheet from the bottom at a bit over half the height, so
|
||||
* the chart stays in view above it; dragging the handle up (or tapping
|
||||
* it) takes it to full height, dragging it down closes it.
|
||||
*
|
||||
* On `/coach` the conversations panel already holds the side column, and
|
||||
* there is never more than one docked panel: the day opens as a sheet there.
|
||||
*
|
||||
* The parameter is the state. A date that is not a calendar date, or lies in
|
||||
* the future, is removed from the URL without a word.
|
||||
*/
|
||||
export function DayLayerMount() {
|
||||
// `useSearchParams` suspends a statically rendered route until the client
|
||||
// has the URL; the boundary keeps that from blanking the page around it.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DayLayer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const DOCKED_QUERY = "(min-width: 1280px)";
|
||||
|
||||
function subscribeDocked(callback: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(DOCKED_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function useWideViewport(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeDocked,
|
||||
() =>
|
||||
typeof window !== "undefined" && !!window.matchMedia
|
||||
? window.matchMedia(DOCKED_QUERY).matches
|
||||
: false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a modal surface (a dialog, a menu) has the keyboard. */
|
||||
function modalSurfaceOpen(): boolean {
|
||||
return (
|
||||
document.querySelector(
|
||||
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"]',
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function DayLayer() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const today = useTodayKey();
|
||||
const raw = searchParams.get(DAY_QUERY_PARAM);
|
||||
const date = parseDayParam(raw, today);
|
||||
|
||||
// A parameter the layer cannot open leaves the URL quietly.
|
||||
useEffect(() => {
|
||||
if (raw === null || date !== null) return;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
withDayHref(window.location.pathname, window.location.search, null),
|
||||
);
|
||||
}, [raw, date]);
|
||||
|
||||
useEffect(() => {
|
||||
publishOpenDay(date);
|
||||
}, [date]);
|
||||
useEffect(() => () => publishOpenDay(null), []);
|
||||
|
||||
const wide = useWideViewport();
|
||||
const phone = useIsMobile();
|
||||
const shell: "docked" | "sheet" | "bottom" = phone
|
||||
? "bottom"
|
||||
: wide && !pathname.startsWith("/coach")
|
||||
? "docked"
|
||||
: "sheet";
|
||||
|
||||
const focus = useDayFocus(date);
|
||||
const longLabel = useLongDayLabel();
|
||||
const titleId = useId();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
|
||||
// What the polite region says: the day that just opened or was stepped to.
|
||||
const announcement = date !== null ? longLabel(date) : "";
|
||||
const previous = useRef<DateKey | null>(null);
|
||||
useEffect(() => {
|
||||
const before = previous.current;
|
||||
previous.current = date;
|
||||
if (date !== null) {
|
||||
// Opening moves focus to the day's heading; a step keeps it where it is
|
||||
// (on the arrow the person pressed).
|
||||
if (before === null && shell === "docked") {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (before !== null && shell === "docked") returnFocus();
|
||||
}, [date, shell]);
|
||||
|
||||
const onStep = useCallback(
|
||||
(delta: number) => {
|
||||
if (date === null) return;
|
||||
if (delta > 0 && date >= today) return;
|
||||
stepDay(date, delta);
|
||||
},
|
||||
[date, today],
|
||||
);
|
||||
|
||||
// Keyboard: Escape closes the docked column (a sheet's own Escape is the
|
||||
// dialog's), Alt with an arrow steps a day. Bare arrows stay with scrolling.
|
||||
useEffect(() => {
|
||||
if (date === null) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const inside =
|
||||
panelRef.current?.contains(event.target as Node | null) ?? false;
|
||||
if (event.key === "Escape" && shell === "docked") {
|
||||
if (modalSurfaceOpen() && !inside) return;
|
||||
event.preventDefault();
|
||||
closeDay();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
(event.key === "ArrowLeft" || event.key === "ArrowRight") &&
|
||||
(inside || shell === "docked")
|
||||
) {
|
||||
event.preventDefault();
|
||||
onStep(event.key === "ArrowLeft" ? -1 : 1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [date, shell, onStep]);
|
||||
|
||||
const live = (
|
||||
<p
|
||||
className="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="day-announcement"
|
||||
>
|
||||
{announcement}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (date === null) return live;
|
||||
|
||||
const view = (
|
||||
Title: Parameters<typeof DayView>[0]["Title"],
|
||||
extra?: Partial<Parameters<typeof DayView>[0]>,
|
||||
) => (
|
||||
<DayView
|
||||
date={date}
|
||||
today={today}
|
||||
focus={focus}
|
||||
shell={shell}
|
||||
onClose={closeDay}
|
||||
onStep={onStep}
|
||||
Title={Title}
|
||||
titleId={titleId}
|
||||
titleRef={titleRef}
|
||||
{...extra}
|
||||
/>
|
||||
);
|
||||
|
||||
if (shell === "docked") {
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<ShellSidePanel>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
className="bg-card text-card-foreground border-border flex h-full w-105 shrink-0 flex-col border-l"
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
headerClassName: cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-border pt-[env(safe-area-inset-top,0px)]",
|
||||
),
|
||||
})}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<DaySheet
|
||||
shell={shell}
|
||||
panelRef={panelRef}
|
||||
titleRef={titleRef}
|
||||
render={(extra) => view(SheetTitle, extra)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DockedTitle({
|
||||
ref,
|
||||
...props
|
||||
}: {
|
||||
id?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
ref?: React.Ref<HTMLHeadingElement>;
|
||||
tabIndex?: number;
|
||||
}) {
|
||||
return <h2 ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
/** Return focus to whatever opened the day, or to the page. */
|
||||
function returnFocus() {
|
||||
const trigger = takeDayTrigger();
|
||||
if (trigger && trigger.isConnected) {
|
||||
trigger.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
document.getElementById("main-content")?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal frames. The bottom sheet opens at a bit over half the height and
|
||||
* grows to full height from its handle: drag it up or tap it. Dragging down
|
||||
* shrinks a full sheet, then closes it.
|
||||
*/
|
||||
function DaySheet({
|
||||
shell,
|
||||
panelRef,
|
||||
titleRef,
|
||||
render,
|
||||
}: {
|
||||
shell: "sheet" | "bottom";
|
||||
panelRef: React.RefObject<HTMLElement | null>;
|
||||
titleRef: React.RefObject<HTMLHeadingElement | null>;
|
||||
render: (extra: Partial<Parameters<typeof DayView>[0]>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
const drag = useRef<{ y: number; moved: boolean } | null>(null);
|
||||
const bottom = shell === "bottom";
|
||||
|
||||
const grabber = bottom ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-sheet-handle"
|
||||
aria-label={full ? t("day.sheetShrink") : t("day.sheetExpand")}
|
||||
aria-expanded={full}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { y: event.clientY, moved: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
if (Math.abs(event.clientY - start.y) > 8) start.moved = true;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = drag.current;
|
||||
drag.current = null;
|
||||
if (!start) return;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!start.moved) return;
|
||||
if (dy < -40) setFull(true);
|
||||
else if (dy > 60) {
|
||||
if (full) setFull(false);
|
||||
else closeDay();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// A drag ends in a click as well; only a tap toggles.
|
||||
if (drag.current?.moved) return;
|
||||
setFull((value) => !value);
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className="bg-muted-foreground/40 h-1.5 w-9 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDay();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
ref={panelRef as React.Ref<HTMLDivElement>}
|
||||
side={bottom ? "bottom" : "right"}
|
||||
showCloseButton={false}
|
||||
data-slot="day-panel"
|
||||
data-shell={shell}
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
returnFocus();
|
||||
}}
|
||||
className={cn(
|
||||
"bg-card gap-0 p-0",
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
full ? "h-[calc(100dvh-2.5rem)]" : "h-[58dvh]",
|
||||
)
|
||||
: "w-105 max-w-[90vw] sm:max-w-105",
|
||||
)}
|
||||
>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("day.description")}
|
||||
</SheetDescription>
|
||||
{render({
|
||||
above: grabber,
|
||||
headerClassName: bottom ? undefined : "py-3",
|
||||
})}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
dateOnlyKey,
|
||||
isDateOnlyKey,
|
||||
isNoonUtcAnchor,
|
||||
} from "@/lib/tz/date-only";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
|
||||
import { dateKeyOfInstant, isOpenableDay } from "./day-url";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* A date that opens its day: the one affordance for "show me everything on
|
||||
* this day" across the app.
|
||||
*
|
||||
* Text in the foreground colour with a quiet underline in the border colour,
|
||||
* no icon, no colour of its own. It sits beside whatever the row already does:
|
||||
* the row keeps opening its entry, the date opens the day. On a touch screen
|
||||
* the hit area grows to 44 px without moving the line it sits in.
|
||||
*
|
||||
* A date in the future, or one that is not a calendar date, renders as plain
|
||||
* text: a day without content is a dead end, so nothing offers it.
|
||||
*
|
||||
* It is a real link to `?day=` on the current page, so it can be opened in a
|
||||
* new tab; a plain click opens the layer in place instead of navigating.
|
||||
*/
|
||||
export interface DayLinkProps {
|
||||
date: DateKey;
|
||||
children: ReactNode;
|
||||
/** What the person was looking at, shown at the top of the day. */
|
||||
focus?: Omit<DayFocus, "date">;
|
||||
className?: string;
|
||||
/** Size of the text; the link inherits it otherwise. */
|
||||
size?: "xs" | "sm";
|
||||
/**
|
||||
* `-1` keeps the link out of the tab order where its surface already owns
|
||||
* the keyboard (a roving-tabindex grid of cards), and offers the day on
|
||||
* another keyboard path.
|
||||
*/
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export const DAY_LINK_CLASS = cn(
|
||||
"text-foreground decoration-border relative rounded-sm underline decoration-[1.5px] underline-offset-[3px] tabular-nums",
|
||||
"hover:decoration-foreground focus-visible:ring-ring/50 transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
// A 44 px hit area on touch that does not change the line height.
|
||||
"pointer-coarse:after:absolute pointer-coarse:after:-inset-y-3 pointer-coarse:after:inset-x-0 pointer-coarse:after:content-['']",
|
||||
);
|
||||
|
||||
/** The open day is marked like a selected row, not underlined. */
|
||||
export const DAY_LINK_OPEN_CLASS =
|
||||
"bg-muted -mx-1.5 px-1.5 no-underline decoration-transparent";
|
||||
|
||||
export function DayLink({
|
||||
date,
|
||||
children,
|
||||
focus,
|
||||
className,
|
||||
size,
|
||||
tabIndex,
|
||||
}: DayLinkProps) {
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const today = useTodayKey();
|
||||
const open = useOpenDay();
|
||||
|
||||
if (!isOpenableDay(date, today)) {
|
||||
return (
|
||||
<span
|
||||
data-slot="day-link-plain"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
size === "xs" && "text-xs",
|
||||
size === "sm" && "text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = open === date;
|
||||
return (
|
||||
<a
|
||||
href={`${pathname}?${DAY_QUERY_PARAM}=${date}`}
|
||||
data-slot="day-link"
|
||||
data-day={date}
|
||||
data-open={isOpen ? "true" : undefined}
|
||||
tabIndex={tabIndex}
|
||||
title={t("day.openDay")}
|
||||
onClick={(event) => {
|
||||
// A modified click opens a new tab or window, as any link does.
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// The row around the date opens its entry; this click is the day's.
|
||||
event.stopPropagation();
|
||||
openDay(date, {
|
||||
focus: focus ? { ...focus, date } : null,
|
||||
trigger: event.currentTarget,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// A row that opens on Enter must not also open under the day.
|
||||
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
DAY_LINK_CLASS,
|
||||
size === "xs" && "text-xs",
|
||||
size === "sm" && "text-sm",
|
||||
isOpen && DAY_LINK_OPEN_CLASS,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `DayLink` for an instant (a reading's `measuredAt`, an intake's
|
||||
* `takenAt`): the day is the instant's calendar day in the record's display
|
||||
* zone, the zone every date on screen is printed in.
|
||||
*/
|
||||
export function DayLinkAt({
|
||||
at,
|
||||
...props
|
||||
}: Omit<DayLinkProps, "date"> & { at: Date | string | number }) {
|
||||
const timeZone = useDisplayTimezone();
|
||||
return <DayLink date={dateKeyOfInstant(at, timeZone)} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `DayLink` for a value that may be a stated calendar date stored at noon
|
||||
* UTC (a lab draw date, a report date) or a real instant. A noon-UTC anchor
|
||||
* is its UTC date in every zone; anything else is cut in the display zone,
|
||||
* exactly as the surface prints it.
|
||||
*/
|
||||
export function DayLinkStated({
|
||||
at,
|
||||
...props
|
||||
}: Omit<DayLinkProps, "date"> & { at: string | Date }) {
|
||||
const timeZone = useDisplayTimezone();
|
||||
if (typeof at === "string" && isDateOnlyKey(at)) {
|
||||
return <DayLink date={at} {...props} />;
|
||||
}
|
||||
const instant = new Date(at);
|
||||
const date = isNoonUtcAnchor(instant)
|
||||
? dateOnlyKey(instant)
|
||||
: dateKeyOfInstant(instant, timeZone);
|
||||
return <DayLink date={date} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a node where a translated sentence names a value. Pass the marker as
|
||||
* the parameter (`t("illness.onsetOn", { date: DAY_LINK_SLOT })`) and the
|
||||
* link replaces it, so a date inside a sentence can open its day without the
|
||||
* sentence being split into fragments in the bundle.
|
||||
*/
|
||||
export const DAY_LINK_SLOT = "day";
|
||||
|
||||
export function withDayLinkSlot(text: string, node: ReactNode): ReactNode {
|
||||
const at = text.indexOf(DAY_LINK_SLOT);
|
||||
if (at === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, at)}
|
||||
{node}
|
||||
{text.slice(at + DAY_LINK_SLOT.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
CalendarCheck,
|
||||
CalendarHeart,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Flag,
|
||||
Footprints,
|
||||
PauseCircle,
|
||||
Pill,
|
||||
ShieldAlert,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
DayEvent,
|
||||
DayEventKind,
|
||||
DayNotable,
|
||||
DayRunningItem,
|
||||
DayRunningKind,
|
||||
DayValue,
|
||||
} from "@/lib/day/contract";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { DayFocus } from "./day-layer-controller";
|
||||
import {
|
||||
curateDayValues,
|
||||
numberLinePositions,
|
||||
tileKeyOf,
|
||||
type DayValueTile,
|
||||
} from "./day-values-model";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
* The sections of one day: the value the person came from, what ran through
|
||||
* the day, the readings against the person's own usual range, and what
|
||||
* happened, in order of the clock.
|
||||
*
|
||||
* Every row that came from a record links to that record; the day is a place
|
||||
* to look from, and the entry is where it is edited.
|
||||
*/
|
||||
|
||||
/** Section label, UI-STANDARDS §5. */
|
||||
export const DAY_SECTION_LABEL =
|
||||
"text-muted-foreground text-xs font-medium tracking-wide uppercase";
|
||||
|
||||
/* ─── Focus ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function DayFocusCard({
|
||||
focus,
|
||||
usual,
|
||||
}: {
|
||||
focus: DayFocus;
|
||||
/** The usual range of the focus type that day, when the server has one. */
|
||||
usual: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const compare =
|
||||
focus.compare ??
|
||||
(usual ? { label: t("day.usualLabel"), value: usual } : null);
|
||||
return (
|
||||
<div
|
||||
data-slot="day-focus"
|
||||
className="bg-muted/60 flex items-end justify-between gap-3 rounded-lg px-3.5 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground truncate text-xs">{focus.label}</p>
|
||||
<p className="text-3xl font-semibold tracking-tight break-words tabular-nums">
|
||||
{focus.value}
|
||||
{focus.unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-base font-medium whitespace-nowrap">
|
||||
{focus.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{compare ? (
|
||||
<div className="shrink-0 text-right" data-slot="day-focus-compare">
|
||||
<p className="text-muted-foreground text-xs">{compare.label}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{compare.value}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Notable ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Words a notable observation from the bundle, its date as month and year. */
|
||||
export function useNotableText() {
|
||||
const { t, locale } = useTranslations();
|
||||
return (notable: DayNotable): string => {
|
||||
const params: Record<string, string | number> = { ...notable.params };
|
||||
const since = notable.params.since;
|
||||
if (typeof since === "string" && /^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
||||
params.since = new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${since}T12:00:00.000Z`));
|
||||
}
|
||||
return t(`day.notable.${notable.kind}`, params);
|
||||
};
|
||||
}
|
||||
|
||||
export function DayNotableLines({
|
||||
notables,
|
||||
labelFor,
|
||||
withMetric,
|
||||
}: {
|
||||
notables: readonly DayNotable[];
|
||||
labelFor: (tileKey: string) => string;
|
||||
/** Name the metric in front; off under the focus card, which names it. */
|
||||
withMetric: boolean;
|
||||
}) {
|
||||
const text = useNotableText();
|
||||
if (notables.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1.5" data-slot="day-notable">
|
||||
{notables.map((notable, i) => (
|
||||
<li
|
||||
key={`${notable.kind}-${notable.type ?? ""}-${i}`}
|
||||
className="text-muted-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<Sparkles className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{withMetric && notable.type
|
||||
? `${labelFor(tileKeyOf(notable.type))} · `
|
||||
: null}
|
||||
{text(notable)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Running ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const RUNNING_COLOR: Record<DayRunningKind, string> = {
|
||||
medication: "var(--chart-1)",
|
||||
medicationCourse: "var(--chart-1)",
|
||||
medicationPause: "var(--muted-foreground)",
|
||||
illness: "var(--chart-5)",
|
||||
restMode: "var(--chart-5)",
|
||||
cyclePhase: "var(--chart-3)",
|
||||
travel: "var(--chart-4)",
|
||||
lifestyle: "var(--muted-foreground)",
|
||||
lifeEvent: "var(--chart-2)",
|
||||
};
|
||||
|
||||
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-running">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.running")}</h3>
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const parts = [
|
||||
item.sub,
|
||||
item.dayIndex !== null
|
||||
? item.dayCount !== null
|
||||
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
|
||||
: t("day.dayN", { n: item.dayIndex })
|
||||
: null,
|
||||
item.dayCount === null
|
||||
? t("day.since", {
|
||||
date: fmt.dateShortSmartCalendar(item.since),
|
||||
})
|
||||
: null,
|
||||
].filter((part): part is string => !!part);
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-[3px] shrink-0 self-stretch rounded-full"
|
||||
style={{ backgroundColor: RUNNING_COLOR[item.kind] }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
{item.href ? (
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
data-slot="day-running-item"
|
||||
className="hover:bg-muted/60 focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-3 rounded-md px-2 py-1.5 transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
data-slot="day-running-item"
|
||||
className="flex min-h-11 items-center gap-3 py-1.5"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Values ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function NumberLine({
|
||||
value,
|
||||
band,
|
||||
}: {
|
||||
value: number;
|
||||
band: { lo: number; hi: number };
|
||||
}) {
|
||||
const pos = numberLinePositions(value, band);
|
||||
return (
|
||||
<div
|
||||
className="relative mt-1.5 h-2.5"
|
||||
aria-hidden="true"
|
||||
data-slot="day-number-line"
|
||||
>
|
||||
<span className="bg-border absolute inset-x-0 top-1 h-0.5 rounded-full" />
|
||||
<span
|
||||
className="bg-muted-foreground/45 absolute top-[3px] h-1 rounded-full"
|
||||
style={{
|
||||
left: `${pos.lo}%`,
|
||||
width: `${Math.max(pos.hi - pos.lo, 1)}%`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="bg-foreground ring-card absolute top-px -ml-1 size-2 rounded-full ring-2"
|
||||
style={{ left: `${pos.point}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueTile({
|
||||
tile,
|
||||
selected,
|
||||
}: {
|
||||
tile: DayValueTile;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { formatTile } = useDayValueFormat();
|
||||
const shown = formatTile(tile);
|
||||
// The line plots the first row: systolic for a blood pressure.
|
||||
const lead = tile.values[0];
|
||||
return (
|
||||
<li
|
||||
data-slot="day-value"
|
||||
data-type={tile.key}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"bg-muted/60 flex min-w-0 flex-col rounded-lg px-3 py-2.5",
|
||||
selected && "ring-foreground ring-[1.5px] ring-inset",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{shown.label}
|
||||
</span>
|
||||
<span className="truncate text-base font-semibold tabular-nums">
|
||||
{shown.value}
|
||||
{shown.unit ? (
|
||||
<span className="text-muted-foreground ml-0.5 text-xs font-medium">
|
||||
{shown.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{lead?.band ? (
|
||||
<>
|
||||
<NumberLine value={lead.value} band={lead.band} />
|
||||
{shown.usualParts ? (
|
||||
<span className="sr-only">
|
||||
{shown.usualParts
|
||||
.map((part) => t("day.usualRange", part))
|
||||
.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayValues({
|
||||
values,
|
||||
focusTypes,
|
||||
}: {
|
||||
values: readonly DayValue[];
|
||||
focusTypes: readonly string[];
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (values.length === 0) return null;
|
||||
const { curated, rest } = curateDayValues(values, focusTypes);
|
||||
const focusKeys = new Set(focusTypes.map(tileKeyOf));
|
||||
const tiles = expanded ? [...curated, ...rest] : curated;
|
||||
const total = curated.length + rest.length;
|
||||
return (
|
||||
<section className="space-y-2" data-slot="day-values">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.values")}</h3>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{t("day.valuesLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{tiles.map((tile) => (
|
||||
<ValueTile
|
||||
key={tile.key}
|
||||
tile={tile}
|
||||
selected={focusKeys.has(tile.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{rest.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-values-all"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((open) => !open)}
|
||||
className="focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
{expanded ? t("day.valuesFewer") : tCount("day.valuesAll", total)}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 transition-transform motion-reduce:transition-none",
|
||||
expanded && "-rotate-90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Events ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const EVENT_ICON: Record<DayEventKind, LucideIcon> = {
|
||||
intake: Pill,
|
||||
doseChange: Pill,
|
||||
medicationStart: Pill,
|
||||
medicationEnd: Pill,
|
||||
pauseStart: PauseCircle,
|
||||
pauseEnd: PauseCircle,
|
||||
courseStart: Pill,
|
||||
courseEnd: Pill,
|
||||
illnessOnset: Thermometer,
|
||||
illnessResolved: Thermometer,
|
||||
illnessDayLog: Thermometer,
|
||||
symptom: Activity,
|
||||
allergyOnset: ShieldAlert,
|
||||
labResult: FlaskConical,
|
||||
visit: Stethoscope,
|
||||
procedure: Stethoscope,
|
||||
vaccination: Syringe,
|
||||
checkup: CalendarCheck,
|
||||
document: FileText,
|
||||
mood: Smile,
|
||||
assessment: Brain,
|
||||
workout: Footprints,
|
||||
cycleDayLog: CalendarHeart,
|
||||
lifeEvent: Flag,
|
||||
};
|
||||
|
||||
export function DayEvents({ events }: { events: readonly DayEvent[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (events.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-1" data-slot="day-events">
|
||||
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.happened")}</h3>
|
||||
<ul className="divide-border divide-y">
|
||||
{events.map((event) => {
|
||||
const Icon = EVENT_ICON[event.kind];
|
||||
return (
|
||||
<li
|
||||
key={`${event.kind}-${event.id}`}
|
||||
data-slot="day-event"
|
||||
data-kind={event.kind}
|
||||
className={cn(
|
||||
"relative grid grid-cols-[2.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5",
|
||||
event.href && "hover:bg-muted/40 rounded-md",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs leading-5 tabular-nums">
|
||||
{event.at ? fmt.time(event.at) : ""}
|
||||
</span>
|
||||
<Icon
|
||||
className="text-muted-foreground mt-0.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
{event.href ? (
|
||||
// The whole row is the target; the title carries the link
|
||||
// so the document chips below stay their own targets.
|
||||
<Link
|
||||
href={event.href}
|
||||
data-slot="day-event-link"
|
||||
className="focus-visible:ring-ring/50 block text-sm leading-5 font-medium after:absolute after:inset-0 after:rounded-md after:content-[''] focus-visible:outline-none focus-visible:after:ring-[3px] focus-visible:after:ring-inherit"
|
||||
>
|
||||
{event.title}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm leading-5 font-medium">{event.title}</p>
|
||||
)}
|
||||
{event.meta ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{event.meta}
|
||||
</p>
|
||||
) : null}
|
||||
{event.note ? (
|
||||
// The person's own words: content, never muted.
|
||||
<p className="mt-0.5 text-sm" data-slot="day-event-note">
|
||||
{event.note}
|
||||
</p>
|
||||
) : null}
|
||||
{event.docs.length > 0 ? (
|
||||
<ul className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{event.docs.map((doc) => (
|
||||
<li key={doc.id} className="relative z-10 min-w-0">
|
||||
<Link
|
||||
href={`/documents?doc=${encodeURIComponent(doc.id)}`}
|
||||
data-slot="day-event-doc"
|
||||
title={t("day.openDocument")}
|
||||
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex max-w-full items-center gap-1.5 rounded-md border py-1 pr-2.5 pl-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FileText
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{doc.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
{event.href ? (
|
||||
<ChevronRight
|
||||
className="text-muted-foreground mt-0.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The day layer's address: `?day=YYYY-MM-DD` on whatever page is open.
|
||||
*
|
||||
* Pure helpers, no React, so the rules the layer lives by (which dates open,
|
||||
* how the URL is spelled, what a step to the neighbouring day lands on) can be
|
||||
* pinned without mounting anything.
|
||||
*
|
||||
* A day opens only when it names a real calendar date that is not in the
|
||||
* future of the record's own zone. A future day has no content to show; a
|
||||
* link to one would be a dead end, so such a parameter is dropped quietly,
|
||||
* the same way `?add=` is on the measurements page.
|
||||
*/
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { dateOnlyKey, isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { getDateTimeFormat } from "@/lib/tz/intl-cache";
|
||||
|
||||
/**
|
||||
* The history-state marker of an entry the layer pushed itself. Closing such
|
||||
* an entry goes back one step; closing a deep link only edits the URL, so the
|
||||
* person stays on the page they arrived at.
|
||||
*/
|
||||
export const DAY_HISTORY_KEY = "__healthlogDayLayer";
|
||||
|
||||
/** Today's calendar date in `timeZone`, `YYYY-MM-DD`. */
|
||||
export function todayKeyInZone(timeZone: string, now = new Date()): DateKey {
|
||||
// en-CA spells a date as YYYY-MM-DD.
|
||||
return getDateTimeFormat("en-CA", timeZone, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
/** The calendar date of an instant in `timeZone`. */
|
||||
export function dateKeyOfInstant(
|
||||
instant: Date | string | number,
|
||||
timeZone: string,
|
||||
): DateKey {
|
||||
return todayKeyInZone(timeZone, new Date(instant));
|
||||
}
|
||||
|
||||
/** True when `key` is a real date on or before `todayKey`. */
|
||||
export function isOpenableDay(
|
||||
key: string | null | undefined,
|
||||
todayKey: DateKey,
|
||||
): key is DateKey {
|
||||
return typeof key === "string" && isCalendarDateKey(key) && key <= todayKey;
|
||||
}
|
||||
|
||||
/** The parameter's day, or null when it names nothing the layer can open. */
|
||||
export function parseDayParam(
|
||||
raw: string | null,
|
||||
todayKey: DateKey,
|
||||
): DateKey | null {
|
||||
return isOpenableDay(raw, todayKey) ? raw : null;
|
||||
}
|
||||
|
||||
/** `key` moved by `delta` calendar days. */
|
||||
export function shiftDateKey(key: DateKey, delta: number): DateKey {
|
||||
const anchor = new Date(`${key}T12:00:00.000Z`);
|
||||
anchor.setUTCDate(anchor.getUTCDate() + delta);
|
||||
return dateOnlyKey(anchor);
|
||||
}
|
||||
|
||||
/** Calendar days from `from` to `to` (negative when `to` is earlier). */
|
||||
export function daysBetween(from: DateKey, to: DateKey): number {
|
||||
const a = Date.parse(`${from}T12:00:00.000Z`);
|
||||
const b = Date.parse(`${to}T12:00:00.000Z`);
|
||||
return Math.round((b - a) / 86_400_000);
|
||||
}
|
||||
|
||||
/** The current location with the day set, or removed when `key` is null. */
|
||||
export function withDayHref(
|
||||
pathname: string,
|
||||
search: string,
|
||||
key: DateKey | null,
|
||||
): string {
|
||||
const next = new URLSearchParams(search);
|
||||
if (key === null) next.delete(DAY_QUERY_PARAM);
|
||||
else next.set(DAY_QUERY_PARAM, key);
|
||||
const query = next.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a day carries off its own page: the dashboard with the day open
|
||||
* over it. Closing the layer there lands on today, which is what a link from
|
||||
* outside the page (a Coach chip, a notification) should do.
|
||||
*/
|
||||
export function externalDayHref(key: DateKey): string {
|
||||
return `/?${DAY_QUERY_PARAM}=${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The history state the layer writes. Only application state: Next copies its
|
||||
* router fields onto the object itself, and copying `history.state` here would
|
||||
* carry them over and make Next treat the write as its own.
|
||||
*/
|
||||
export function dayHistoryState(key: DateKey): Record<string, unknown> {
|
||||
return { [DAY_HISTORY_KEY]: key };
|
||||
}
|
||||
|
||||
/** True when the current history entry was pushed by the layer. */
|
||||
export function historyEntryOwnedByLayer(state: unknown): boolean {
|
||||
return (
|
||||
state !== null &&
|
||||
typeof state === "object" &&
|
||||
typeof (state as Record<string, unknown>)[DAY_HISTORY_KEY] === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Which of a day's values the view shows first, and how they pair up.
|
||||
*
|
||||
* Pure, so the curation can be pinned without rendering. The server sends one
|
||||
* row per type (canonical source, band already decided); this only orders the
|
||||
* rows and folds the two halves of a blood pressure into one tile, because a
|
||||
* reading of 138 over 88 is one thing to a person, not two.
|
||||
*/
|
||||
import type { DayValue } from "@/lib/day/contract";
|
||||
|
||||
/** How many tiles the day shows before "All values". */
|
||||
export const CURATED_VALUE_COUNT = 8;
|
||||
|
||||
/**
|
||||
* The order a person reads a day in: the core vitals first, then sleep and
|
||||
* movement, then the rest. A type not listed comes after every listed one,
|
||||
* in the order the server sent it.
|
||||
*/
|
||||
export const VALUE_PRIORITY: readonly string[] = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"RESTING_HEART_RATE",
|
||||
"PULSE",
|
||||
"WEIGHT",
|
||||
"BODY_TEMPERATURE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"BLOOD_GLUCOSE",
|
||||
"OXYGEN_SATURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"BODY_FAT",
|
||||
];
|
||||
|
||||
export interface DayValueTile {
|
||||
/** Stable key: the first type, `BLOOD_PRESSURE` for the pair. */
|
||||
key: string;
|
||||
/** The rows the tile shows, systolic before diastolic for the pair. */
|
||||
values: DayValue[];
|
||||
}
|
||||
|
||||
/** The tile key a type belongs to. */
|
||||
export function tileKeyOf(type: string): string {
|
||||
return type === "BLOOD_PRESSURE_SYS" || type === "BLOOD_PRESSURE_DIA"
|
||||
? "BLOOD_PRESSURE"
|
||||
: type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold, order and split the day's values. `focusTypes` (the value the person
|
||||
* came from) always lands among the first tiles, wherever its type ranks.
|
||||
*/
|
||||
export function curateDayValues(
|
||||
values: readonly DayValue[],
|
||||
focusTypes: readonly string[] = [],
|
||||
): { curated: DayValueTile[]; rest: DayValueTile[] } {
|
||||
const tiles = new Map<string, DayValueTile>();
|
||||
for (const value of values) {
|
||||
const key = tileKeyOf(value.type);
|
||||
const tile = tiles.get(key) ?? { key, values: [] };
|
||||
tile.values.push(value);
|
||||
tiles.set(key, tile);
|
||||
}
|
||||
for (const tile of tiles.values()) {
|
||||
if (tile.key === "BLOOD_PRESSURE") {
|
||||
tile.values.sort((a, b) =>
|
||||
a.type === b.type ? 0 : a.type === "BLOOD_PRESSURE_SYS" ? -1 : 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rank = (tile: DayValueTile): number => {
|
||||
const first =
|
||||
tile.key === "BLOOD_PRESSURE" ? "BLOOD_PRESSURE_SYS" : tile.key;
|
||||
const index = VALUE_PRIORITY.indexOf(first);
|
||||
return index === -1 ? VALUE_PRIORITY.length : index;
|
||||
};
|
||||
const sent = [...tiles.values()];
|
||||
const ordered = sent
|
||||
.map((tile, arrival) => ({ tile, arrival }))
|
||||
.sort((a, b) => rank(a.tile) - rank(b.tile) || a.arrival - b.arrival)
|
||||
.map(({ tile }) => tile);
|
||||
|
||||
const focusKeys = new Set(focusTypes.map(tileKeyOf));
|
||||
const focused = ordered.filter((tile) => focusKeys.has(tile.key));
|
||||
const others = ordered.filter((tile) => !focusKeys.has(tile.key));
|
||||
const head = [...focused, ...others].slice(0, CURATED_VALUE_COUNT);
|
||||
const headKeys = new Set(head.map((tile) => tile.key));
|
||||
// Keep the reading order inside the first tiles: the focus is marked, not
|
||||
// moved to the front.
|
||||
const curated = ordered.filter((tile) => headKeys.has(tile.key));
|
||||
const rest = ordered.filter((tile) => !headKeys.has(tile.key));
|
||||
return { curated, rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a value and its usual range sit on a short line, in percent. The
|
||||
* line spans the band with room on both sides so a value inside the range
|
||||
* reads as inside, and stretches to include a value outside it.
|
||||
*/
|
||||
export function numberLinePositions(
|
||||
value: number,
|
||||
band: { lo: number; hi: number },
|
||||
): { lo: number; hi: number; point: number } {
|
||||
const lo = Math.min(band.lo, band.hi);
|
||||
const hi = Math.max(band.lo, band.hi);
|
||||
const span = hi - lo || Math.max(Math.abs(hi) * 0.1, 1);
|
||||
let min = lo - span * 0.75;
|
||||
let max = hi + span * 0.75;
|
||||
if (value < min) min = value - span * 0.15;
|
||||
if (value > max) max = value + span * 0.15;
|
||||
const width = max - min;
|
||||
const at = (n: number) =>
|
||||
Math.round(Math.min(100, Math.max(0, ((n - min) / width) * 100)) * 10) / 10;
|
||||
return { lo: at(lo), hi: at(hi), point: at(value) };
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
CapturePicker,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { DayFocus } from "./day-layer-controller";
|
||||
import {
|
||||
DayEvents,
|
||||
DayFocusCard,
|
||||
DayNotableLines,
|
||||
DayRunning,
|
||||
DayValues,
|
||||
} from "./day-sections";
|
||||
import { curateDayValues, tileKeyOf } from "./day-values-model";
|
||||
import { useDay } from "./use-day";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
|
||||
/**
|
||||
* One day, as the layer shows it in every shell: a header with the date and
|
||||
* the way to the neighbouring days, the sections, and a footer with the two
|
||||
* ways onward.
|
||||
*
|
||||
* The shell decides only the frame (docked column, sheet from the right,
|
||||
* sheet from the bottom). Header, body and footer are this component, so the
|
||||
* three frames cannot drift apart.
|
||||
*/
|
||||
|
||||
/** "Saturday, 3 January 2026", for the header and the spoken name. */
|
||||
export function useLongDayLabel(): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
}
|
||||
|
||||
export interface DayViewProps {
|
||||
date: DateKey;
|
||||
today: DateKey;
|
||||
focus: DayFocus | null;
|
||||
shell: "docked" | "sheet" | "bottom";
|
||||
onClose: () => void;
|
||||
onStep: (delta: number) => void;
|
||||
/** The heading element: an `h2`, or the sheet primitive's title. */
|
||||
Title: ComponentType<{
|
||||
id?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
ref?: Ref<HTMLHeadingElement>;
|
||||
tabIndex?: number;
|
||||
}>;
|
||||
titleId: string;
|
||||
titleRef?: Ref<HTMLHeadingElement>;
|
||||
/** Shell chrome above the header (the bottom sheet's grabber). */
|
||||
above?: ReactNode;
|
||||
/** Classes for the header row, which the docked shell aligns to the top bar. */
|
||||
headerClassName?: string;
|
||||
}
|
||||
|
||||
const ICON_BUTTON =
|
||||
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
|
||||
|
||||
export function DayView({
|
||||
date,
|
||||
today,
|
||||
focus,
|
||||
shell,
|
||||
onClose,
|
||||
onStep,
|
||||
Title,
|
||||
titleId,
|
||||
titleRef,
|
||||
above,
|
||||
headerClassName,
|
||||
}: DayViewProps) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const longLabel = useLongDayLabel();
|
||||
const pathname = usePathname();
|
||||
const day = useDay(date);
|
||||
const { labelFor, formatTile } = useDayValueFormat();
|
||||
const timelineOn = useModuleEnabled("timeline");
|
||||
const coach = useAiCapability("coach");
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { user } = useAuth();
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
|
||||
.length > 0;
|
||||
const onTimeline = pathname.startsWith("/timeline");
|
||||
const data = day.data;
|
||||
const focusTypes = focus?.types ?? [];
|
||||
const focusKeys = new Set(focusTypes.map(tileKeyOf));
|
||||
const focusNotables =
|
||||
data?.notable.filter(
|
||||
(n) => n.type !== null && focusKeys.has(tileKeyOf(n.type)),
|
||||
) ?? [];
|
||||
const otherNotables =
|
||||
data?.notable.filter(
|
||||
(n) => !(n.type !== null && focusKeys.has(tileKeyOf(n.type))),
|
||||
) ?? [];
|
||||
const focusTile =
|
||||
focus && data
|
||||
? curateDayValues(data.values, focusTypes).curated.find((tile) =>
|
||||
focusKeys.has(tile.key),
|
||||
)
|
||||
: undefined;
|
||||
const isEmpty =
|
||||
data !== undefined &&
|
||||
data.values.length === 0 &&
|
||||
data.events.length === 0 &&
|
||||
data.running.length === 0;
|
||||
const notShared = data
|
||||
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
|
||||
: false;
|
||||
const meta = data
|
||||
? [
|
||||
tCount("day.countValues", data.counts.values),
|
||||
tCount("day.countEntries", data.counts.entries),
|
||||
].join(" · ")
|
||||
: null;
|
||||
const compact = shell === "bottom";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="day-view"
|
||||
data-day={date}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{above}
|
||||
<div
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
compact
|
||||
? "pt-1.5 pr-2 pb-2.5 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Title
|
||||
id={titleId}
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="truncate text-base leading-snug font-semibold focus-visible:outline-none"
|
||||
>
|
||||
{longLabel(date)}
|
||||
</Title>
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs tabular-nums"
|
||||
data-slot="day-meta"
|
||||
>
|
||||
{meta ?? <span className="invisible">·</span>}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
size="icon"
|
||||
data-slot="day-prev"
|
||||
aria-label={t("day.previousDay")}
|
||||
title={t("day.previousDayShortcut")}
|
||||
onClick={() => onStep(-1)}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-5 pointer-fine:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={compact ? "ghost" : "outline"}
|
||||
size="icon"
|
||||
data-slot="day-next"
|
||||
aria-label={t("day.nextDay")}
|
||||
title={t("day.nextDayShortcut")}
|
||||
disabled={date >= today}
|
||||
onClick={() => onStep(1)}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-5 pointer-fine:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-close"
|
||||
aria-label={t("day.close")}
|
||||
title={t("day.close")}
|
||||
onClick={onClose}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-slot="day-body"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
|
||||
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
|
||||
)}
|
||||
>
|
||||
{focus ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayFocusCard
|
||||
focus={focus}
|
||||
usual={focusTile ? formatTile(focusTile).usual : null}
|
||||
/>
|
||||
<DayNotableLines
|
||||
notables={focusNotables}
|
||||
labelFor={labelFor}
|
||||
withMetric={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{day.isError ? (
|
||||
<QueryErrorRow
|
||||
message={t("day.loadFailed")}
|
||||
onRetry={() => void day.refetch()}
|
||||
slot="day-error"
|
||||
/>
|
||||
) : data === undefined ? (
|
||||
<DaySkeleton />
|
||||
) : isEmpty ? (
|
||||
<p className="text-muted-foreground text-sm" data-slot="day-empty">
|
||||
{t("day.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<DayRunning items={data.running} />
|
||||
{data.values.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
<DayValues values={data.values} focusTypes={focusTypes} />
|
||||
<DayNotableLines
|
||||
notables={otherNotables}
|
||||
labelFor={labelFor}
|
||||
withMetric
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DayNotableLines
|
||||
notables={otherNotables}
|
||||
labelFor={labelFor}
|
||||
withMetric
|
||||
/>
|
||||
)}
|
||||
<DayEvents events={data.events} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{notShared ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="day-not-shared"
|
||||
>
|
||||
{t("day.notShared")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{coach.available && !capabilities.inSharedRecord ? (
|
||||
<Link
|
||||
href={`/coach?ask=${encodeURIComponent(
|
||||
t("day.askCoachPrompt", { date: longLabel(date) }),
|
||||
)}`}
|
||||
data-slot="day-ask-coach"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
<MessageCircle className="size-4" aria-hidden="true" />
|
||||
{t("day.askCoach")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canCapture || (timelineOn && !onTimeline) ? (
|
||||
<div
|
||||
data-slot="day-footer"
|
||||
className={cn(
|
||||
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
|
||||
compact
|
||||
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
|
||||
: "px-6 py-3.5",
|
||||
)}
|
||||
>
|
||||
{timelineOn && !onTimeline ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("min-h-11 sm:min-h-9", compact && "flex-1")}
|
||||
>
|
||||
<Link
|
||||
href={`/timeline?${DAY_QUERY_PARAM}=${date}`}
|
||||
data-slot="day-in-timeline"
|
||||
>
|
||||
<History className="size-4" aria-hidden="true" />
|
||||
{compact ? t("day.inTimelineShort") : t("day.inTimeline")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{canCapture ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
data-slot="day-capture"
|
||||
onClick={() => setCaptureOpen(true)}
|
||||
className={cn("min-h-11 sm:min-h-9", compact && "flex-1")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{compact ? t("day.captureShort") : t("day.captureForDay")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCapture ? (
|
||||
<CapturePicker
|
||||
open={captureOpen}
|
||||
onOpenChange={setCaptureOpen}
|
||||
defaultDate={date}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DaySkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" data-slot="day-loading" aria-hidden="true">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-16 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
/**
|
||||
* The `YYYY-MM-DDTHH:mm` a capture form starts with, in the browser's local
|
||||
* time, the shape every quick-entry form keeps its date-time field in.
|
||||
*
|
||||
* Without a day it is now. Opened from a past day ("Capture for this day"),
|
||||
* it is that day at the current time of day, so the person only adjusts the
|
||||
* clock if it matters. A day that is not a calendar date falls back to now.
|
||||
*/
|
||||
export function localDateTimeValue(
|
||||
dateKey?: string | null,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
if (!dateKey || !isCalendarDateKey(dateKey)) return local;
|
||||
return `${dateKey}${local.slice(10)}`;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
CircleDashed,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Pill,
|
||||
ShieldAlert,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
DateKey,
|
||||
DayEventKind,
|
||||
DayNotable,
|
||||
DayResponse,
|
||||
DaySectionKey,
|
||||
} from "@/lib/day/contract";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { DAY_SECTION_LABEL, useNotableText } from "./day-sections";
|
||||
import { DayLink } from "./day-link";
|
||||
import { dateKeyOfInstant, shiftDateKey, daysBetween } from "./day-url";
|
||||
import { tileKeyOf } from "./day-values-model";
|
||||
import { useDayIndex } from "./use-day";
|
||||
import { useDayValueFormat } from "./use-day-value-format";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
/**
|
||||
* "Since the last visit": the preparation the next planned appointment
|
||||
* otherwise lacks. Dose changes, episodes that began, new results, extreme
|
||||
* readings and gaps between the last visit and today, each dated, each date
|
||||
* opening its day. Compiled from the record without any judgement; the
|
||||
* wording is the day view's own.
|
||||
*
|
||||
* Built from what the day routes already answer: the index names the days
|
||||
* that carry a context change or a notable observation, and those days (at
|
||||
* most {@link MAX_DAYS_READ}) are read for their entries. Nothing is
|
||||
* recomputed here.
|
||||
*/
|
||||
|
||||
/** Sections whose presence alone makes a day worth reading here. */
|
||||
const CONTEXT_SECTIONS: readonly DaySectionKey[] = [
|
||||
"illness",
|
||||
"labs",
|
||||
"vaccinations",
|
||||
"visits",
|
||||
"allergies",
|
||||
];
|
||||
|
||||
/** Entry kinds that are a change of context, the rows this block lists. */
|
||||
const CONTEXT_EVENTS: ReadonlySet<DayEventKind> = new Set<DayEventKind>([
|
||||
"doseChange",
|
||||
"medicationStart",
|
||||
"medicationEnd",
|
||||
"pauseStart",
|
||||
"courseStart",
|
||||
"illnessOnset",
|
||||
"allergyOnset",
|
||||
"labResult",
|
||||
"procedure",
|
||||
"vaccination",
|
||||
]);
|
||||
|
||||
const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
|
||||
doseChange: Pill,
|
||||
medicationStart: Pill,
|
||||
medicationEnd: Pill,
|
||||
pauseStart: Pill,
|
||||
courseStart: Pill,
|
||||
illnessOnset: Thermometer,
|
||||
allergyOnset: ShieldAlert,
|
||||
labResult: FlaskConical,
|
||||
procedure: Stethoscope,
|
||||
vaccination: Syringe,
|
||||
};
|
||||
|
||||
/** Days read for their entries, newest kept when there are more. */
|
||||
export const MAX_DAYS_READ = 12;
|
||||
/** Rows shown before "All". */
|
||||
export const VISIBLE_ROWS = 5;
|
||||
|
||||
interface PrepRow {
|
||||
key: string;
|
||||
date: DateKey;
|
||||
Icon: LucideIcon;
|
||||
title: string;
|
||||
meta: string | null;
|
||||
}
|
||||
|
||||
/** The days to read: notable ones first, then context days, newest first. */
|
||||
export function pickPreparationDays(
|
||||
index: {
|
||||
days: Record<string, readonly string[]>;
|
||||
notable: readonly string[];
|
||||
},
|
||||
max = MAX_DAYS_READ,
|
||||
): DateKey[] {
|
||||
const notable = [...new Set(index.notable)].sort().reverse();
|
||||
const context = Object.entries(index.days)
|
||||
.filter(([, sections]) =>
|
||||
sections.some((s) => CONTEXT_SECTIONS.includes(s as DaySectionKey)),
|
||||
)
|
||||
.map(([day]) => day)
|
||||
.sort()
|
||||
.reverse();
|
||||
const picked: DateKey[] = [];
|
||||
for (const day of [...notable, ...context]) {
|
||||
if (!picked.includes(day)) picked.push(day);
|
||||
if (picked.length >= max) break;
|
||||
}
|
||||
return picked.sort();
|
||||
}
|
||||
|
||||
export function SinceLastVisit({
|
||||
next,
|
||||
last,
|
||||
}: {
|
||||
/** The next planned visit, the one this prepares. */
|
||||
next: EncounterDTO;
|
||||
/** The last visit that took place. */
|
||||
last: EncounterDTO;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const today = useTodayKey();
|
||||
const { labelFor, formatTile } = useDayValueFormat();
|
||||
const notableText = useNotableText();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const lastDay = dateKeyOfInstant(last.occurredAt, timeZone);
|
||||
// The window starts the day after the last visit and ends today; the index
|
||||
// answers at most a year at a time, which is the stretch that matters here.
|
||||
const from =
|
||||
daysBetween(shiftDateKey(lastDay, 1), today) > 365
|
||||
? shiftDateKey(today, -365)
|
||||
: shiftDateKey(lastDay, 1);
|
||||
const index = useDayIndex(from, today, from <= today);
|
||||
const days = index.data ? pickPreparationDays(index.data) : [];
|
||||
const reads = useQueries({
|
||||
queries: days.map((day) => ({
|
||||
queryKey: queryKeys.day(day),
|
||||
queryFn: () => apiGet<DayResponse>(`/api/day/${day}`),
|
||||
staleTime: 5 * 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// "Blood pressure 152/94 mmHg": the reading a notable day is about, in the
|
||||
// day view's own spelling.
|
||||
const notableTitle = (notable: DayNotable): string => {
|
||||
if (!notable.type) return "";
|
||||
const label = labelFor(tileKeyOf(notable.type));
|
||||
const value = notable.params.value;
|
||||
if (typeof value !== "number") return label;
|
||||
const shown = formatTile({
|
||||
key: tileKeyOf(notable.type),
|
||||
values: [
|
||||
{
|
||||
type: notable.type,
|
||||
value,
|
||||
unit: getUnitForType(notable.type),
|
||||
at: "",
|
||||
source: "",
|
||||
band: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return `${label} ${shown.value}${shown.unit ? ` ${shown.unit}` : ""}`;
|
||||
};
|
||||
|
||||
const rows: PrepRow[] = [];
|
||||
reads.forEach((read, i) => {
|
||||
const day = days[i];
|
||||
if (!day || !read.data) return;
|
||||
for (const event of read.data.events) {
|
||||
if (!CONTEXT_EVENTS.has(event.kind)) continue;
|
||||
rows.push({
|
||||
key: `${event.kind}-${event.id}`,
|
||||
date: day,
|
||||
Icon: EVENT_ICON[event.kind] ?? FileText,
|
||||
title: event.title,
|
||||
meta: event.meta,
|
||||
});
|
||||
}
|
||||
for (const notable of read.data.notable) {
|
||||
if (notable.kind === "firstValue") continue;
|
||||
rows.push({
|
||||
key: `notable-${day}-${notable.kind}-${notable.type ?? ""}`,
|
||||
date: day,
|
||||
Icon: notable.kind === "gap" ? CircleDashed : Activity,
|
||||
title: notableTitle(notable),
|
||||
meta: notableText(notable),
|
||||
});
|
||||
}
|
||||
});
|
||||
rows.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
|
||||
const loading =
|
||||
index.isPending ||
|
||||
reads.some((read) => read.isPending && read.fetchStatus !== "idle");
|
||||
const failed = index.isError || reads.some((read) => read.isError);
|
||||
if (!loading && !failed && rows.length === 0) return null;
|
||||
// A preparation that could not be read is left out rather than shown half:
|
||||
// the visit card stands on its own, and the day links stay everywhere else.
|
||||
if (failed && rows.length === 0) return null;
|
||||
|
||||
const visible = showAll ? rows : rows.slice(0, VISIBLE_ROWS);
|
||||
const lastLabel = fmt.dateShortSmartCalendar(lastDay);
|
||||
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="since-last-visit"
|
||||
aria-labelledby={`since-last-visit-${next.id}`}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 id={`since-last-visit-${next.id}`} className={DAY_SECTION_LABEL}>
|
||||
{t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
</h3>
|
||||
{rows.length > 0 ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="space-y-2" aria-hidden="true">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
|
||||
{visible.map((row) => (
|
||||
<li
|
||||
key={row.key}
|
||||
data-slot="since-last-visit-row"
|
||||
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 py-2.5 sm:grid-cols-[7.5rem_1.25rem_minmax(0,1fr)]"
|
||||
>
|
||||
<span className="col-start-2 text-sm sm:col-start-1 sm:row-start-1">
|
||||
<DayLink date={row.date}>
|
||||
{fmt.dateShortSmartCalendar(row.date)}
|
||||
</DayLink>
|
||||
</span>
|
||||
<row.Icon
|
||||
className="text-muted-foreground row-span-2 mt-0.5 size-4 sm:row-span-1 sm:row-start-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
|
||||
{row.title ? <p className="text-sm">{row.title}</p> : null}
|
||||
{row.meta ? (
|
||||
<p className="text-muted-foreground text-xs">{row.meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{rows.length > VISIBLE_ROWS ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAll}
|
||||
onClick={() => setShowAll((value) => !value)}
|
||||
className="focus-visible:ring-ring/50 min-h-11 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
{showAll
|
||||
? t("day.sinceLastVisit.fewer")
|
||||
: tCount("day.sinceLastVisit.all", rows.length)}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("day.sinceLastVisit.note")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
{t("day.sinceLastVisit.report", { date: lastLabel })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import type { DayValue } from "@/lib/day/contract";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
import type { DayValueTile } from "./day-values-model";
|
||||
|
||||
/** Count metrics are whole numbers by definition. */
|
||||
const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
|
||||
"ACTIVITY_STEPS",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"TIME_IN_DAYLIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"OXYGEN_SATURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
]);
|
||||
|
||||
/** True for a type read in whole numbers (counts, pressures, pulses). */
|
||||
export function isWholeNumberType(type: string): boolean {
|
||||
return WHOLE_NUMBER_TYPES.has(type);
|
||||
}
|
||||
|
||||
export interface FormattedDayValue {
|
||||
label: string;
|
||||
/** The number (or duration) as it is read. */
|
||||
value: string;
|
||||
/** The unit beside it, or "" when the value carries its own. */
|
||||
unit: string;
|
||||
/** The usual range in the same spelling, or null without a band. */
|
||||
usual: string | null;
|
||||
/** The usual range per row, for the spoken form. */
|
||||
usualParts: Array<{ lo: string; hi: string }> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a day's values the way every other surface in the app shows them:
|
||||
* the person's unit preference, the locale's decimal separator, sleep as a
|
||||
* duration. The server sends canonical units; a row whose unit is not the
|
||||
* canonical one is shown as sent.
|
||||
*/
|
||||
export function useDayValueFormat() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
|
||||
const labelFor = useCallback(
|
||||
(tileKey: string): string => {
|
||||
if (tileKey === "BLOOD_PRESSURE") return t("charts.bloodPressure");
|
||||
const key = (MEASUREMENT_TYPE_LABEL_KEYS as Record<string, string>)[
|
||||
tileKey
|
||||
];
|
||||
return key ? t(key) : tileKey;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const number = useCallback(
|
||||
(row: Pick<DayValue, "type" | "unit">, raw: number): string => {
|
||||
const canonical = row.unit === getUnitForType(row.type);
|
||||
const shown = canonical ? units.toDisplay(row.type, raw) : raw;
|
||||
if (row.type === "SLEEP_DURATION" && canonical) {
|
||||
return formatDurationMinutes(shown, t);
|
||||
}
|
||||
if (WHOLE_NUMBER_TYPES.has(row.type)) {
|
||||
return fmt.number(Math.round(shown), 0);
|
||||
}
|
||||
const decimals =
|
||||
canonical && units.isTransformed(row.type)
|
||||
? units.decimalsFor(row.type)
|
||||
: 1;
|
||||
const scale = 10 ** decimals;
|
||||
return fmt.number(Math.round(shown * scale) / scale);
|
||||
},
|
||||
[fmt, t, units],
|
||||
);
|
||||
|
||||
const unitFor = useCallback(
|
||||
(row: Pick<DayValue, "type" | "unit">): string => {
|
||||
if (row.type === "SLEEP_DURATION") return "";
|
||||
if (row.unit === getUnitForType(row.type)) return units.unitFor(row.type);
|
||||
return row.unit;
|
||||
},
|
||||
[units],
|
||||
);
|
||||
|
||||
const formatTile = useCallback(
|
||||
(tile: DayValueTile): FormattedDayValue => {
|
||||
const label = labelFor(tile.key);
|
||||
const first = tile.values[0];
|
||||
if (!first)
|
||||
return { label, value: "", unit: "", usual: null, usualParts: null };
|
||||
const join = (part: (row: DayValue) => string | null) => {
|
||||
const parts = tile.values.map(part);
|
||||
return parts.every((p) => p !== null) ? parts.join("/") : null;
|
||||
};
|
||||
const value = join((row) => number(row, row.value)) ?? "";
|
||||
const usualParts = tile.values.every((row) => row.band !== null)
|
||||
? tile.values.map((row) => ({
|
||||
lo: number(row, row.band!.lo),
|
||||
hi: number(row, row.band!.hi),
|
||||
}))
|
||||
: null;
|
||||
const usual = usualParts
|
||||
? usualParts.map((part) => `${part.lo}–${part.hi}`).join("/")
|
||||
: null;
|
||||
return { label, value, unit: unitFor(first), usual, usualParts };
|
||||
},
|
||||
[labelFor, number, unitFor],
|
||||
);
|
||||
|
||||
return { labelFor, formatTile };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
DateKey,
|
||||
DayIndexResponse,
|
||||
DayResponse,
|
||||
} from "@/lib/day/contract";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* One local day, from `GET /api/day/{date}`.
|
||||
*
|
||||
* The server cuts the day in the record's zone and decides everything the
|
||||
* view shows: which sections there are, the usual range of a value, the
|
||||
* running-day counts and the notable observations. Nothing here recomputes
|
||||
* any of it.
|
||||
*/
|
||||
export function useDay(date: DateKey | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.day(date ?? ""),
|
||||
queryFn: () => apiGet<DayResponse>(`/api/day/${date}`),
|
||||
enabled: date !== null,
|
||||
// A past day changes only when something is written into it, and every
|
||||
// record write evicts the `["day"]` root.
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Which days of a window hold anything, from `GET /api/day/index`. Feeds the
|
||||
* row of dots under a chart and the visit preparation. `null` bounds keep the
|
||||
* query idle.
|
||||
*/
|
||||
export function useDayIndex(
|
||||
from: DateKey | null,
|
||||
to: DateKey | null,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dayIndex(from ?? "", to ?? ""),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
from: from as string,
|
||||
to: to as string,
|
||||
});
|
||||
return apiGet<DayIndexResponse>(`/api/day/index?${params}`);
|
||||
},
|
||||
enabled: enabled && from !== null && to !== null && from <= to,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
|
||||
import { todayKeyInZone } from "./day-url";
|
||||
|
||||
/**
|
||||
* Today in the record's display zone. A day after this one has nothing to
|
||||
* show, so no surface links to it and the layer refuses it.
|
||||
*/
|
||||
export function useTodayKey(): DateKey {
|
||||
const timeZone = useDisplayTimezone();
|
||||
return todayKeyInZone(timeZone);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* whole card is clickable through an invisible overlay button; the checkbox
|
||||
* floats above it.
|
||||
*/
|
||||
import { DayLinkStated } from "@/components/day/day-link";
|
||||
import { useDocumentDate } from "@/components/documents/use-document-date";
|
||||
import { Check, Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -184,7 +185,21 @@ export function DocumentCard({
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{date} · {size}
|
||||
{/* v1.42 — a dated document's date opens its day, above the
|
||||
whole-card button. It follows the grid's roving tab stop, so the
|
||||
card that holds the keyboard offers its day next. */}
|
||||
{document.documentDate ? (
|
||||
<DayLinkStated
|
||||
at={document.documentDate}
|
||||
tabIndex={tabIndex}
|
||||
className="z-10"
|
||||
>
|
||||
{date}
|
||||
</DayLinkStated>
|
||||
) : (
|
||||
date
|
||||
)}{" "}
|
||||
· {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
*/
|
||||
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import { SinceLastVisit } from "@/components/day/since-last-visit";
|
||||
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";
|
||||
@@ -60,6 +62,8 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
open: canManageVisits ? openSheet : undefined,
|
||||
});
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
// The last visit that took place: the start of the visit preparation.
|
||||
const lastDone = past.find((encounter) => encounter.status === "DONE");
|
||||
|
||||
const addButton = canAddVisit ? (
|
||||
<Button
|
||||
@@ -119,12 +123,22 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
{upcoming.map((encounter, i) => (
|
||||
<Fragment key={encounter.id}>
|
||||
<VisitCard
|
||||
encounter={encounter}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
{/* v1.42 — the next appointment carries what happened
|
||||
since the last one, each date opening its day. */}
|
||||
{i === 0 && lastDone ? (
|
||||
<Card className="gap-3 py-3 md:gap-3 md:py-4">
|
||||
<CardContent>
|
||||
<SinceLastVisit next={encounter} last={lastDone} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* day-log history, paginated server-side); when nothing has been logged it
|
||||
* renders a calm prompt to log a day.
|
||||
*/
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -38,7 +39,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
return (
|
||||
<div className="border-border/60 space-y-2 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{fmt.dateShortSmartCalendar(log.date)}
|
||||
{/* v1.42 — the logged day opens the whole day. */}
|
||||
<DayLink date={log.date.slice(0, 10)}>
|
||||
{fmt.dateShortSmartCalendar(log.date)}
|
||||
</DayLink>
|
||||
</p>
|
||||
|
||||
{log.symptoms.length > 0 ? (
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
* per-day timeline and correlation card live. Retrospective-only copy — a
|
||||
* journal, not a medical device, and it does not diagnose.
|
||||
*/
|
||||
import {
|
||||
DAY_LINK_SLOT,
|
||||
DayLinkStated,
|
||||
withDayLinkSlot,
|
||||
} from "@/components/day/day-link";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -107,7 +112,9 @@ function EpisodeCard({
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{fmt.dateShortSmart(new Date(episode.onsetAt))}
|
||||
<DayLinkStated at={episode.onsetAt}>
|
||||
{fmt.dateShortSmart(new Date(episode.onsetAt))}
|
||||
</DayLinkStated>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,15 +180,26 @@ function EpisodeCard({
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* v1.42 — onset and recovery are the interesting days of a
|
||||
period: each opens its day. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("illness.onsetOn", {
|
||||
date: fmt.dateShortSmart(new Date(episode.onsetAt)),
|
||||
})}
|
||||
{episode.resolvedAt
|
||||
? ` · ${t("illness.recoveredOn", {
|
||||
date: fmt.dateShortSmart(new Date(episode.resolvedAt)),
|
||||
})}`
|
||||
: ""}
|
||||
{withDayLinkSlot(
|
||||
t("illness.onsetOn", { date: DAY_LINK_SLOT }),
|
||||
<DayLinkStated at={episode.onsetAt}>
|
||||
{fmt.dateShortSmart(new Date(episode.onsetAt))}
|
||||
</DayLinkStated>,
|
||||
)}
|
||||
{episode.resolvedAt ? (
|
||||
<>
|
||||
{" · "}
|
||||
{withDayLinkSlot(
|
||||
t("illness.recoveredOn", { date: DAY_LINK_SLOT }),
|
||||
<DayLinkStated at={episode.resolvedAt}>
|
||||
{fmt.dateShortSmart(new Date(episode.resolvedAt))}
|
||||
</DayLinkStated>,
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{flareCount > 0 ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CoachDayChips } from "@/components/day/coach-day-chips";
|
||||
import { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bot, Info, RotateCcw, Sparkles, User } from "lucide-react";
|
||||
@@ -751,6 +752,11 @@ function ChatBubbleImpl({
|
||||
section="displayed"
|
||||
/>
|
||||
)}
|
||||
{/* v1.42 — the days the turn's tools read, each opening its day.
|
||||
From the tool calls, never from the prose. */}
|
||||
{!inProgress && !errorCode ? (
|
||||
<CoachDayChips steps={steps ?? metricSource?.steps ?? []} />
|
||||
) : null}
|
||||
{/* v1.41 — what the answer assumed instead of asking, and what the
|
||||
Coach kept from this turn: one quiet line each. */}
|
||||
{settled && (metricSource?.assumptions?.length ?? 0) > 0 ? (
|
||||
|
||||
@@ -491,6 +491,7 @@ export function HealthKitMetricPage({
|
||||
valueOffset={resolvedOffset}
|
||||
onVisibleStats={onVisibleStats}
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
{bothMeasures ? (
|
||||
<HealthChartDynamic
|
||||
@@ -513,6 +514,7 @@ export function HealthKitMetricPage({
|
||||
// two charts writing the one stats slot would replace each other's
|
||||
// window on every render without end.
|
||||
showDataTable
|
||||
dayLinks
|
||||
/>
|
||||
) : null}
|
||||
{targetSummarySlug ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
@@ -90,8 +91,13 @@ export function WorkoutDetailDayContext({
|
||||
className="px-4 md:px-6"
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
action={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateWithWeekdaySmart(new Date(workout.startedAt))}
|
||||
// v1.42 — the date opens the whole day around the session: what
|
||||
// ran through it and everything else that happened.
|
||||
<span className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-xs">
|
||||
<span>{fmt.dateWithWeekdaySmart(new Date(workout.startedAt))}</span>
|
||||
<DayLink date={workout.dayKey} size="xs">
|
||||
{t("day.viewWholeDay")}
|
||||
</DayLink>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
import Link from "next/link";
|
||||
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
|
||||
|
||||
@@ -98,13 +99,15 @@ export function WorkoutList({
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<li key={workout.id}>
|
||||
<Link
|
||||
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
|
||||
// v1.42 — the row opens the workout and its date opens the day:
|
||||
// two targets, so the row is a stretched link (the sport name's
|
||||
// link covers the row) and the date sits above it.
|
||||
<li key={workout.id} className="relative">
|
||||
<div
|
||||
data-slot="workout-list-row"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 text-sm transition-colors",
|
||||
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"hover:bg-accent has-[a[data-slot=workout-list-link]:focus-visible]:ring-ring/50 has-[a[data-slot=workout-list-link]:focus-visible]:ring-2",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -115,7 +118,13 @@ export function WorkoutList({
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 truncate font-medium">
|
||||
{sportName}
|
||||
<Link
|
||||
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
|
||||
data-slot="workout-list-link"
|
||||
className="truncate after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
|
||||
>
|
||||
{sportName}
|
||||
</Link>
|
||||
{/* #67 — discreet glyphs flag which sessions open rich
|
||||
(map / HR curve). Muted so they never compete with
|
||||
the sport name. */}
|
||||
@@ -135,7 +144,9 @@ export function WorkoutList({
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
<DayLinkAt at={workout.startedAt} className="z-10">
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
</DayLinkAt>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs">
|
||||
@@ -160,7 +171,7 @@ export function WorkoutList({
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
@@ -17,10 +18,17 @@ import { Button } from "@/components/ui/button";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { isNoonUtcAnchor } from "@/lib/tz/date-only";
|
||||
import {
|
||||
useDisplayTimezone,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { dateOnlyKey, isNoonUtcAnchor } from "@/lib/tz/date-only";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import { TooltipDayAction, useCoarsePointer } from "../day/chart-day";
|
||||
import { openDay, useOpenDay } from "../day/day-layer-controller";
|
||||
import { dateKeyOfInstant } from "../day/day-url";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
|
||||
@@ -55,6 +63,8 @@ const RANGE_DAYS = [
|
||||
type RangeKey = (typeof RANGE_DAYS)[number]["key"];
|
||||
|
||||
interface ChartPoint {
|
||||
/** The reading's id, to find the one before it for the day's focus. */
|
||||
id: string;
|
||||
timestamp: number;
|
||||
value: number;
|
||||
/** The window this reading was judged against, for the tooltip. */
|
||||
@@ -104,6 +114,45 @@ export function LabBiomarkerChart({
|
||||
: fmt.dateShortSmart(instant);
|
||||
};
|
||||
const [range, setRange] = useState<RangeKey>("365");
|
||||
// v1.42 — a reading opens its day: a click on a fine pointer, the
|
||||
// tooltip's button on a touch screen. The open day's reading is ringed.
|
||||
const timeZone = useDisplayTimezone();
|
||||
const coarse = useCoarsePointer();
|
||||
const openDayKey = useOpenDay();
|
||||
const dayOf = (timestamp: number) => {
|
||||
const instant = new Date(timestamp);
|
||||
return isNoonUtcAnchor(instant)
|
||||
? dateOnlyKey(instant)
|
||||
: dateKeyOfInstant(instant, timeZone);
|
||||
};
|
||||
const openReadingDay = (point: ChartPoint | undefined) => {
|
||||
if (!point) return;
|
||||
const ordered = [...readings]
|
||||
.filter((r) => r.value !== null)
|
||||
.sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const at = ordered.findIndex((r) => r.id === point.id);
|
||||
const before = at > 0 ? ordered[at - 1] : undefined;
|
||||
const date = dayOf(point.timestamp);
|
||||
openDay(date, {
|
||||
focus: {
|
||||
date,
|
||||
label: readings[0]?.analyte ?? "",
|
||||
value: labNumber(point.value),
|
||||
unit,
|
||||
compare:
|
||||
before && before.value !== null
|
||||
? {
|
||||
label: t("day.previousReading", {
|
||||
date: labShortDate(before.takenAt),
|
||||
}),
|
||||
value: `${labNumber(before.value)} ${unit}`.trim(),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
// Capture "now" once at mount so the recency filter stays pure across
|
||||
// re-renders (an inline `Date.now()` in render is impure).
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
@@ -126,6 +175,7 @@ export function LabBiomarkerChart({
|
||||
})
|
||||
: sorted;
|
||||
return filtered.map((r) => ({
|
||||
id: r.id,
|
||||
timestamp: new Date(r.takenAt).getTime(),
|
||||
value: r.value,
|
||||
referenceLow: r.referenceLow,
|
||||
@@ -214,6 +264,16 @@ export function LabBiomarkerChart({
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
className={coarse ? undefined : "cursor-pointer"}
|
||||
onClick={
|
||||
coarse
|
||||
? undefined
|
||||
: (state) => {
|
||||
const index = Number(state?.activeTooltipIndex);
|
||||
if (Number.isInteger(index))
|
||||
openReadingDay(points[index]);
|
||||
}
|
||||
}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -302,6 +362,10 @@ export function LabBiomarkerChart({
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
trigger={coarse ? "click" : "hover"}
|
||||
wrapperStyle={
|
||||
coarse ? { pointerEvents: "auto", zIndex: 20 } : undefined
|
||||
}
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
@@ -338,10 +402,35 @@ export function LabBiomarkerChart({
|
||||
active
|
||||
label={labShortDate(point.timestamp)}
|
||||
rows={rows}
|
||||
action={
|
||||
coarse ? (
|
||||
<TooltipDayAction
|
||||
onOpen={() => openReadingDay(point)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{points
|
||||
.filter(
|
||||
(point) =>
|
||||
openDayKey !== null &&
|
||||
dayOf(point.timestamp) === openDayKey,
|
||||
)
|
||||
.map((point) => (
|
||||
<ReferenceDot
|
||||
key={`open-day-${point.id}`}
|
||||
x={point.timestamp}
|
||||
y={point.value}
|
||||
r={6.5}
|
||||
fill="var(--card)"
|
||||
stroke={primary}
|
||||
strokeWidth={2.5}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DayLinkStated } from "@/components/day/day-link";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -80,6 +81,30 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const labNumber = useLabNumber();
|
||||
const labDate = useLabDate();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
// What the day shows at its top when opened from a reading: the value and
|
||||
// the reading before it, the comparison a draw is read against.
|
||||
const chronological = [...readings].sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const labDayFocus = (reading: LabResultDto) => {
|
||||
const at = chronological.findIndex((r) => r.id === reading.id);
|
||||
const before = at > 0 ? chronological[at - 1] : undefined;
|
||||
return {
|
||||
label: reading.analyte,
|
||||
// The number and its unit apart, as the day sets every value.
|
||||
value:
|
||||
reading.value === null
|
||||
? (reading.valueText ?? "")
|
||||
: labNumber(reading.value),
|
||||
unit: reading.value === null ? undefined : reading.unit,
|
||||
compare: before
|
||||
? {
|
||||
label: t("day.previousReading", { date: labDate(before.takenAt) }),
|
||||
value: formatLabReading(before, labNumber),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -364,7 +389,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
unit={r.unit}
|
||||
/>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="whitespace-nowrap">{labDate(r.takenAt)}</span>
|
||||
<DayLinkStated
|
||||
at={r.takenAt}
|
||||
focus={labDayFocus(r)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{labDate(r.takenAt)}
|
||||
</DayLinkStated>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
{r.hasNote ? (
|
||||
<span className="text-xs">{t("labs.hasNote")}</span>
|
||||
@@ -431,7 +462,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{labDate(r.takenAt)}
|
||||
{/* v1.42 — the draw date opens the day; the row keeps its
|
||||
edit and delete. A draw day is often a visit day. */}
|
||||
<DayLinkStated at={r.takenAt} focus={labDayFocus(r)}>
|
||||
{labDate(r.takenAt)}
|
||||
</DayLinkStated>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DayLayerMount } from "@/components/day/day-layer";
|
||||
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
@@ -541,6 +542,11 @@ export function AuthShell({
|
||||
part of what this person was given.
|
||||
*/}
|
||||
{!inSharedRecord && <LayoutCoachMount />}
|
||||
{/* v1.42 — the day layer: `?day=` opens one day over whichever page
|
||||
is open (docked beside it from 1280 px, a sheet below that). It
|
||||
reads the record the shell resolved, shared or one's own; the day
|
||||
route decides per section what that grant may see. */}
|
||||
<DayLayerMount />
|
||||
{/* v1.18.6 — the module-tour launcher lives at the shell level so its
|
||||
overlay survives the cross-page `router.push`es the tour makes. It
|
||||
self-gates: it only auto-opens on the dashboard for a user who has
|
||||
|
||||
@@ -164,9 +164,18 @@ interface CapturePickerProps {
|
||||
open: boolean;
|
||||
/** Open-state setter for the chooser sheet. */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/**
|
||||
* v1.42 — the day the picker was opened from ("Capture for this day" in the
|
||||
* day view), `YYYY-MM-DD`. Every form starts on it. Omitted, they start now.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
export function CapturePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultDate,
|
||||
}: CapturePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { user } = useAuth();
|
||||
@@ -314,6 +323,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "medication" && (
|
||||
@@ -321,6 +331,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "workout" && (
|
||||
@@ -328,6 +339,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "symptom" && (
|
||||
@@ -335,6 +347,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "lifeEvent" && (
|
||||
@@ -342,6 +355,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
{openKind === "mood" && (
|
||||
@@ -349,6 +363,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
defaultDate={defaultDate}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { localDateTimeValue } from "@/components/day/prefill";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -263,6 +264,11 @@ interface MeasurementFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
defaultType?: string;
|
||||
/**
|
||||
* v1.42 — a calendar day (`YYYY-MM-DD`) to start the date on, when the
|
||||
* form is opened from that day. Defaults to now.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
/**
|
||||
* v1.4.27 R4 RC2 — when the form is mounted inside a
|
||||
* `<ResponsiveSheet>` the caller passes the sheet's footer slot
|
||||
@@ -310,6 +316,7 @@ export function MeasurementForm({
|
||||
onCancel,
|
||||
defaultType,
|
||||
footerSlot,
|
||||
defaultDate,
|
||||
}: MeasurementFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
@@ -352,7 +359,9 @@ export function MeasurementForm({
|
||||
const [diaBp, setDiaBp] = useState("");
|
||||
const [pulse, setPulse] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(getDefaultMeasuredAtValue);
|
||||
const [measuredAt, setMeasuredAt] = useState(() =>
|
||||
localDateTimeValue(defaultDate),
|
||||
);
|
||||
const [glucoseContext, setGlucoseContext] =
|
||||
useState<GlucoseContextValue>("FASTING");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -385,7 +394,7 @@ export function MeasurementForm({
|
||||
setDiaBp("");
|
||||
setPulse("");
|
||||
setNotes("");
|
||||
setMeasuredAt(getDefaultMeasuredAtValue());
|
||||
setMeasuredAt(localDateTimeValue(defaultDate));
|
||||
setError(null);
|
||||
setValueError(null);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { DayLink, DayLinkAt } from "@/components/day/day-link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
@@ -1283,7 +1284,17 @@ export function MeasurementList({
|
||||
phrase "when".
|
||||
*/}
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
{/* v1.42 — the date opens its day; the row
|
||||
keeps opening the reading. */}
|
||||
{m.dayKey !== undefined ? (
|
||||
<DayLink date={m.dayKey}>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</DayLink>
|
||||
) : (
|
||||
<DayLinkAt at={m.measuredAt}>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</DayLinkAt>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.notes ? (
|
||||
@@ -1497,9 +1508,15 @@ export function MeasurementList({
|
||||
site. Relative under 24 h, absolute
|
||||
older.
|
||||
*/}
|
||||
<span>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</span>
|
||||
{m.dayKey !== undefined ? (
|
||||
<DayLink date={m.dayKey}>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</DayLink>
|
||||
) : (
|
||||
<DayLinkAt at={m.measuredAt}>
|
||||
{formatDateOrRelative(m.measuredAt, t)}
|
||||
</DayLinkAt>
|
||||
)}
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
* the ledger uses the semantic feedback vocabulary on the row, not the card.
|
||||
*/
|
||||
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import {
|
||||
useActiveRecordName,
|
||||
useRecordCapabilities,
|
||||
@@ -481,7 +482,16 @@ export function DoseHistoryLedger({
|
||||
the DEVICE zone and could shift the heading a day when
|
||||
browser ≠ profile zone; the day-key label renders
|
||||
UTC-pinned instead (zone-independent, weekday included). */}
|
||||
{formatDateWithWeekdaySmart(group.dayKey, dateFormatPref, locale)}
|
||||
{/* v1.42 — the day heading opens the whole day: did I take
|
||||
it, and how was that day otherwise. Rows keep their own
|
||||
actions. A planned future slot gets no link. */}
|
||||
<DayLink date={group.dayKey}>
|
||||
{formatDateWithWeekdaySmart(
|
||||
group.dayKey,
|
||||
dateFormatPref,
|
||||
locale,
|
||||
)}
|
||||
</DayLink>
|
||||
</h3>
|
||||
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
|
||||
{group.rows.map((row, i) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DayLinkAt } from "@/components/day/day-link";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
@@ -385,7 +386,10 @@ export function IntakeHistoryListV2({
|
||||
"(planned)" suffix so the chronological
|
||||
order stays clean and no `—` row floats. */}
|
||||
{event.takenAt ? (
|
||||
formatters.dateTime(event.takenAt)
|
||||
// v1.42 — a taken dose's date opens its day.
|
||||
<DayLinkAt at={event.takenAt}>
|
||||
{formatters.dateTime(event.takenAt)}
|
||||
</DayLinkAt>
|
||||
) : (
|
||||
<>
|
||||
{formatters.dateTime(event.scheduledFor)}{" "}
|
||||
@@ -483,7 +487,9 @@ export function IntakeHistoryListV2({
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{event.takenAt ? (
|
||||
formatters.dateTime(event.takenAt)
|
||||
<DayLinkAt at={event.takenAt}>
|
||||
{formatters.dateTime(event.takenAt)}
|
||||
</DayLinkAt>
|
||||
) : (
|
||||
<>
|
||||
{formatters.dateTime(event.scheduledFor)}{" "}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { localDateTimeValue } from "@/components/day/prefill";
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -102,6 +103,11 @@ interface MoodFormProps {
|
||||
* attribute so submit-on-Enter still works.
|
||||
*/
|
||||
footerSlot?: HTMLElement | null;
|
||||
/**
|
||||
* v1.42 — a calendar day (`YYYY-MM-DD`) to start the date on, when the
|
||||
* form is opened from that day. Defaults to now.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
function getDefaultMoodLoggedAtValue() {
|
||||
@@ -111,7 +117,12 @@ function getDefaultMoodLoggedAtValue() {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
export function MoodForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
defaultDate,
|
||||
}: MoodFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -137,7 +148,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
const [note, setNote] = useState("");
|
||||
const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue);
|
||||
const [moodLoggedAt, setMoodLoggedAt] = useState(() =>
|
||||
localDateTimeValue(defaultDate),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// v1.11.5 — confirm before Reset wipes typed input. Only the content
|
||||
@@ -193,7 +206,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setMoodLoggedAt(localDateTimeValue(defaultDate));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
const MOOD_DESC_COLUMNS: ReadonlySet<string> = new Set(["moodLoggedAt"]);
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { MOOD_LABEL_KEYS, MOOD_SCORE_BY_ENUM } from "@/lib/mood/labels";
|
||||
import {
|
||||
@@ -841,7 +842,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
{/* v1.42 — the entry's own day (its `date`, cut
|
||||
in the zone it was logged in) opens the day. */}
|
||||
<DayLink date={entry.date.slice(0, 10)}>
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</DayLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.source !== "MANUAL" && (
|
||||
@@ -932,7 +937,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
: entry.mood}
|
||||
</span>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
<DayLink date={entry.date.slice(0, 10)}>
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
</DayLink>
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* `undefined` and stick — and the per-user gate means a late re-resolve never
|
||||
* clobbers edits the user is in the middle of.
|
||||
*/
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -79,6 +80,21 @@ type ExportFormat = "pdf" | "fhir" | "package";
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
const PRESET_RANGES = [30, 90, 180, 365] as const;
|
||||
|
||||
/**
|
||||
* The custom window a link into the panel asks for, when both ends are
|
||||
* calendar dates in order. Anything else opens the panel as usual.
|
||||
*/
|
||||
export function readLinkedReportRange(): { from: string; to: string } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const from = params.get("reportFrom");
|
||||
const to = params.get("reportTo");
|
||||
if (!from || !to || !isCalendarDateKey(from) || !isCalendarDateKey(to)) {
|
||||
return null;
|
||||
}
|
||||
return from <= to ? { from, to } : null;
|
||||
}
|
||||
|
||||
/** A refusal already worded for the panel; shown as it is. */
|
||||
class ReportRequestError extends Error {}
|
||||
|
||||
@@ -97,9 +113,13 @@ export function HealthRecordExportPanel() {
|
||||
SAVED_PROFILE_FALLBACK.format,
|
||||
);
|
||||
const [days, setDays] = useState<number>(SAVED_PROFILE_FALLBACK.rangeDays);
|
||||
const [customRange, setCustomRange] = useState(false);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
// v1.42 — `?reportFrom=&reportTo=` (the visit preparation's "Doctor
|
||||
// report for this period") opens the panel on that custom window. Read
|
||||
// once, on the first client render; the person can change it from there.
|
||||
const [linkedRange] = useState(readLinkedReportRange);
|
||||
const [customRange, setCustomRange] = useState(linkedRange !== null);
|
||||
const [startDate, setStartDate] = useState(linkedRange?.from ?? "");
|
||||
const [endDate, setEndDate] = useState(linkedRange?.to ?? "");
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
const [includeCharts, setIncludeCharts] = useState<boolean>(
|
||||
SAVED_PROFILE_FALLBACK.includeCharts,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* kebab + Cancel + Save action row portalled into the sheet's footer slot so
|
||||
* it stays above the phone keyboard.
|
||||
*/
|
||||
import { localDateTimeValue } from "@/components/day/prefill";
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
@@ -67,12 +68,18 @@ interface SymptomEntryFormProps {
|
||||
onCancel?: () => void;
|
||||
/** The sheet's footer slot; the action row portals into it. */
|
||||
footerSlot?: HTMLElement | null;
|
||||
/**
|
||||
* v1.42 — a calendar day (`YYYY-MM-DD`) to start the date on, when the
|
||||
* form is opened from that day. Defaults to now.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
export function SymptomEntryForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
defaultDate,
|
||||
}: SymptomEntryFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
@@ -84,7 +91,9 @@ export function SymptomEntryForm({
|
||||
|
||||
const [definitionId, setDefinitionId] = useState<string | null>(null);
|
||||
const [intensity, setIntensity] = useState<number | null>(null);
|
||||
const [occurredAt, setOccurredAt] = useState(nowLocalValue);
|
||||
const [occurredAt, setOccurredAt] = useState(() =>
|
||||
localDateTimeValue(defaultDate),
|
||||
);
|
||||
const [note, setNote] = useState("");
|
||||
const [episodeId, setEpisodeId] = useState<string>(NO_EPISODE);
|
||||
const [symptomError, setSymptomError] = useState<string | null>(null);
|
||||
@@ -106,7 +115,7 @@ export function SymptomEntryForm({
|
||||
function resetForm() {
|
||||
setDefinitionId(null);
|
||||
setIntensity(null);
|
||||
setOccurredAt(nowLocalValue());
|
||||
setOccurredAt(localDateTimeValue(defaultDate));
|
||||
setNote("");
|
||||
setEpisodeId(NO_EPISODE);
|
||||
setSymptomError(null);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* intensity as a neutral `TagChip` ("6/10"), and the same kebab + confirm
|
||||
* vocabulary as the episode menu.
|
||||
*/
|
||||
import { DayLink } from "@/components/day/day-link";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -478,7 +479,10 @@ export function SymptomsSection() {
|
||||
{days.map((group) => (
|
||||
<section key={group.day} className="space-y-0.5">
|
||||
<h3 className="text-sm font-medium">
|
||||
{fmt.dateWithWeekdaySmart(group.at)}
|
||||
{/* v1.42 — the day heading opens the whole day. */}
|
||||
<DayLink date={group.day}>
|
||||
{fmt.dateWithWeekdaySmart(group.at)}
|
||||
</DayLink>
|
||||
</h3>
|
||||
<ul className="divide-border divide-y">
|
||||
{group.events.map((event) => {
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
* 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. */
|
||||
@@ -87,15 +85,6 @@ export function todayKeyIn(timeZone: string | undefined, now = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useMemo, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { openDay as openDayLayer } from "@/components/day/day-layer-controller";
|
||||
import { parseDayParam } from "@/components/day/day-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -53,7 +55,7 @@ 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 { todayKeyIn } from "./timeline-dates";
|
||||
import { LANE_ORDER, windowFor } from "./timeline-geometry";
|
||||
import {
|
||||
LayersMenu,
|
||||
@@ -104,17 +106,6 @@ export const DEFAULT_VALUES = [
|
||||
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<{
|
||||
@@ -140,7 +131,7 @@ export function TimelineView() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const today = todayKeyIn(user?.timezone);
|
||||
const dayParam = acceptableDayParam(searchParams.get(DAY_QUERY_PARAM), today);
|
||||
const dayParam = parseDayParam(searchParams.get(DAY_QUERY_PARAM), today);
|
||||
// Life events are written in one's own record only (owner-only routes).
|
||||
const canAddLifeEvent = !caps.inSharedRecord;
|
||||
|
||||
@@ -235,14 +226,12 @@ export function TimelineView() {
|
||||
const effectiveSelected =
|
||||
selected ?? (data ? latestEntryDate(data.lanes, today) : null) ?? today;
|
||||
|
||||
// The shell's day layer owns `?day=` and its history entries; this only
|
||||
// asks it to open the day, and keeps the selection on it.
|
||||
function openDay(date: string) {
|
||||
if (date > today) return;
|
||||
setPicked({ date, param: date });
|
||||
window.history.pushState(
|
||||
{ __healthlogTimelineDay: date },
|
||||
"",
|
||||
dayHref(pathname, searchParams.toString(), date),
|
||||
);
|
||||
openDayLayer(date);
|
||||
}
|
||||
|
||||
function editLifeEvent(id: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { localDateTimeValue } from "@/components/day/prefill";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
@@ -67,6 +68,19 @@ interface ManualWorkoutFormProps {
|
||||
edit?: ManualWorkoutEdit;
|
||||
/** Whether the fields differ from what the form opened with. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/**
|
||||
* v1.42 — a past calendar day (`YYYY-MM-DD`) the form was opened from. The
|
||||
* start is set on that day, because a blank start means "it just ended".
|
||||
* Today leaves the start blank.
|
||||
*/
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
/** A past day's start for the draft: that day at the current clock time. */
|
||||
function pastDayStart(defaultDate: string | undefined): string {
|
||||
if (!defaultDate) return "";
|
||||
const value = localDateTimeValue(defaultDate);
|
||||
return value.slice(0, 10) === localDateTimeValue().slice(0, 10) ? "" : value;
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
@@ -120,6 +134,7 @@ export function ManualWorkoutForm({
|
||||
footerSlot,
|
||||
edit,
|
||||
onDirtyChange,
|
||||
defaultDate,
|
||||
}: ManualWorkoutFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -135,7 +150,11 @@ export function ManualWorkoutForm({
|
||||
() => edit?.externalId ?? newManualWorkoutExternalId(),
|
||||
);
|
||||
const [initialDraft] = useState<ManualWorkoutDraft>(
|
||||
() => edit?.original.draft ?? emptyManualWorkoutDraft(),
|
||||
() =>
|
||||
edit?.original.draft ?? {
|
||||
...emptyManualWorkoutDraft(),
|
||||
start: pastDayStart(defaultDate),
|
||||
},
|
||||
);
|
||||
const [draft, setDraft] = useState<ManualWorkoutDraft>(initialDraft);
|
||||
const dirty = (
|
||||
|
||||
@@ -109,6 +109,13 @@ export const coachStepSchema = z
|
||||
resultRef: resultRefSchema
|
||||
.optional()
|
||||
.describe("The table this step produced (`r1`..), when it produced one."),
|
||||
day: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.describe(
|
||||
"The calendar day a `get_day` step read, `YYYY-MM-DD`. A settled answer lists these days as links to each day; set only on `get_day` steps.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachStep",
|
||||
|
||||
@@ -521,6 +521,12 @@ export interface CoachStep {
|
||||
reason?: CoachStepReason;
|
||||
/** `r1` when the step produced a table. */
|
||||
resultRef?: string;
|
||||
/**
|
||||
* v1.42 — the calendar day (`YYYY-MM-DD`) a `get_day` step read. A settled
|
||||
* answer lists the days its tools read as chips that open each day; the
|
||||
* prose is never parsed for dates. Set only on `get_day` steps.
|
||||
*/
|
||||
day?: string;
|
||||
}
|
||||
|
||||
// ── Results (v1.39.4) ───────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user