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 4cb661346..c831bdf18 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Tag",
"previousDay": "Vortag",
"nextDay": "Folgetag",
"close": "Tag schließen",
@@ -12178,42 +12177,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 8caeba290..501722425 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Day",
"previousDay": "Previous day",
"nextDay": "Next day",
"close": "Close day",
@@ -12178,42 +12177,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 dfc5d0c56..b11bd8c39 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Día",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"close": "Cerrar día",
@@ -12178,42 +12177,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 89558cf53..3106bf016 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Jour",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"close": "Fermer le jour",
@@ -12178,42 +12177,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 ca47c9257..76e3bbb71 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Giorno",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"close": "Chiudi giorno",
@@ -12178,42 +12177,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 e79f48350..c86778d75 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "하루",
"previousDay": "전날",
"nextDay": "다음 날",
"close": "하루 닫기",
@@ -12178,42 +12177,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 a0263551f..9c04c1619 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -12166,7 +12166,6 @@
}
},
"day": {
- "title": "Dzień",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"close": "Zamknij dzień",
@@ -12178,42 +12177,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 && (
+ {event.href ? (
+ // The whole row is the target; the title carries the link
+ // so the document chips below stay their own targets.
+
+ {event.title}
+
+ ) : (
+
{event.title}
+ )}
+ {event.meta ? (
+
+ {event.meta}
+
+ ) : null}
+ {event.note ? (
+ // The person's own words: content, never muted.
+
+ {event.note}
+
+ ) : null}
+ {event.docs.length > 0 ? (
+
+ {event.docs.map((doc) => (
+
+
+
+ {doc.name}
+
+
+ ))}
+
+ ) : null}
+
+ {event.href ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
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 (
+
+
+
+ );
+}
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 ? (
- {upcoming.map((encounter) => (
-
+ {upcoming.map((encounter, i) => (
+
+
+ {/* v1.42 — the next appointment carries what happened
+ since the last one, each date opening its day. */}
+ {i === 0 && lastDone ? (
+
+
+
+
+
+ ) : null}
+
))}
) : 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. */}
{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)}
+ {/* 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}
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/types.ts b/src/lib/ai/coach/types.ts
index be0c2d5dd..0a22376c8 100644
--- a/src/lib/ai/coach/types.ts
+++ b/src/lib/ai/coach/types.ts
@@ -523,8 +523,9 @@ export interface CoachStep {
/** `r1` when the step produced a table. */
resultRef?: string;
/**
- * v1.42 (#613) — the calendar date a `get_day` call read, so the view can
- * list the days an answer looked at without reading the prose.
+ * 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;
}