From bf83d414e266eca257ed9b17a91bd44ac06d9c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 14:20:01 +0200 Subject: [PATCH] feat: open any past day as a layer over the page A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day over whichever page is open: docked beside it from 1280 px, the way the Coach's conversations panel sits, a sheet from the right below that, and a bottom sheet on a phone that opens at a little over half the height and grows to full height from its handle. On /coach it is always a sheet, so there is never more than one docked panel. Back and Escape close it, the previous and next day replace the history entry instead of adding one, Alt with an arrow steps a day, focus moves to the day's heading on open and returns to whatever opened it on close, and a polite region names the day. A malformed or future date is dropped from the URL. The day shows the value it was opened from with its comparison, what ran through the day, the readings against the person's own usual range on a short number line (eight curated, the rest behind "All values"), and what happened in order of the clock, each row linking to its record. Loading, failure, an empty day and areas a grant does not cover each read as such. The footer offers the timeline when that module is on and capture for the day, with the capture forms starting on that date. Doors into it: a click on a daily point of a metric chart (a tap shows the value and the tooltip offers the day on touch screens), the dates in the chart's data table, a quiet row of dots under the axis for days with entries and a ring for notable ones, the lab chart and lab readings, the measurement, mood and intake lists, symptom and illness day headings, episode onset and recovery, document dates, workout dates, the workout detail, the mood and adherence heatmaps, and the days a Coach answer's get_day calls read, shown as chips. The next planned visit carries what happened since the last one, each date opening its day, with a doctor report for that window. Not here, and frozen by a guard: the dashboard's today area, mini charts in tiles, the cycle calendar, model-written text, future dates, the clinician share link and the doctor-report PDF. --- docs/api/openapi.yaml | 5 + e2e/day-view.spec.ts | 266 ++++++++++ messages/de.json | 65 ++- messages/en.json | 65 ++- messages/es.json | 65 ++- messages/fr.json | 65 ++- messages/it.json | 65 ++- messages/ko.json | 61 ++- messages/pl.json | 65 ++- src/__tests__/i18n-reverse-coverage.test.ts | 1 - src/app/insights/blood-pressure/page.tsx | 1 + src/app/insights/bmi/page.tsx | 1 + src/app/insights/pulse/page.tsx | 1 + src/app/insights/weight/page.tsx | 1 + .../__tests__/health-chart-day-links.test.tsx | 78 +++ src/components/charts/chart-data-table.tsx | 35 +- src/components/charts/chart-tooltip.tsx | 10 +- src/components/charts/compliance-heatmap.tsx | 42 +- src/components/charts/health-chart.tsx | 151 +++++- src/components/charts/mood-heatmap.tsx | 41 +- .../dashboard/medication-intake-quick-add.tsx | 18 +- .../day/__tests__/chart-day.test.ts | 93 ++++ .../__tests__/day-layer-controller.test.ts | 116 +++++ .../day/__tests__/day-link.test.tsx | 67 +++ .../day/__tests__/day-not-here-guard.test.ts | 74 +++ src/components/day/__tests__/day-url.test.ts | 63 +++ .../day/__tests__/day-values-model.test.ts | 103 ++++ .../day/__tests__/day-view.test.tsx | 285 ++++++++++ src/components/day/chart-day.tsx | 198 +++++++ src/components/day/coach-day-chips.tsx | 56 ++ src/components/day/day-layer-controller.ts | 168 ++++++ src/components/day/day-layer.tsx | 384 ++++++++++++++ src/components/day/day-link.tsx | 188 +++++++ src/components/day/day-sections.tsx | 485 ++++++++++++++++++ src/components/day/day-url.ts | 110 ++++ src/components/day/day-values-model.ts | 116 +++++ src/components/day/day-view.tsx | 386 ++++++++++++++ src/components/day/prefill.ts | 20 + src/components/day/since-last-visit.tsx | 308 +++++++++++ src/components/day/use-day-value-format.ts | 124 +++++ src/components/day/use-day.ts | 54 ++ src/components/day/use-today-key.ts | 15 + src/components/documents/document-card.tsx | 17 +- src/components/encounters/visits-section.tsx | 28 +- .../illness/illness-day-timeline.tsx | 6 +- src/components/illness/illness-view.tsx | 36 +- .../insights/coach-panel/chat-bubble.tsx | 6 + .../insights/healthkit-metric-page.tsx | 2 + .../insights/workout-detail/day-context.tsx | 10 +- src/components/insights/workout-list.tsx | 25 +- src/components/labs/lab-biomarker-chart.tsx | 93 +++- src/components/labs/lab-history-list.tsx | 39 +- src/components/layout/auth-shell.tsx | 6 + src/components/layout/capture-picker.tsx | 16 +- .../measurements/measurement-form.tsx | 13 +- .../measurements/measurement-list.tsx | 25 +- .../medications/dose-history-ledger.tsx | 12 +- .../medications/intake-history-list-v2.tsx | 10 +- src/components/mood/mood-form.tsx | 19 +- src/components/mood/mood-list.tsx | 11 +- .../report-selection/selection-panel.tsx | 26 +- .../symptoms/symptom-entry-form.tsx | 13 +- src/components/symptoms/symptoms-section.tsx | 6 +- .../workouts/manual-workout-form.tsx | 21 +- src/lib/ai/coach/stream-events.ts | 7 + src/lib/ai/coach/types.ts | 6 + 66 files changed, 4725 insertions(+), 243 deletions(-) create mode 100644 e2e/day-view.spec.ts create mode 100644 src/components/charts/__tests__/health-chart-day-links.test.tsx create mode 100644 src/components/day/__tests__/chart-day.test.ts create mode 100644 src/components/day/__tests__/day-layer-controller.test.ts create mode 100644 src/components/day/__tests__/day-link.test.tsx create mode 100644 src/components/day/__tests__/day-not-here-guard.test.ts create mode 100644 src/components/day/__tests__/day-url.test.ts create mode 100644 src/components/day/__tests__/day-values-model.test.ts create mode 100644 src/components/day/__tests__/day-view.test.tsx create mode 100644 src/components/day/chart-day.tsx create mode 100644 src/components/day/coach-day-chips.tsx create mode 100644 src/components/day/day-layer-controller.ts create mode 100644 src/components/day/day-layer.tsx create mode 100644 src/components/day/day-link.tsx create mode 100644 src/components/day/day-sections.tsx create mode 100644 src/components/day/day-url.ts create mode 100644 src/components/day/day-values-model.ts create mode 100644 src/components/day/day-view.tsx create mode 100644 src/components/day/prefill.ts create mode 100644 src/components/day/since-last-visit.tsx create mode 100644 src/components/day/use-day-value-format.ts create mode 100644 src/components/day/use-day.ts create mode 100644 src/components/day/use-today-key.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a3e9d839e..044ad773a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts new file mode 100644 index 000000000..b0439aec8 --- /dev/null +++ b/e2e/day-view.spec.ts @@ -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); + }); +}); diff --git a/messages/de.json b/messages/de.json index 5e38ba61e..ad29c4ba5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index d5c9e4027..a563bdd62 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 95594a427..6233b0d7d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9a25437e0..127da7500 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index d8e68c0c9..e7af260d2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 9b35bfea8..0646a4249 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index fc3569b56..505f7bd64 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index e6e818fdf..ae0e99949 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -79,7 +79,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ */ const RELEASE_PENDING_PREFIXES: Record = { "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", - day: "v1.42 day view and its entry points (#613)", timeline: "v1.42 timeline and its readiness inventory (#613)", lifeEvents: "v1.42 life-event form and list (#613)", }; diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 98df95c45..754327c50 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -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 diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 8f0fbc906..c4e91cb5a 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -124,6 +124,7 @@ export default function InsightsBmiPage() { compareBaseline={compareBaseline} userTimezone={user?.timezone} showDataTable + dayLinks /> diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index de8f3fc0d..74101bde9 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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 diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 00ad2e0ca..b51ebebf1 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -203,6 +203,7 @@ export default function InsightsGewichtPage() { valueScale={weightScale} onVisibleStats={onVisibleStats} showDataTable + dayLinks /> diff --git a/src/components/charts/__tests__/health-chart-day-links.test.tsx b/src/components/charts/__tests__/health-chart-day-links.test.tsx new file mode 100644 index 000000000..0e944731d --- /dev/null +++ b/src/components/charts/__tests__/health-chart-day-links.test.tsx @@ -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) { + 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( + + + , + ); + vi.doUnmock("@tanstack/react-query"); + return html; +} + +describe(" 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"'); + }); +}); diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx index dbb29c192..185d00dae 100644 --- a/src/components/charts/chart-data-table.tsx +++ b/src/components/charts/chart-data-table.tsx @@ -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 = { @@ -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" ? ( + + {formatDate(new Date(point.timestamp))} + + ) : ( + formatDate(new Date(point.timestamp)) + )} {columns.map((column) => { const value = point[column.key]; diff --git a/src/components/charts/chart-tooltip.tsx b/src/components/charts/chart-tooltip.tsx index dd6388a1f..05526d668 100644 --- a/src/components/charts/chart-tooltip.tsx +++ b/src/components/charts/chart-tooltip.tsx @@ -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({ ))} + {action ?? null} ); } diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index b4fe29356..fa9ad89af 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -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("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 ( (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 && (
{tooltip.text} + {tooltip.day ? ( + + ) : null}
)} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 89d340ed8..06d7050fd 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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(); 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(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 + } > ))} + {/* 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 ? ( + + ) : null} + {openDayIndex !== undefined && openDayPoint + ? types.map((type, i) => { + const v = openDayPoint[type]; + if (typeof v !== "number") return null; + return ( + + ); + }) + : 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({ })} openPointDay(hoverPoint)} + /> + ) : undefined + } /> ); }} @@ -2444,6 +2574,18 @@ export function HealthChart({

) : null} + {dayLinksActive && chartData && chartData.length > 0 ? ( + <> + + + + ) : null} {/* The points the chart just drew, as a table. Reads the SAME expression `` 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} diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 6e0276ea8..39841e476 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -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("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 && (
{tooltip.text} + {tooltip.day ? ( + + ) : null}
)} diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 91676c106..ce59a7fc7 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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(null); - const [takenAt, setTakenAt] = useState(getDefaultIntakeAtValue); + const [takenAt, setTakenAt] = useState(() => + localDateTimeValue(defaultDate), + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectionNow] = useState(() => new Date()); diff --git a/src/components/day/__tests__/chart-day.test.ts b/src/components/day/__tests__/chart-day.test.ts new file mode 100644 index 000000000..b19380d7d --- /dev/null +++ b/src/components/day/__tests__/chart-day.test.ts @@ -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 => ({ + 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"]); + }); +}); diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts new file mode 100644 index 000000000..32eef666c --- /dev/null +++ b/src/components/day/__tests__/day-layer-controller.test.ts @@ -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; + g.window = { location, history }; + g.document = { activeElement: null, body: {} }; + return { + current: () => entries[index]!.url, + length: () => entries.length, + }; +} + +let browser: ReturnType; + +beforeEach(() => { + browser = installFakeBrowser("/insights/blood-pressure?range=90"); +}); + +afterEach(() => { + const g = globalThis as unknown as Record; + 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); + }); +}); diff --git a/src/components/day/__tests__/day-link.test.tsx b/src/components/day/__tests__/day-link.test.tsx new file mode 100644 index 000000000..1358db87f --- /dev/null +++ b/src/components/day/__tests__/day-link.test.tsx @@ -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( + + {node(mod)} + , + ); +} + +describe("", () => { + it("links a past day to ?day= on the current page", async () => { + const html = await render(({ DayLink }) => ( + 12 Dec 2025 + )); + 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 }) => ( + tomorrow + )); + 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 }) => ( + <> + a + b + + )); + 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`, + 31 Dec, + ), + ); + expect(html).toMatch(/^Started on ]*>31 Dec<\/a> at home$/); + }); +}); diff --git a/src/components/day/__tests__/day-not-here-guard.test.ts b/src/components/day/__tests__/day-not-here-guard.test.ts new file mode 100644 index 000000000..3b42f1a4c --- /dev/null +++ b/src/components/day/__tests__/day-not-here-guard.test.ts @@ -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/); + }); +}); diff --git a/src/components/day/__tests__/day-url.test.ts b/src/components/day/__tests__/day-url.test.ts new file mode 100644 index 000000000..59fa26e16 --- /dev/null +++ b/src/components/day/__tests__/day-url.test.ts @@ -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"); + }); +}); diff --git a/src/components/day/__tests__/day-values-model.test.ts b/src/components/day/__tests__/day-values-model.test.ts new file mode 100644 index 000000000..f90364949 --- /dev/null +++ b/src/components/day/__tests__/day-values-model.test.ts @@ -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); + }); +}); diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx new file mode 100644 index 000000000..1c74138e6 --- /dev/null +++ b/src/components/day/__tests__/day-view.test.tsx @@ -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( + + undefined} + onStep={() => undefined} + Title={(p) =>

} + titleId="t" + /> + , + ); +} + +beforeEach(() => { + vi.resetModules(); + dayQuery = { data: DAY }; + timelineOn = true; + pathname = "/insights/blood-pressure"; + canWrite = true; +}); + +describe("", () => { + 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("")[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."); + }); +}); diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx new file mode 100644 index 000000000..7983a6604 --- /dev/null +++ b/src/components/day/chart-day.tsx @@ -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 | 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 ( + + ); +} + +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 ( + + ); +} + +/** 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 ( +

+ {t("day.rugLegend")} + {coarse ? t("day.chartHintTouch") : t("day.chartHint")} +

+ ); +} diff --git a/src/components/day/coach-day-chips.tsx b/src/components/day/coach-day-chips.tsx new file mode 100644 index 000000000..4aa554f88 --- /dev/null +++ b/src/components/day/coach-day-chips.tsx @@ -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(); + 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 ( +
+ + {t("day.coachChips")} + +
    + {days.map((day) => ( +
  • + + {fmt.dateShortSmartCalendar(day)} + +
  • + ))} +
+
+ ); +} diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts new file mode 100644 index 000000000..d91ba729e --- /dev/null +++ b/src/components/day/day-layer-controller.ts @@ -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)); +} diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx new file mode 100644 index 000000000..bb19ae540 --- /dev/null +++ b/src/components/day/day-layer.tsx @@ -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 ( + + + + ); +} + +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(null); + const panelRef = useRef(null); + + // What the polite region says: the day that just opened or was stepped to. + const announcement = date !== null ? longLabel(date) : ""; + const previous = useRef(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 = ( +

+ {announcement} +

+ ); + + if (date === null) return live; + + const view = ( + Title: Parameters[0]["Title"], + extra?: Partial[0]>, + ) => ( + + ); + + if (shell === "docked") { + return ( + <> + {live} + + + + + ); + } + + return ( + <> + {live} + view(SheetTitle, extra)} + /> + + ); +} + +function DockedTitle({ + ref, + ...props +}: { + id?: string; + className?: string; + children: React.ReactNode; + ref?: React.Ref; + tabIndex?: number; +}) { + return

; +} + +/** 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; + titleRef: React.RefObject; + render: (extra: Partial[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 ? ( + + ) : null; + + return ( + { + if (!open) closeDay(); + }} + > + } + 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", + )} + > + + {t("day.description")} + + {render({ + above: grabber, + headerClassName: bottom ? undefined : "py-3", + })} + + + ); +} diff --git a/src/components/day/day-link.tsx b/src/components/day/day-link.tsx new file mode 100644 index 000000000..1807f6a3e --- /dev/null +++ b/src/components/day/day-link.tsx @@ -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; + 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 ( + + {children} + + ); + } + + const isOpen = open === date; + return ( + { + // 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 `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 & { at: Date | string | number }) { + const timeZone = useDisplayTimezone(); + return ; +} + +/** + * 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 & { at: string | Date }) { + const timeZone = useDisplayTimezone(); + if (typeof at === "string" && isDateOnlyKey(at)) { + return ; + } + const instant = new Date(at); + const date = isNoonUtcAnchor(instant) + ? dateOnlyKey(instant) + : dateKeyOfInstant(instant, timeZone); + return ; +} + +/** + * 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)} + + ); +} diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx new file mode 100644 index 000000000..767ab2414 --- /dev/null +++ b/src/components/day/day-sections.tsx @@ -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 ( +
+
+

{focus.label}

+

+ {focus.value} + {focus.unit ? ( + + {focus.unit} + + ) : null} +

+
+ {compare ? ( +
+

{compare.label}

+

{compare.value}

+
+ ) : null} +
+ ); +} + +/* ─── 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 = { ...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 ( +
    + {notables.map((notable, i) => ( +
  • +
  • + ))} +
+ ); +} + +/* ─── Running ─────────────────────────────────────────────────────────── */ + +const RUNNING_COLOR: Record = { + 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 ( +
+

{t("day.groups.running")}

+
    + {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 = ( + <> +
+
+ ); +} + +/* ─── Values ──────────────────────────────────────────────────────────── */ + +function NumberLine({ + value, + band, +}: { + value: number; + band: { lo: number; hi: number }; +}) { + const pos = numberLinePositions(value, band); + return ( + + ); +} + +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 ( +
  • + + {shown.label} + + + {shown.value} + {shown.unit ? ( + + {shown.unit} + + ) : null} + + {lead?.band ? ( + <> + + {shown.usualParts ? ( + + {shown.usualParts + .map((part) => t("day.usualRange", part)) + .join(", ")} + + ) : null} + + ) : null} +
  • + ); +} + +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 ( +
    +
    +

    {t("day.groups.values")}

    + + {t("day.valuesLegend")} + +
    +
      + {tiles.map((tile) => ( + + ))} +
    + {rest.length > 0 ? ( + + ) : null} +
    + ); +} + +/* ─── Events ──────────────────────────────────────────────────────────── */ + +const EVENT_ICON: Record = { + 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 ( +
    +

    {t("day.groups.happened")}

    +
      + {events.map((event) => { + const Icon = EVENT_ICON[event.kind]; + return ( +
    • + + {event.at ? fmt.time(event.at) : ""} + +
    • + ); + })} +
    +
    + ); +} diff --git a/src/components/day/day-url.ts b/src/components/day/day-url.ts new file mode 100644 index 000000000..a679a3bf6 --- /dev/null +++ b/src/components/day/day-url.ts @@ -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 { + 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)[DAY_HISTORY_KEY] === "string" + ); +} diff --git a/src/components/day/day-values-model.ts b/src/components/day/day-values-model.ts new file mode 100644 index 000000000..d8cd9f8fe --- /dev/null +++ b/src/components/day/day-values-model.ts @@ -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(); + 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) }; +} diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx new file mode 100644 index 000000000..b92fd2830 --- /dev/null +++ b/src/components/day/day-view.tsx @@ -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; + tabIndex?: number; + }>; + titleId: string; + titleRef?: Ref; + /** 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 ( +
    + {above} +
    +
    + + {longLabel(date)} + +

    + {meta ?? ·} +

    +
    + + + +
    + +
    + {focus ? ( +
    + + +
    + ) : null} + + {day.isError ? ( + void day.refetch()} + slot="day-error" + /> + ) : data === undefined ? ( + + ) : isEmpty ? ( +

    + {t("day.empty")} +

    + ) : ( + <> + + {data.values.length > 0 ? ( +
    + + +
    + ) : ( + + )} + + + )} + + {notShared ? ( +

    + {t("day.notShared")} +

    + ) : null} + + {coach.available && !capabilities.inSharedRecord ? ( + +
    + + {canCapture || (timelineOn && !onTimeline) ? ( +
    + {timelineOn && !onTimeline ? ( + + ) : ( + + )} + {canCapture ? ( + + ) : null} +
    + ) : null} + + {canCapture ? ( + + ) : null} +
    + ); +} + +function DaySkeleton() { + return ( + + ); +} diff --git a/src/components/day/prefill.ts b/src/components/day/prefill.ts new file mode 100644 index 000000000..2386280d0 --- /dev/null +++ b/src/components/day/prefill.ts @@ -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)}`; +} diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx new file mode 100644 index 000000000..624fc1145 --- /dev/null +++ b/src/components/day/since-last-visit.tsx @@ -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 = new Set([ + "doseChange", + "medicationStart", + "medicationEnd", + "pauseStart", + "courseStart", + "illnessOnset", + "allergyOnset", + "labResult", + "procedure", + "vaccination", +]); + +const EVENT_ICON: Partial> = { + 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; + 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(`/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 ( +
    +
    +

    + {t("day.sinceLastVisit.title", { date: lastLabel })} +

    + {rows.length > 0 ? ( + + {tCount("day.sinceLastVisit.count", rows.length)} + + ) : null} +
    + {loading && rows.length === 0 ? ( + + ) : ( +
      + {visible.map((row) => ( +
    • + + + {fmt.dateShortSmartCalendar(row.date)} + + +
    • + ))} +
    + )} + {rows.length > VISIBLE_ROWS ? ( + + ) : null} +

    + {t("day.sinceLastVisit.note")} +

    + +
    + ); +} diff --git a/src/components/day/use-day-value-format.ts b/src/components/day/use-day-value-format.ts new file mode 100644 index 000000000..2764492ac --- /dev/null +++ b/src/components/day/use-day-value-format.ts @@ -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 = 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)[ + tileKey + ]; + return key ? t(key) : tileKey; + }, + [t], + ); + + const number = useCallback( + (row: Pick, 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): 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 }; +} diff --git a/src/components/day/use-day.ts b/src/components/day/use-day.ts new file mode 100644 index 000000000..5dcc8fc97 --- /dev/null +++ b/src/components/day/use-day.ts @@ -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(`/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(`/api/day/index?${params}`); + }, + enabled: enabled && from !== null && to !== null && from <= to, + staleTime: 5 * 60_000, + }); +} diff --git a/src/components/day/use-today-key.ts b/src/components/day/use-today-key.ts new file mode 100644 index 000000000..b00cede85 --- /dev/null +++ b/src/components/day/use-today-key.ts @@ -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); +} diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 0b739ff45..04b19c114 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -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({

    {title}

    - {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 ? ( + + {date} + + ) : ( + date + )}{" "} + · {size}

    {showFilename ? ( // Filename on its OWN muted line so it never clips the date/size diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index eef6bd78e..21aa16783 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -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 ? (
    ) : null} diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 37e327d46..822fc0837 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -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 (

    - {fmt.dateShortSmartCalendar(log.date)} + {/* v1.42 — the logged day opens the whole day. */} + + {fmt.dateShortSmartCalendar(log.date)} +

    {log.symptoms.length > 0 ? ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4e592d1ee..0ef5b8f1f 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -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")} - {fmt.dateShortSmart(new Date(episode.onsetAt))} + + {fmt.dateShortSmart(new Date(episode.onsetAt))} +
    @@ -173,15 +180,26 @@ function EpisodeCard({ + {/* v1.42 — onset and recovery are the interesting days of a + period: each opens its day. */}

    - {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 }), + + {fmt.dateShortSmart(new Date(episode.onsetAt))} + , + )} + {episode.resolvedAt ? ( + <> + {" · "} + {withDayLinkSlot( + t("illness.recoveredOn", { date: DAY_LINK_SLOT }), + + {fmt.dateShortSmart(new Date(episode.resolvedAt))} + , + )} + + ) : null}

    {flareCount > 0 ? ( diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index f2a7ef634..9a712d453 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -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 ? ( + + ) : 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 ? ( diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 3863e9062..60c6a2109 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -491,6 +491,7 @@ export function HealthKitMetricPage({ valueOffset={resolvedOffset} onVisibleStats={onVisibleStats} showDataTable + dayLinks /> {bothMeasures ? ( ) : null} {targetSummarySlug ? ( diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 5cc99ce15..1b69d184b 100644 --- a/src/components/insights/workout-detail/day-context.tsx +++ b/src/components/insights/workout-detail/day-context.tsx @@ -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={ - - {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. + + {fmt.dateWithWeekdaySmart(new Date(workout.startedAt))} + + {t("day.viewWholeDay")} + } /> diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index ab9130316..c39431619 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -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 ( -
  • - +
    - {sportName} + + {sportName} + {/* #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} - {formatDate(workout.startedAt, locale, timeFormat)} + + {formatDate(workout.startedAt, locale, timeFormat)} +
    @@ -160,7 +171,7 @@ export function WorkoutList({ ) : null}
    - +
  • ); })} diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index bd4804579..ec2a1774e 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -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("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({ { + const index = Number(state?.activeTooltipIndex); + if (Number.isInteger(index)) + openReadingDay(points[index]); + } + } > ) : null} { 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 ? ( + openReadingDay(point)} + /> + ) : undefined + } /> ); }} /> + {points + .filter( + (point) => + openDayKey !== null && + dayOf(point.timestamp) === openDayKey, + ) + .map((point) => ( + + ))} 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} />
    - {labDate(r.takenAt)} + + {labDate(r.takenAt)} + {r.hasNote ? ( {t("labs.hasNote")} @@ -431,7 +462,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
    - {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. */} + + {labDate(r.takenAt)} +
    diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index e794a1c59..68a360bc8 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -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 && } + {/* 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. */} + {/* 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 diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index 139ead635..50b483a72 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -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} /> )} diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index c588d32ec..8ff88bb8a 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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 * `` 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("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); } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index d85ad6a8c..7196e29aa 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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". */} - {formatDateOrRelative(m.measuredAt, t)} + {/* v1.42 — the date opens its day; the row + keeps opening the reading. */} + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} {m.notes ? ( @@ -1497,9 +1508,15 @@ export function MeasurementList({ site. Relative under 24 h, absolute older. */} - - {formatDateOrRelative(m.measuredAt, t)} - + {m.dayKey !== undefined ? ( + + {formatDateOrRelative(m.measuredAt, t)} + + ) : ( + + {formatDateOrRelative(m.measuredAt, t)} + + )} {m.source !== "MANUAL" && ( + {formatDateWithWeekdaySmart( + group.dayKey, + dateFormatPref, + locale, + )} +

      {group.rows.map((row, i) => ( diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 01607b4ff..b7333457f 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -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. + + {formatters.dateTime(event.takenAt)} + ) : ( <> {formatters.dateTime(event.scheduledFor)}{" "} @@ -483,7 +487,9 @@ export function IntakeHistoryListV2({

      {event.takenAt ? ( - formatters.dateTime(event.takenAt) + + {formatters.dateTime(event.takenAt)} + ) : ( <> {formatters.dateTime(event.scheduledFor)}{" "} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 9b14f4697..704f4f108 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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(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); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 6d68cd3a2..238292f77 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -70,6 +70,7 @@ import { const MOOD_DESC_COLUMNS: ReadonlySet = 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 = {}) { )} - {formatDateTime(entry.moodLoggedAt)} + {/* v1.42 — the entry's own day (its `date`, cut + in the zone it was logged in) opens the day. */} + + {formatDateTime(entry.moodLoggedAt)} + {entry.source !== "MANUAL" && ( @@ -932,7 +937,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { : entry.mood}

      - {formatDateTime(entry.moodLoggedAt)} + + {formatDateTime(entry.moodLoggedAt)} +

      {entry.tags.length > 0 && (

      diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 4fb1830cb..12bf71c42 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -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(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( SAVED_PROFILE_FALLBACK.includeCharts, diff --git a/src/components/symptoms/symptom-entry-form.tsx b/src/components/symptoms/symptom-entry-form.tsx index cca703874..19e6c73d4 100644 --- a/src/components/symptoms/symptom-entry-form.tsx +++ b/src/components/symptoms/symptom-entry-form.tsx @@ -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(null); const [intensity, setIntensity] = useState(null); - const [occurredAt, setOccurredAt] = useState(nowLocalValue); + const [occurredAt, setOccurredAt] = useState(() => + localDateTimeValue(defaultDate), + ); const [note, setNote] = useState(""); const [episodeId, setEpisodeId] = useState(NO_EPISODE); const [symptomError, setSymptomError] = useState(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); diff --git a/src/components/symptoms/symptoms-section.tsx b/src/components/symptoms/symptoms-section.tsx index 670f05c9a..6807ca848 100644 --- a/src/components/symptoms/symptoms-section.tsx +++ b/src/components/symptoms/symptoms-section.tsx @@ -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) => (

      - {fmt.dateWithWeekdaySmart(group.at)} + {/* v1.42 — the day heading opens the whole day. */} + + {fmt.dateWithWeekdaySmart(group.at)} +

        {group.events.map((event) => { diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index d4ced34cf..72e618a4e 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -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( - () => edit?.original.draft ?? emptyManualWorkoutDraft(), + () => + edit?.original.draft ?? { + ...emptyManualWorkoutDraft(), + start: pastDayStart(defaultDate), + }, ); const [draft, setDraft] = useState(initialDraft); const dirty = ( diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index e228a7496..a3ec1c57a 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -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", diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 7c9307d1f..7626b014c 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -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) ───────────────────────────────────────────────────