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) ───────────────────────────────────────────────────