Merge the day view into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-08 14:21:00 +02:00
commit 4cfad0e116
66 files changed
+4725 -243

No files matched your search

+5
View File
@@ -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
+266
View File
@@ -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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Tag",
"previousDay": "Vortag",
"nextDay": "Folgetag",
"close": "Tag schließen",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Day",
"previousDay": "Previous day",
"nextDay": "Next day",
"close": "Close day",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Día",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"close": "Cerrar día",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Jour",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"close": "Fermer le jour",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Giorno",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"close": "Chiudi giorno",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "하루",
"previousDay": "전날",
"nextDay": "다음 날",
"close": "하루 닫기",
@@ -12174,42 +12173,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
View File
@@ -12162,7 +12162,6 @@
}
},
"day": {
"title": "Dzień",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"close": "Zamknij dzień",
@@ -12174,42 +12173,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)",
timeline: "v1.42 timeline and its readiness inventory (#613)",
lifeEvents: "v1.42 life-event form and list (#613)",
};
+1
View File
@@ -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
+1
View File
@@ -124,6 +124,7 @@ export default function InsightsBmiPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
showDataTable
dayLinks
/>
<MetricTargetSummary slug="bmi" />
+1
View File
@@ -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
+1
View File
@@ -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"');
});
});
+34 -1
View File
@@ -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];
+9 -1
View File
@@ -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>
);
}
+41 -1
View File
@@ -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>
)}
+147 -4
View File
@@ -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}
</>
+40 -1
View File
@@ -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.");
});
});
+198
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+168
View File
@@ -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));
}
+384
View File
@@ -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>
);
}
+188
View File
@@ -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)}
</>
);
}
+485
View File
@@ -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>
);
}
+110
View File
@@ -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"
);
}
+116
View File
@@ -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) };
}
+386
View File
@@ -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>
);
}
+20
View File
@@ -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)}`;
}
+308
View File
@@ -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>
);
}
+124
View File
@@ -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 };
}
+54
View File
@@ -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,
});
}
+15
View File
@@ -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 -1
View File
@@ -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
+21 -7
View File
@@ -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 ? (
+27 -9
View File
@@ -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>
}
/>
+18 -7
View File
@@ -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>
);
})}
+91 -2
View File
@@ -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"
+37 -2
View File
@@ -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">
+6
View File
@@ -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
+15 -1
View File
@@ -150,9 +150,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();
@@ -293,6 +302,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "medication" && (
@@ -300,6 +310,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "workout" && (
@@ -307,6 +318,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "symptom" && (
@@ -314,6 +326,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
defaultDate={defaultDate}
/>
)}
{openKind === "mood" && (
@@ -321,6 +334,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)}{" "}
+16 -3
View File
@@ -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);
}
+9 -2
View File
@@ -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 -2
View File
@@ -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);
+5 -1
View File
@@ -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) => {
@@ -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 = (
+7
View File
@@ -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",
+6
View File
@@ -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) ───────────────────────────────────────────────────