From bf83d414e266eca257ed9b17a91bd44ac06d9c58 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 8 Oct 2026 14:20:01 +0200
Subject: [PATCH] feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.
The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.
Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.
Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
---
docs/api/openapi.yaml | 5 +
e2e/day-view.spec.ts | 266 ++++++++++
messages/de.json | 65 ++-
messages/en.json | 65 ++-
messages/es.json | 65 ++-
messages/fr.json | 65 ++-
messages/it.json | 65 ++-
messages/ko.json | 61 ++-
messages/pl.json | 65 ++-
src/__tests__/i18n-reverse-coverage.test.ts | 1 -
src/app/insights/blood-pressure/page.tsx | 1 +
src/app/insights/bmi/page.tsx | 1 +
src/app/insights/pulse/page.tsx | 1 +
src/app/insights/weight/page.tsx | 1 +
.../__tests__/health-chart-day-links.test.tsx | 78 +++
src/components/charts/chart-data-table.tsx | 35 +-
src/components/charts/chart-tooltip.tsx | 10 +-
src/components/charts/compliance-heatmap.tsx | 42 +-
src/components/charts/health-chart.tsx | 151 +++++-
src/components/charts/mood-heatmap.tsx | 41 +-
.../dashboard/medication-intake-quick-add.tsx | 18 +-
.../day/__tests__/chart-day.test.ts | 93 ++++
.../__tests__/day-layer-controller.test.ts | 116 +++++
.../day/__tests__/day-link.test.tsx | 67 +++
.../day/__tests__/day-not-here-guard.test.ts | 74 +++
src/components/day/__tests__/day-url.test.ts | 63 +++
.../day/__tests__/day-values-model.test.ts | 103 ++++
.../day/__tests__/day-view.test.tsx | 285 ++++++++++
src/components/day/chart-day.tsx | 198 +++++++
src/components/day/coach-day-chips.tsx | 56 ++
src/components/day/day-layer-controller.ts | 168 ++++++
src/components/day/day-layer.tsx | 384 ++++++++++++++
src/components/day/day-link.tsx | 188 +++++++
src/components/day/day-sections.tsx | 485 ++++++++++++++++++
src/components/day/day-url.ts | 110 ++++
src/components/day/day-values-model.ts | 116 +++++
src/components/day/day-view.tsx | 386 ++++++++++++++
src/components/day/prefill.ts | 20 +
src/components/day/since-last-visit.tsx | 308 +++++++++++
src/components/day/use-day-value-format.ts | 124 +++++
src/components/day/use-day.ts | 54 ++
src/components/day/use-today-key.ts | 15 +
src/components/documents/document-card.tsx | 17 +-
src/components/encounters/visits-section.tsx | 28 +-
.../illness/illness-day-timeline.tsx | 6 +-
src/components/illness/illness-view.tsx | 36 +-
.../insights/coach-panel/chat-bubble.tsx | 6 +
.../insights/healthkit-metric-page.tsx | 2 +
.../insights/workout-detail/day-context.tsx | 10 +-
src/components/insights/workout-list.tsx | 25 +-
src/components/labs/lab-biomarker-chart.tsx | 93 +++-
src/components/labs/lab-history-list.tsx | 39 +-
src/components/layout/auth-shell.tsx | 6 +
src/components/layout/capture-picker.tsx | 16 +-
.../measurements/measurement-form.tsx | 13 +-
.../measurements/measurement-list.tsx | 25 +-
.../medications/dose-history-ledger.tsx | 12 +-
.../medications/intake-history-list-v2.tsx | 10 +-
src/components/mood/mood-form.tsx | 19 +-
src/components/mood/mood-list.tsx | 11 +-
.../report-selection/selection-panel.tsx | 26 +-
.../symptoms/symptom-entry-form.tsx | 13 +-
src/components/symptoms/symptoms-section.tsx | 6 +-
.../workouts/manual-workout-form.tsx | 21 +-
src/lib/ai/coach/stream-events.ts | 7 +
src/lib/ai/coach/types.ts | 6 +
66 files changed, 4725 insertions(+), 243 deletions(-)
create mode 100644 e2e/day-view.spec.ts
create mode 100644 src/components/charts/__tests__/health-chart-day-links.test.tsx
create mode 100644 src/components/day/__tests__/chart-day.test.ts
create mode 100644 src/components/day/__tests__/day-layer-controller.test.ts
create mode 100644 src/components/day/__tests__/day-link.test.tsx
create mode 100644 src/components/day/__tests__/day-not-here-guard.test.ts
create mode 100644 src/components/day/__tests__/day-url.test.ts
create mode 100644 src/components/day/__tests__/day-values-model.test.ts
create mode 100644 src/components/day/__tests__/day-view.test.tsx
create mode 100644 src/components/day/chart-day.tsx
create mode 100644 src/components/day/coach-day-chips.tsx
create mode 100644 src/components/day/day-layer-controller.ts
create mode 100644 src/components/day/day-layer.tsx
create mode 100644 src/components/day/day-link.tsx
create mode 100644 src/components/day/day-sections.tsx
create mode 100644 src/components/day/day-url.ts
create mode 100644 src/components/day/day-values-model.ts
create mode 100644 src/components/day/day-view.tsx
create mode 100644 src/components/day/prefill.ts
create mode 100644 src/components/day/since-last-visit.tsx
create mode 100644 src/components/day/use-day-value-format.ts
create mode 100644 src/components/day/use-day.ts
create mode 100644 src/components/day/use-today-key.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index a3e9d839e..044ad773a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -54766,6 +54766,11 @@ components:
description: The table this step produced (`r1`..), when it produced one.
type: string
pattern: ^r[1-8]$
+ day:
+ description: The calendar day a `get_day` step read, `YYYY-MM-DD`. A settled answer lists these days as links to each
+ day; set only on `get_day` steps.
+ type: string
+ pattern: ^\d{4}-\d{2}-\d{2}$
required:
- id
- tool
diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts
new file mode 100644
index 000000000..b0439aec8
--- /dev/null
+++ b/e2e/day-view.spec.ts
@@ -0,0 +1,266 @@
+import type { Page, Route } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
+import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
+
+/**
+ * v1.42 — the day view and its doors.
+ *
+ * The record is the phone-sweep account `globalSetup` seeds: a week of blood
+ * pressure, two lab readings, mood and an episode. The day itself
+ * (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
+ * the contract's shape, so the journeys pin the client: which door opens which
+ * day, what the history does, and where no door is.
+ *
+ * - a chart point opens the day beside the chart (desktop);
+ * - on a touch screen the first tap shows the value and the tooltip offers
+ * the day, the second opens it as a bottom sheet;
+ * - a date in a list opens the day while the row keeps its own target;
+ * - `?day=` opens the layer on any page, Back and Escape close it, a date in
+ * the future is dropped;
+ * - stepping to the neighbouring days never adds history entries;
+ * - the dashboard's today area offers no door.
+ *
+ * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
+ * `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
+ * `day-next` / `day-close`.
+ */
+
+function dayFixture(date: string) {
+ return {
+ date,
+ tz: "Europe/Berlin",
+ counts: { values: 2, entries: 1 },
+ running: [
+ {
+ kind: "illness",
+ section: "illness",
+ id: "ep1",
+ title: "Common cold",
+ sub: null,
+ since: date,
+ until: null,
+ dayIndex: 1,
+ dayCount: null,
+ href: "/illness",
+ },
+ ],
+ values: [
+ {
+ type: "BLOOD_PRESSURE_SYS",
+ value: 124,
+ unit: "mmHg",
+ at: `${date}T07:00:00.000Z`,
+ source: "MANUAL",
+ band: { lo: 118, hi: 128, n: 20 },
+ },
+ {
+ type: "BLOOD_PRESSURE_DIA",
+ value: 79,
+ unit: "mmHg",
+ at: `${date}T07:00:00.000Z`,
+ source: "MANUAL",
+ band: { lo: 76, hi: 82, n: 20 },
+ },
+ ],
+ events: [
+ {
+ at: `${date}T07:00:00.000Z`,
+ kind: "mood",
+ section: "mood",
+ id: "m1",
+ title: "Mood: good",
+ meta: null,
+ note: null,
+ docs: [],
+ href: "/mood",
+ },
+ ],
+ notable: [],
+ sections: {},
+ };
+}
+
+async function mockDay(page: Page) {
+ await page.route("**/api/day/**", async (route: Route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api/day/index") {
+ const from = url.searchParams.get("from") ?? "";
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ data: {
+ from,
+ to: url.searchParams.get("to") ?? "",
+ days: {},
+ notable: [],
+ },
+ error: null,
+ }),
+ });
+ return;
+ }
+ const date = url.pathname.split("/").pop() ?? "";
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ data: dayFixture(date), error: null }),
+ });
+ });
+}
+
+const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
+
+function isoDaysAgo(days: number): string {
+ const d = new Date(Date.now() - days * 86_400_000);
+ return d.toISOString().slice(0, 10);
+}
+
+test.describe("the day view", () => {
+ test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
+
+ test.beforeEach(async ({ page, context }, testInfo) => {
+ const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
+ await context.addCookies([
+ { name: "healthlog-locale", value: "en", url: baseURL },
+ ]);
+ await mockDay(page);
+ });
+
+ test("a chart point opens its day beside the chart; Back closes it", async ({
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.goto("/insights/blood-pressure");
+ const plot = page.locator(
+ '[data-slot="chart-plot"][data-day-links="true"]',
+ );
+ await expect(plot).toBeVisible();
+ // The plot's right half holds the most recent points.
+ const box = (await plot.boundingBox())!;
+ await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
+ await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
+
+ await expect(panel(page)).toBeVisible();
+ await expect(panel(page)).toHaveAttribute("data-shell", "docked");
+ // A landmark beside the page, named by its date.
+ await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
+ await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
+ await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
+ // The chart beside it stays usable: the panel is not modal.
+ await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
+
+ await page.goBack();
+ await expect(panel(page)).toHaveCount(0);
+ await expect(page).not.toHaveURL(/[?&]day=/);
+ });
+
+ test("a date in a list opens the day; the row keeps its own target", async ({
+ page,
+ }) => {
+ await page.goto("/labs");
+ await page
+ .locator('[data-slot="lab-list-analyte"]')
+ .filter({ hasText: MOBILE_ROUTES_ANALYTE })
+ .first()
+ .click();
+ // The marker's readings, each dated.
+ await page.locator('a[href$="/values"]').first().click();
+ const link = page.locator('[data-slot="day-link"]:visible').first();
+ await expect(link).toBeVisible();
+ const day = await link.getAttribute("data-day");
+ const before = page.url();
+ await link.click();
+ await expect(panel(page)).toBeVisible();
+ await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
+ "data-day",
+ day!,
+ );
+ // Same page underneath: the click opened the day, not the reading.
+ expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
+ // The reading the person came from sits on top, with the one before it.
+ await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
+ });
+
+ test("?day= opens the layer on any page; Escape closes it", async ({
+ page,
+ }) => {
+ const day = isoDaysAgo(3);
+ await page.goto(`/mood?day=${day}`);
+ await expect(panel(page)).toBeVisible();
+ await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
+ "data-day",
+ day,
+ );
+ await page.keyboard.press("Escape");
+ await expect(panel(page)).toHaveCount(0);
+ await expect(page).toHaveURL(/\/mood$/);
+ });
+
+ test("a future or malformed ?day= is dropped without a word", async ({
+ page,
+ }) => {
+ await page.goto("/mood?day=2999-01-01");
+ await expect(page).toHaveURL(/\/mood$/);
+ await expect(panel(page)).toHaveCount(0);
+ await page.goto("/mood?day=2026-02-30");
+ await expect(page).toHaveURL(/\/mood$/);
+ await expect(panel(page)).toHaveCount(0);
+ });
+
+ test("stepping between days never adds a history entry", async ({ page }) => {
+ await page.goto("/mood");
+ await page.locator('[data-slot="day-link"]:visible').first().click();
+ await expect(panel(page)).toBeVisible();
+ const start = await page.evaluate(() => window.history.length);
+ const first = await page
+ .locator('[data-slot="day-view"]')
+ .getAttribute("data-day");
+ for (let i = 0; i < 3; i += 1) {
+ await page.locator('[data-slot="day-prev"]').click();
+ }
+ await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
+ "data-day",
+ first!,
+ );
+ expect(await page.evaluate(() => window.history.length)).toBe(start);
+ // One Back leaves the layer, however far it stepped.
+ await page.goBack();
+ await expect(panel(page)).toHaveCount(0);
+ await expect(page).toHaveURL(/\/mood$/);
+ });
+
+ test("on a phone the first tap shows the value, the second opens the day", async ({
+ page,
+ }, testInfo) => {
+ test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
+ await page.goto("/insights/blood-pressure");
+ const plot = page.locator(
+ '[data-slot="chart-plot"][data-day-links="true"]',
+ );
+ await plot.scrollIntoViewIfNeeded();
+ await expect(plot).toBeVisible();
+ const box = (await plot.boundingBox())!;
+ await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
+ const open = page.locator('[data-slot="chart-tooltip-open-day"]');
+ await expect(open).toBeVisible();
+ // A tap alone never opens a sheet.
+ await expect(panel(page)).toHaveCount(0);
+ await open.tap();
+ await expect(panel(page)).toBeVisible();
+ await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
+ });
+
+ test("the dashboard's today area offers no day door", async ({ page }) => {
+ await page.goto("/");
+ await expect(
+ page.locator('[data-slot="main-content-wrapper"]'),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-slot="chart-plot"][data-day-links="true"]'),
+ ).toHaveCount(0);
+ });
+});
diff --git a/messages/de.json b/messages/de.json
index 5e38ba61e..ad29c4ba5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Tag",
"previousDay": "Vortag",
"nextDay": "Folgetag",
"close": "Tag schließen",
@@ -12174,42 +12173,58 @@
"inTimeline": "In der Zeitleiste",
"askCoach": "Im Coach nach diesem Tag fragen",
"chartHint": "Klick auf einen Punkt öffnet den Tag.",
- "daysWithEntries": "Tage mit Einträgen",
"loadFailed": "Dieser Tag konnte nicht geladen werden.",
"dayOf": "Tag {n} von {m}",
"dayN": "Tag {n}",
- "since": "Seit {date}",
+ "since": "seit {date}",
"usualRange": "Üblich {lo} bis {hi}",
"groups": {
- "running": "Laufend",
+ "running": "Laufend an diesem Tag",
"values": "Werte",
- "happened": "Passiert"
- },
- "sections": {
- "values": "Werte",
- "sleep": "Schlaf",
- "mood": "Stimmung",
- "assessments": "Fragebögen",
- "medications": "Medikamente",
- "illness": "Krankheit",
- "symptoms": "Symptome",
- "allergies": "Allergien",
- "labs": "Labor",
- "visits": "Arzt und Eingriffe",
- "vaccinations": "Impfungen",
- "checkups": "Vorsorge",
- "documents": "Dokumente",
- "workouts": "Workouts",
- "cycle": "Zyklus",
- "environment": "Umwelt",
- "lifestyle": "Lebensstil",
- "lifeEvents": "Lebensereignisse"
+ "happened": "Was war"
},
"notable": {
"extremeHigh": "Höchster Tageswert seit {since}",
"extremeLow": "Niedrigster Tageswert seit {since}",
"firstValue": "Erster Wert dieser Art",
"gap": "{days} Tage ohne Wert"
+ },
+ "previousDayShortcut": "Vortag (Alt + Pfeil links)",
+ "nextDayShortcut": "Folgetag (Alt + Pfeil rechts)",
+ "captureShort": "Erfassen",
+ "inTimelineShort": "Zeitleiste",
+ "askCoachPrompt": "Was war am {date}?",
+ "chartHintTouch": "Antippen zeigt den Wert.",
+ "coachChips": "Tage in dieser Antwort",
+ "description": "Alles, was deine Einträge für diesen Tag enthalten.",
+ "openDocument": "Dokument öffnen",
+ "previousReading": "Vorher, {date}",
+ "rugLegend": "Punkte unter der Achse: Tage mit Einträgen · Ring: auffälliger Tag",
+ "sheetExpand": "Auf volle Höhe aufziehen",
+ "sheetShrink": "Verkleinern",
+ "usualLabel": "Deine 30 Tage davor",
+ "valuesLegend": "Punkt: dieser Tag · Strich: deine 30 Tage davor",
+ "valuesAllOne": "Alle Werte",
+ "valuesAllFew": "Alle {count} Werte",
+ "valuesAllOther": "Alle {count} Werte",
+ "valuesFewer": "Weniger Werte",
+ "countValuesOne": "1 Wert",
+ "countValuesFew": "{count} Werte",
+ "countValuesOther": "{count} Werte",
+ "countEntriesOne": "1 Eintrag",
+ "countEntriesFew": "{count} Einträge",
+ "countEntriesOther": "{count} Einträge",
+ "sinceLastVisit": {
+ "title": "Seit dem letzten Besuch am {date}",
+ "countOne": "1 Eintrag",
+ "countFew": "{count} Einträge",
+ "countOther": "{count} Einträge",
+ "allOne": "Alle anzeigen",
+ "allFew": "Alle {count} anzeigen",
+ "allOther": "Alle {count} anzeigen",
+ "fewer": "Weniger anzeigen",
+ "note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.",
+ "report": "Arztbericht {date} bis heute"
}
},
"timeline": {
diff --git a/messages/en.json b/messages/en.json
index d5c9e4027..a563bdd62 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Day",
"previousDay": "Previous day",
"nextDay": "Next day",
"close": "Close day",
@@ -12174,42 +12173,58 @@
"inTimeline": "In the timeline",
"askCoach": "Ask the Coach about this day",
"chartHint": "Click a point to open its day.",
- "daysWithEntries": "Days with entries",
"loadFailed": "This day could not be loaded.",
"dayOf": "Day {n} of {m}",
"dayN": "Day {n}",
- "since": "Since {date}",
+ "since": "since {date}",
"usualRange": "Usually {lo} to {hi}",
"groups": {
- "running": "Running",
+ "running": "Running that day",
"values": "Values",
- "happened": "Happened"
- },
- "sections": {
- "values": "Values",
- "sleep": "Sleep",
- "mood": "Mood",
- "assessments": "Check-ins",
- "medications": "Medications",
- "illness": "Illness",
- "symptoms": "Symptoms",
- "allergies": "Allergies",
- "labs": "Labs",
- "visits": "Doctor and procedures",
- "vaccinations": "Vaccinations",
- "checkups": "Check-ups",
- "documents": "Documents",
- "workouts": "Workouts",
- "cycle": "Cycle",
- "environment": "Environment",
- "lifestyle": "Lifestyle",
- "lifeEvents": "Life events"
+ "happened": "What happened"
},
"notable": {
"extremeHigh": "Highest daily value since {since}",
"extremeLow": "Lowest daily value since {since}",
"firstValue": "First reading of this kind",
"gap": "No reading for {days} days"
+ },
+ "previousDayShortcut": "Previous day (Alt + Left arrow)",
+ "nextDayShortcut": "Next day (Alt + Right arrow)",
+ "captureShort": "Log",
+ "inTimelineShort": "Timeline",
+ "askCoachPrompt": "What happened on {date}?",
+ "chartHintTouch": "Tap a point to see its value.",
+ "coachChips": "Days in this answer",
+ "description": "Everything your records hold for this day.",
+ "openDocument": "Open document",
+ "previousReading": "Before, {date}",
+ "rugLegend": "Dots under the axis: days with entries · ring: notable day",
+ "sheetExpand": "Expand to full height",
+ "sheetShrink": "Shrink",
+ "usualLabel": "Your 30 days before",
+ "valuesLegend": "Dot: this day · bar: your 30 days before",
+ "valuesAllOne": "All values",
+ "valuesAllFew": "All {count} values",
+ "valuesAllOther": "All {count} values",
+ "valuesFewer": "Fewer values",
+ "countValuesOne": "1 value",
+ "countValuesFew": "{count} values",
+ "countValuesOther": "{count} values",
+ "countEntriesOne": "1 entry",
+ "countEntriesFew": "{count} entries",
+ "countEntriesOther": "{count} entries",
+ "sinceLastVisit": {
+ "title": "Since the last visit on {date}",
+ "countOne": "1 entry",
+ "countFew": "{count} entries",
+ "countOther": "{count} entries",
+ "allOne": "Show all",
+ "allFew": "Show all {count}",
+ "allOther": "Show all {count}",
+ "fewer": "Show fewer",
+ "note": "Compiled from your entries, without judgement. Every date opens the whole day.",
+ "report": "Doctor report from {date} to today"
}
},
"timeline": {
diff --git a/messages/es.json b/messages/es.json
index 95594a427..6233b0d7d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Día",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"close": "Cerrar día",
@@ -12174,42 +12173,58 @@
"inTimeline": "En la cronología",
"askCoach": "Preguntar al Coach por este día",
"chartHint": "Haz clic en un punto para abrir su día.",
- "daysWithEntries": "Días con entradas",
"loadFailed": "No se pudo cargar este día.",
"dayOf": "Día {n} de {m}",
"dayN": "Día {n}",
- "since": "Desde {date}",
+ "since": "desde {date}",
"usualRange": "Habitual de {lo} a {hi}",
"groups": {
- "running": "En curso",
+ "running": "En curso ese día",
"values": "Valores",
- "happened": "Sucedió"
- },
- "sections": {
- "values": "Valores",
- "sleep": "Sueño",
- "mood": "Estado de ánimo",
- "assessments": "Cuestionarios",
- "medications": "Medicamentos",
- "illness": "Enfermedad",
- "symptoms": "Síntomas",
- "allergies": "Alergias",
- "labs": "Laboratorio",
- "visits": "Médico e intervenciones",
- "vaccinations": "Vacunas",
- "checkups": "Chequeos",
- "documents": "Documentos",
- "workouts": "Entrenamientos",
- "cycle": "Ciclo",
- "environment": "Entorno",
- "lifestyle": "Estilo de vida",
- "lifeEvents": "Acontecimientos vitales"
+ "happened": "Qué pasó"
},
"notable": {
"extremeHigh": "Valor diario más alto desde {since}",
"extremeLow": "Valor diario más bajo desde {since}",
"firstValue": "Primera medición de este tipo",
"gap": "{days} días sin medición"
+ },
+ "previousDayShortcut": "Día anterior (Alt + flecha izquierda)",
+ "nextDayShortcut": "Día siguiente (Alt + flecha derecha)",
+ "captureShort": "Registrar",
+ "inTimelineShort": "Cronología",
+ "askCoachPrompt": "¿Qué pasó el {date}?",
+ "chartHintTouch": "Toca un punto para ver su valor.",
+ "coachChips": "Días en esta respuesta",
+ "description": "Todo lo que tus registros contienen para este día.",
+ "openDocument": "Abrir documento",
+ "previousReading": "Antes, {date}",
+ "rugLegend": "Puntos bajo el eje: días con entradas · anillo: día destacado",
+ "sheetExpand": "Ampliar a toda la altura",
+ "sheetShrink": "Reducir",
+ "usualLabel": "Tus 30 días anteriores",
+ "valuesLegend": "Punto: este día · barra: tus 30 días anteriores",
+ "valuesAllOne": "Todos los valores",
+ "valuesAllFew": "Los {count} valores",
+ "valuesAllOther": "Los {count} valores",
+ "valuesFewer": "Menos valores",
+ "countValuesOne": "1 valor",
+ "countValuesFew": "{count} valores",
+ "countValuesOther": "{count} valores",
+ "countEntriesOne": "1 entrada",
+ "countEntriesFew": "{count} entradas",
+ "countEntriesOther": "{count} entradas",
+ "sinceLastVisit": {
+ "title": "Desde la última visita el {date}",
+ "countOne": "1 entrada",
+ "countFew": "{count} entradas",
+ "countOther": "{count} entradas",
+ "allOne": "Mostrar todo",
+ "allFew": "Mostrar las {count}",
+ "allOther": "Mostrar las {count}",
+ "fewer": "Mostrar menos",
+ "note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.",
+ "report": "Informe médico del {date} hasta hoy"
}
},
"timeline": {
diff --git a/messages/fr.json b/messages/fr.json
index 9a25437e0..127da7500 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Jour",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"close": "Fermer le jour",
@@ -12174,42 +12173,58 @@
"inTimeline": "Dans la chronologie",
"askCoach": "Interroger le Coach sur ce jour",
"chartHint": "Cliquez sur un point pour ouvrir son jour.",
- "daysWithEntries": "Jours avec des entrées",
"loadFailed": "Impossible de charger ce jour.",
"dayOf": "Jour {n} sur {m}",
"dayN": "Jour {n}",
- "since": "Depuis le {date}",
+ "since": "depuis le {date}",
"usualRange": "Habituellement de {lo} à {hi}",
"groups": {
- "running": "En cours",
+ "running": "En cours ce jour-là",
"values": "Valeurs",
- "happened": "Événements"
- },
- "sections": {
- "values": "Valeurs",
- "sleep": "Sommeil",
- "mood": "Humeur",
- "assessments": "Questionnaires",
- "medications": "Médicaments",
- "illness": "Maladie",
- "symptoms": "Symptômes",
- "allergies": "Allergies",
- "labs": "Analyses",
- "visits": "Médecin et interventions",
- "vaccinations": "Vaccinations",
- "checkups": "Dépistages",
- "documents": "Documents",
- "workouts": "Entraînements",
- "cycle": "Cycle",
- "environment": "Environnement",
- "lifestyle": "Mode de vie",
- "lifeEvents": "Événements de vie"
+ "happened": "Ce qui s’est passé"
},
"notable": {
"extremeHigh": "Valeur journalière la plus haute depuis {since}",
"extremeLow": "Valeur journalière la plus basse depuis {since}",
"firstValue": "Première mesure de ce type",
"gap": "{days} jours sans mesure"
+ },
+ "previousDayShortcut": "Jour précédent (Alt + flèche gauche)",
+ "nextDayShortcut": "Jour suivant (Alt + flèche droite)",
+ "captureShort": "Enregistrer",
+ "inTimelineShort": "Chronologie",
+ "askCoachPrompt": "Que s’est-il passé le {date} ?",
+ "chartHintTouch": "Touchez un point pour voir sa valeur.",
+ "coachChips": "Jours dans cette réponse",
+ "description": "Tout ce que vos entrées contiennent pour ce jour.",
+ "openDocument": "Ouvrir le document",
+ "previousReading": "Avant, {date}",
+ "rugLegend": "Points sous l’axe : jours avec entrées · anneau : jour remarquable",
+ "sheetExpand": "Agrandir en pleine hauteur",
+ "sheetShrink": "Réduire",
+ "usualLabel": "Vos 30 jours précédents",
+ "valuesLegend": "Point : ce jour · barre : vos 30 jours précédents",
+ "valuesAllOne": "Toutes les valeurs",
+ "valuesAllFew": "Les {count} valeurs",
+ "valuesAllOther": "Les {count} valeurs",
+ "valuesFewer": "Moins de valeurs",
+ "countValuesOne": "1 valeur",
+ "countValuesFew": "{count} valeurs",
+ "countValuesOther": "{count} valeurs",
+ "countEntriesOne": "1 entrée",
+ "countEntriesFew": "{count} entrées",
+ "countEntriesOther": "{count} entrées",
+ "sinceLastVisit": {
+ "title": "Depuis la dernière visite le {date}",
+ "countOne": "1 entrée",
+ "countFew": "{count} entrées",
+ "countOther": "{count} entrées",
+ "allOne": "Tout afficher",
+ "allFew": "Afficher les {count}",
+ "allOther": "Afficher les {count}",
+ "fewer": "Afficher moins",
+ "note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.",
+ "report": "Rapport médical du {date} à aujourd’hui"
}
},
"timeline": {
diff --git a/messages/it.json b/messages/it.json
index d8e68c0c9..e7af260d2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Giorno",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"close": "Chiudi giorno",
@@ -12174,42 +12173,58 @@
"inTimeline": "Nella cronologia",
"askCoach": "Chiedi al Coach di questo giorno",
"chartHint": "Fai clic su un punto per aprire il suo giorno.",
- "daysWithEntries": "Giorni con voci",
"loadFailed": "Impossibile caricare questo giorno.",
"dayOf": "Giorno {n} di {m}",
"dayN": "Giorno {n}",
- "since": "Dal {date}",
+ "since": "dal {date}",
"usualRange": "Di solito da {lo} a {hi}",
"groups": {
- "running": "In corso",
+ "running": "In corso quel giorno",
"values": "Valori",
- "happened": "Accaduto"
- },
- "sections": {
- "values": "Valori",
- "sleep": "Sonno",
- "mood": "Umore",
- "assessments": "Questionari",
- "medications": "Farmaci",
- "illness": "Malattia",
- "symptoms": "Sintomi",
- "allergies": "Allergie",
- "labs": "Esami",
- "visits": "Medico e interventi",
- "vaccinations": "Vaccinazioni",
- "checkups": "Controlli",
- "documents": "Documenti",
- "workouts": "Allenamenti",
- "cycle": "Ciclo",
- "environment": "Ambiente",
- "lifestyle": "Stile di vita",
- "lifeEvents": "Eventi di vita"
+ "happened": "Cosa è successo"
},
"notable": {
"extremeHigh": "Valore giornaliero più alto dal {since}",
"extremeLow": "Valore giornaliero più basso dal {since}",
"firstValue": "Prima misurazione di questo tipo",
"gap": "{days} giorni senza misurazione"
+ },
+ "previousDayShortcut": "Giorno precedente (Alt + freccia sinistra)",
+ "nextDayShortcut": "Giorno successivo (Alt + freccia destra)",
+ "captureShort": "Registra",
+ "inTimelineShort": "Cronologia",
+ "askCoachPrompt": "Cosa è successo il {date}?",
+ "chartHintTouch": "Tocca un punto per vederne il valore.",
+ "coachChips": "Giorni in questa risposta",
+ "description": "Tutto ciò che le tue voci contengono per questo giorno.",
+ "openDocument": "Apri documento",
+ "previousReading": "Prima, {date}",
+ "rugLegend": "Punti sotto l’asse: giorni con voci · anello: giorno rilevante",
+ "sheetExpand": "Espandi a tutta altezza",
+ "sheetShrink": "Riduci",
+ "usualLabel": "I tuoi 30 giorni precedenti",
+ "valuesLegend": "Punto: questo giorno · barra: i tuoi 30 giorni precedenti",
+ "valuesAllOne": "Tutti i valori",
+ "valuesAllFew": "Tutti i {count} valori",
+ "valuesAllOther": "Tutti i {count} valori",
+ "valuesFewer": "Meno valori",
+ "countValuesOne": "1 valore",
+ "countValuesFew": "{count} valori",
+ "countValuesOther": "{count} valori",
+ "countEntriesOne": "1 voce",
+ "countEntriesFew": "{count} voci",
+ "countEntriesOther": "{count} voci",
+ "sinceLastVisit": {
+ "title": "Dall’ultima visita del {date}",
+ "countOne": "1 voce",
+ "countFew": "{count} voci",
+ "countOther": "{count} voci",
+ "allOne": "Mostra tutto",
+ "allFew": "Mostra tutte le {count}",
+ "allOther": "Mostra tutte le {count}",
+ "fewer": "Mostra meno",
+ "note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.",
+ "report": "Referto medico dal {date} a oggi"
}
},
"timeline": {
diff --git a/messages/ko.json b/messages/ko.json
index 9b35bfea8..0646a4249 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "하루",
"previousDay": "전날",
"nextDay": "다음 날",
"close": "하루 닫기",
@@ -12174,42 +12173,58 @@
"inTimeline": "타임라인에서 보기",
"askCoach": "이 날에 대해 코치에게 묻기",
"chartHint": "점을 클릭하면 그날이 열려요.",
- "daysWithEntries": "기록이 있는 날",
"loadFailed": "이 날을 불러오지 못했어요.",
"dayOf": "{m}일 중 {n}일째",
"dayN": "{n}일째",
"since": "{date}부터",
"usualRange": "평소 {lo}~{hi}",
"groups": {
- "running": "진행 중",
+ "running": "그날 진행 중",
"values": "수치",
"happened": "있었던 일"
},
- "sections": {
- "values": "수치",
- "sleep": "수면",
- "mood": "기분",
- "assessments": "설문",
- "medications": "약",
- "illness": "질환",
- "symptoms": "증상",
- "allergies": "알레르기",
- "labs": "검사",
- "visits": "진료와 시술",
- "vaccinations": "예방접종",
- "checkups": "건강검진",
- "documents": "문서",
- "workouts": "운동",
- "cycle": "생리 주기",
- "environment": "환경",
- "lifestyle": "생활 습관",
- "lifeEvents": "삶의 사건"
- },
"notable": {
"extremeHigh": "{since} 이후 가장 높은 하루 값",
"extremeLow": "{since} 이후 가장 낮은 하루 값",
"firstValue": "이 종류의 첫 측정",
"gap": "{days}일 동안 측정 없음"
+ },
+ "previousDayShortcut": "전날 (Alt + 왼쪽 화살표)",
+ "nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)",
+ "captureShort": "기록",
+ "inTimelineShort": "타임라인",
+ "askCoachPrompt": "{date}에 무슨 일이 있었나요?",
+ "chartHintTouch": "점을 탭하면 값이 보여요.",
+ "coachChips": "이 답변에 쓰인 날",
+ "description": "이 날에 대한 모든 기록이에요.",
+ "openDocument": "문서 열기",
+ "previousReading": "이전, {date}",
+ "rugLegend": "축 아래 점: 기록이 있는 날 · 고리: 눈에 띄는 날",
+ "sheetExpand": "전체 높이로 펼치기",
+ "sheetShrink": "줄이기",
+ "usualLabel": "직전 30일",
+ "valuesLegend": "점: 이 날 · 막대: 직전 30일",
+ "valuesAllOne": "모든 수치",
+ "valuesAllFew": "수치 {count}개 모두",
+ "valuesAllOther": "수치 {count}개 모두",
+ "valuesFewer": "수치 줄여 보기",
+ "countValuesOne": "수치 1개",
+ "countValuesFew": "수치 {count}개",
+ "countValuesOther": "수치 {count}개",
+ "countEntriesOne": "기록 1개",
+ "countEntriesFew": "기록 {count}개",
+ "countEntriesOther": "기록 {count}개",
+ "sinceLastVisit": {
+ "title": "{date} 마지막 방문 이후",
+ "countOne": "기록 1개",
+ "countFew": "기록 {count}개",
+ "countOther": "기록 {count}개",
+ "allOne": "모두 보기",
+ "allFew": "{count}개 모두 보기",
+ "allOther": "{count}개 모두 보기",
+ "fewer": "줄여 보기",
+ "note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.",
+ "report": "{date}부터 오늘까지 진료 보고서"
}
},
"timeline": {
diff --git a/messages/pl.json b/messages/pl.json
index fc3569b56..505f7bd64 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -12162,7 +12162,6 @@
}
},
"day": {
- "title": "Dzień",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"close": "Zamknij dzień",
@@ -12174,42 +12173,58 @@
"inTimeline": "Na osi czasu",
"askCoach": "Zapytaj Coacha o ten dzień",
"chartHint": "Kliknij punkt, aby otworzyć jego dzień.",
- "daysWithEntries": "Dni z wpisami",
"loadFailed": "Nie udało się wczytać tego dnia.",
"dayOf": "Dzień {n} z {m}",
"dayN": "Dzień {n}",
- "since": "Od {date}",
+ "since": "od {date}",
"usualRange": "Zwykle od {lo} do {hi}",
"groups": {
- "running": "W toku",
+ "running": "W toku tego dnia",
"values": "Wartości",
- "happened": "Wydarzyło się"
- },
- "sections": {
- "values": "Wartości",
- "sleep": "Sen",
- "mood": "Nastrój",
- "assessments": "Kwestionariusze",
- "medications": "Leki",
- "illness": "Choroba",
- "symptoms": "Objawy",
- "allergies": "Alergie",
- "labs": "Badania",
- "visits": "Lekarz i zabiegi",
- "vaccinations": "Szczepienia",
- "checkups": "Badania profilaktyczne",
- "documents": "Dokumenty",
- "workouts": "Treningi",
- "cycle": "Cykl",
- "environment": "Otoczenie",
- "lifestyle": "Styl życia",
- "lifeEvents": "Wydarzenia życiowe"
+ "happened": "Co się działo"
},
"notable": {
"extremeHigh": "Najwyższa wartość dzienna od {since}",
"extremeLow": "Najniższa wartość dzienna od {since}",
"firstValue": "Pierwszy pomiar tego rodzaju",
"gap": "{days} dni bez pomiaru"
+ },
+ "previousDayShortcut": "Poprzedni dzień (Alt + strzałka w lewo)",
+ "nextDayShortcut": "Następny dzień (Alt + strzałka w prawo)",
+ "captureShort": "Zapisz",
+ "inTimelineShort": "Oś czasu",
+ "askCoachPrompt": "Co się działo {date}?",
+ "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.",
+ "coachChips": "Dni w tej odpowiedzi",
+ "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.",
+ "openDocument": "Otwórz dokument",
+ "previousReading": "Wcześniej, {date}",
+ "rugLegend": "Punkty pod osią: dni z wpisami · pierścień: dzień godny uwagi",
+ "sheetExpand": "Rozwiń na pełną wysokość",
+ "sheetShrink": "Zmniejsz",
+ "usualLabel": "Twoje 30 dni wcześniej",
+ "valuesLegend": "Punkt: ten dzień · pasek: Twoje 30 dni wcześniej",
+ "valuesAllOne": "Wszystkie wartości",
+ "valuesAllFew": "Wszystkie {count} wartości",
+ "valuesAllOther": "Wszystkie {count} wartości",
+ "valuesFewer": "Mniej wartości",
+ "countValuesOne": "1 wartość",
+ "countValuesFew": "{count} wartości",
+ "countValuesOther": "{count} wartości",
+ "countEntriesOne": "1 wpis",
+ "countEntriesFew": "{count} wpisy",
+ "countEntriesOther": "{count} wpisów",
+ "sinceLastVisit": {
+ "title": "Od ostatniej wizyty {date}",
+ "countOne": "1 wpis",
+ "countFew": "{count} wpisy",
+ "countOther": "{count} wpisów",
+ "allOne": "Pokaż wszystko",
+ "allFew": "Pokaż wszystkie {count}",
+ "allOther": "Pokaż wszystkie {count}",
+ "fewer": "Pokaż mniej",
+ "note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.",
+ "report": "Raport lekarski od {date} do dziś"
}
},
"timeline": {
diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts
index e6e818fdf..ae0e99949 100644
--- a/src/__tests__/i18n-reverse-coverage.test.ts
+++ b/src/__tests__/i18n-reverse-coverage.test.ts
@@ -79,7 +79,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
*/
const RELEASE_PENDING_PREFIXES: Record = {
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
- day: "v1.42 day view and its entry points (#613)",
timeline: "v1.42 timeline and its readiness inventory (#613)",
lifeEvents: "v1.42 life-event form and list (#613)",
};
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index 98df95c45..754327c50 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -174,6 +174,7 @@ export default function InsightsBlutdruckPage() {
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
showDataTable
+ dayLinks
/>
{/* v1.12.4 — target card sits between the chart and the assessment on
diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx
index 8f0fbc906..c4e91cb5a 100644
--- a/src/app/insights/bmi/page.tsx
+++ b/src/app/insights/bmi/page.tsx
@@ -124,6 +124,7 @@ export default function InsightsBmiPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
showDataTable
+ dayLinks
/>
diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx
index de8f3fc0d..74101bde9 100644
--- a/src/app/insights/pulse/page.tsx
+++ b/src/app/insights/pulse/page.tsx
@@ -159,6 +159,7 @@ export default function InsightsPulsPage() {
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
showDataTable
+ dayLinks
/>
{/* S11 — the intraday "shape of the day" layer: 10-minute mean heart
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index 00ad2e0ca..b51ebebf1 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -203,6 +203,7 @@ export default function InsightsGewichtPage() {
valueScale={weightScale}
onVisibleStats={onVisibleStats}
showDataTable
+ dayLinks
/>
diff --git a/src/components/charts/__tests__/health-chart-day-links.test.tsx b/src/components/charts/__tests__/health-chart-day-links.test.tsx
new file mode 100644
index 000000000..0e944731d
--- /dev/null
+++ b/src/components/charts/__tests__/health-chart-day-links.test.tsx
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import enMessages from "../../../../messages/en.json";
+
+/**
+ * v1.42 — the chart as a door to its days, and where it is NOT one.
+ *
+ * A metric sub-page (`dayLinks`) gets the plot click, the caption saying so,
+ * the row of day dots and a day link in every daily row of its data table.
+ * A tile's mini chart and every mount that does not ask (the dashboard) get
+ * none of it: too small to hit, and the tile has its own destination.
+ */
+
+function series(count: number) {
+ const base = Date.now() - 12 * 3_600_000;
+ return Array.from({ length: count }, (_, i) => ({
+ date: `p${i}`,
+ timestamp: base - (count - 1 - i) * 86_400_000,
+ PULSE: 60 + i,
+ }));
+}
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
+}));
+
+async function renderChart(extraProps: Record) {
+ vi.doMock("@tanstack/react-query", () => ({
+ keepPreviousData: (previous: unknown) => previous,
+ useQuery: () => ({ data: series(10), isLoading: false }),
+ useQueryClient: () => ({
+ cancelQueries: () => Promise.resolve(),
+ getQueryData: () => undefined,
+ setQueryData: () => undefined,
+ invalidateQueries: () => Promise.resolve(),
+ }),
+ useMutation: () => ({ mutate: () => undefined, isPending: false }),
+ }));
+ const { I18nProvider } = await import("@/lib/i18n/context");
+ const { HealthChart } = await import("../health-chart");
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ vi.doUnmock("@tanstack/react-query");
+ return html;
+}
+
+describe(" day links", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it("a metric page's chart opens its days: plot, caption and table", async () => {
+ const html = await renderChart({ dayLinks: true, showDataTable: true });
+ expect(html).toContain('data-day-links="true"');
+ expect(html).toContain('data-slot="chart-day-caption"');
+ expect(html).toContain("Click a point to open its day.");
+ const links = html.match(/data-slot="day-link"/g) ?? [];
+ expect(links).toHaveLength(10);
+ expect(html).toMatch(/href="[^"]*\?day=\d{4}-\d{2}-\d{2}"/);
+ });
+
+ it("a chart that does not ask stays a chart", async () => {
+ const html = await renderChart({ showDataTable: true });
+ expect(html).not.toContain('data-day-links="true"');
+ expect(html).not.toContain('data-slot="chart-day-caption"');
+ expect(html).not.toContain('data-slot="day-link"');
+ });
+
+ it("a mini chart in a tile never opens a day, even when asked", async () => {
+ const html = await renderChart({ dayLinks: true, mini: true });
+ expect(html).not.toContain('data-day-links="true"');
+ expect(html).not.toContain('data-slot="chart-day-caption"');
+ expect(html).not.toContain('data-slot="day-link"');
+ });
+});
diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx
index dbb29c192..185d00dae 100644
--- a/src/components/charts/chart-data-table.tsx
+++ b/src/components/charts/chart-data-table.tsx
@@ -15,6 +15,8 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
+import { chartPointDayKey } from "@/components/day/chart-day";
+import { DayLink } from "@/components/day/day-link";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
/**
@@ -77,6 +79,12 @@ export interface ChartDataTableProps {
bucket: ChartBucketType;
/** Metric name for the table caption. */
metricLabel: string;
+ /**
+ * v1.42 — each date cell opens its day: the keyboard and screen-reader
+ * way to a day from a chart, which has no keyboard of its own. Daily rows
+ * only; a week or month row has no single day.
+ */
+ dayLinks?: boolean;
}
const BUCKET_CAPTION_KEY: Record = {
@@ -93,6 +101,7 @@ export function ChartDataTable({
formatDate,
bucket,
metricLabel,
+ dayLinks = false,
}: ChartDataTableProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -100,6 +109,21 @@ export function ChartDataTable({
if (points.length === 0 || columns.length === 0) return null;
+ // The row's values, carried to the top of the day it opens.
+ const dayFocusOf = (point: ChartTablePoint) => {
+ const values = columns
+ .map((column) => point[column.key])
+ .filter((v): v is number => typeof v === "number" && Number.isFinite(v));
+ return values.length > 0
+ ? {
+ label: metricLabel,
+ value: values.map(formatValue).join("/"),
+ unit,
+ types: columns.map((column) => column.key),
+ }
+ : undefined;
+ };
+
// Newest first, matching the measurement list — the two places the app
// prints the same readings must not disagree on reading order.
const rows = [...points].reverse();
@@ -170,7 +194,16 @@ export function ChartDataTable({
scope="row"
className="text-foreground h-auto p-2 font-normal"
>
- {formatDate(new Date(point.timestamp))}
+ {dayLinks && bucket === "day" ? (
+
+ {formatDate(new Date(point.timestamp))}
+
+ ) : (
+ formatDate(new Date(point.timestamp))
+ )}
{columns.map((column) => {
const value = point[column.key];
diff --git a/src/components/charts/chart-tooltip.tsx b/src/components/charts/chart-tooltip.tsx
index dd6388a1f..05526d668 100644
--- a/src/components/charts/chart-tooltip.tsx
+++ b/src/components/charts/chart-tooltip.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { ReactElement } from "react";
+import type { ReactElement, ReactNode } from "react";
/**
* Apple-Health-style rich tooltip for chart wrappers (B1a v1.4.16).
@@ -51,6 +51,12 @@ export interface RichChartTooltipProps {
label?: string;
/** Already-shaped, sorted rows. The tooltip never re-orders. */
rows: RichTooltipRow[];
+ /**
+ * v1.42 — one action under the rows, e.g. "View the whole day" on a touch
+ * screen, where the tooltip is pinned by a tap and is the way onward. The
+ * chart makes the tooltip take pointer events only when it passes one.
+ */
+ action?: ReactNode;
}
/**
@@ -67,6 +73,7 @@ export function RichChartTooltip({
active,
label,
rows,
+ action,
}: RichChartTooltipProps): ReactElement | null {
if (!active || rows.length === 0) return null;
@@ -120,6 +127,7 @@ export function RichChartTooltip({
))}
+ {action ?? null}
);
}
diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx
index b4fe29356..fa9ad89af 100644
--- a/src/components/charts/compliance-heatmap.tsx
+++ b/src/components/charts/compliance-heatmap.tsx
@@ -1,5 +1,8 @@
"use client";
+import { openDay } from "@/components/day/day-layer-controller";
+import { isOpenableDay } from "@/components/day/day-url";
+import { useTodayKey } from "@/components/day/use-today-key";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { useState, useMemo, useEffect, useRef } from "react";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
@@ -100,7 +103,13 @@ export function ComplianceHeatmap({
* tooltip; a tap outside any cell clears it (wired below).
*/
pinned?: boolean;
+ /** v1.42 — the day a pinned (touch) tooltip offers to open. */
+ day?: string;
} | null>(null);
+ // v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
+ // which then offers the day. The pointer type of the press decides.
+ const lastPointer = useRef("mouse");
+ const today = useTodayKey();
// v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
// touch user can clear the per-cell detail without scrolling the
@@ -350,6 +359,7 @@ export function ComplianceHeatmap({
return (
(prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
+ lastPointer.current = e.pointerType;
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
+ day:
+ cell.data.expected > 0 &&
+ isOpenableDay(cell.dateKey, today)
+ ? cell.dateKey
+ : undefined,
});
}}
+ onClick={() => {
+ if (lastPointer.current === "touch") return;
+ if (cell.data.expected === 0) return;
+ if (!isOpenableDay(cell.dateKey, today)) return;
+ setTooltip(null);
+ openDay(cell.dateKey, { trigger: containerRef.current });
+ }}
/>
);
})}
@@ -408,7 +431,10 @@ export function ComplianceHeatmap({
{/* Tooltip */}
{tooltip && (
{tooltip.text}
+ {tooltip.day ? (
+
+ ) : null}
)}
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 89d340ed8..06d7050fd 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -77,6 +77,16 @@ import {
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
+import {
+ ChartDayCaption,
+ DayRug,
+ TooltipDayAction,
+ chartPointDayKey,
+ openChartDay,
+ useCoarsePointer,
+} from "@/components/day/chart-day";
+import { useOpenDay } from "@/components/day/day-layer-controller";
+import { isWholeNumberType } from "@/components/day/use-day-value-format";
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
// "the last 7 days", not "the last 7 readings". The labels said "points"
@@ -321,6 +331,15 @@ interface HealthChartProps {
* bury the dashboard it is meant to summarise.
*/
showDataTable?: boolean;
+ /**
+ * v1.42 — the chart is a door to its days. A click on a point opens that
+ * day over the page (on a touch screen the tooltip offers it), the open day
+ * is marked with a dashed line, and a row of dots under the axis marks the
+ * days that hold anything. Only while the chart is drawn in days, and never
+ * in `mini` mode: a tile's sparkline has its own destination and targets
+ * too small to hit. The metric sub-pages turn it on; the dashboard does not.
+ */
+ dayLinks?: boolean;
}
interface ChartDataPoint {
@@ -532,15 +551,22 @@ export interface ResolvedVerticalMarker {
export function resolveVerticalMarkerPositions(
markers: Array<{ date: string; label?: string; color?: string }> | undefined,
- chartData: Array<{ date: string }> | undefined,
+ chartData: Array<{ date: string; timestamp?: number }> | undefined,
): ResolvedVerticalMarker[] {
if (!markers || !chartData || chartData.length === 0) return [];
const indexByDate = new Map();
for (const [i, point] of chartData.entries()) {
// Last-write-wins — multiple bucket-aggregated points should never
// share the same day key, but defensively keep the latest if they
- // do.
- indexByDate.set(point.date, i);
+ // do. A drawn point's `date` is its display label ("12.03."), so the
+ // day key comes from its timestamp, which a daily point holds at noon
+ // UTC of its day; a bare `{ date }` (the pure-helper tests) is the key.
+ indexByDate.set(
+ typeof point.timestamp === "number"
+ ? chartPointDayKey(point.timestamp)
+ : point.date,
+ i,
+ );
}
const out: ResolvedVerticalMarker[] = [];
for (const marker of markers) {
@@ -656,6 +682,7 @@ export function HealthChart({
preloadedSeries,
preloadedCoverageDays,
showDataTable = false,
+ dayLinks = false,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
// A mount without the prop used to pin Europe/Berlin, so those charts
@@ -1702,6 +1729,46 @@ export function HealthChart({
chartData,
);
+ // v1.42 — the chart as a door to its days (`dayLinks`). Daily points only:
+ // a week or month point averages many days and has no one day to open.
+ const dayLinksActive = dayLinks && !mini && activeBucket === "day";
+ const openDayKey = useOpenDay();
+ const coarsePointer = useCoarsePointer();
+ const plotRef = useRef(null);
+ const openDayIndex =
+ dayLinksActive && openDayKey
+ ? resolveVerticalMarkerPositions([{ date: openDayKey }], chartData)[0]
+ ?.pointIndex
+ : undefined;
+ const openDayPoint =
+ openDayIndex !== undefined ? chartData?.[openDayIndex] : undefined;
+ // What the day shows at its top when it is opened from a point: the
+ // chart's own name and the point's value as the tooltip reads it.
+ const dayFocusFor = (point: ChartDataPoint) => {
+ const parts = types
+ .map((type) => ({ type, v: point[type] }))
+ .filter((p): p is { type: string; v: number } => typeof p.v === "number");
+ if (parts.length === 0) return null;
+ return {
+ label: title,
+ // A pressure or a pulse reads in whole numbers on the day, as in every
+ // list; the rest keep the tooltip's one decimal.
+ value: parts
+ .map(({ type, v }) =>
+ valueMode === "raw" && isWholeNumberType(type)
+ ? fmt.number(Math.round(v), 0)
+ : formatTooltipValue(v),
+ )
+ .join("/"),
+ unit,
+ types: valueMode === "raw" ? types : [],
+ };
+ };
+ const openPointDay = (point: ChartDataPoint | undefined) => {
+ if (!point) return;
+ openChartDay(point.timestamp, dayFocusFor(point), plotRef.current);
+ };
+
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -1958,7 +2025,11 @@ export function HealthChart({
// gate on; this slot is rendered by the data branch only, so it
// says the same thing without depending on Recharts' markup.
data-slot="chart-plot"
- className="relative z-10 h-full touch-pan-y"
+ data-day-links={dayLinksActive ? "true" : undefined}
+ ref={plotRef}
+ className={`relative z-10 h-full touch-pan-y ${
+ dayLinksActive && !coarsePointer ? "cursor-pointer" : ""
+ }`}
role="img"
aria-label={chartAriaLabel}
>
@@ -1967,6 +2038,17 @@ export function HealthChart({
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
accessibilityLayer
+ onClick={
+ dayLinksActive && !coarsePointer
+ ? (state) => {
+ const index = Number(state?.activeTooltipIndex);
+ if (!Number.isInteger(index)) return;
+ openPointDay(
+ (chartDataWithCompare ?? chartData)?.[index],
+ );
+ }
+ : undefined
+ }
>
))}
+ {/* v1.42 — the open day: a dashed line through it and a
+ ring on each of its values, so the point the day panel
+ describes stays in sight beside it. */}
+ {openDayIndex !== undefined ? (
+
+ ) : null}
+ {openDayIndex !== undefined && openDayPoint
+ ? types.map((type, i) => {
+ const v = openDayPoint[type];
+ if (typeof v !== "number") return null;
+ return (
+
+ );
+ })
+ : null}
{/* v1.4.18 — personal-baseline reference line is now
opt-in via the Trend toggle. the maintainer rejected the
always-on dashed mean line; it now only paints when
@@ -2166,6 +2279,16 @@ export function HealthChart({
})}
openPointDay(hoverPoint)}
+ />
+ ) : undefined
+ }
/>
);
}}
@@ -2444,6 +2574,18 @@ export function HealthChart({
) : null}
+ {dayLinksActive && chartData && chartData.length > 0 ? (
+ <>
+
+
+ >
+ ) : null}
{/* The points the chart just drew, as a table. Reads the SAME
expression `` is handed above, so the two
cannot disagree. Sits inside this branch on purpose: an empty
@@ -2459,6 +2601,7 @@ export function HealthChart({
formatDate={tzFmt.date}
bucket={visibleSlice?.bucketType ?? "day"}
metricLabel={getTypeLabel(primaryType, valueMode, t)}
+ dayLinks={dayLinksActive}
/>
) : null}
>
diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx
index 6e0276ea8..39841e476 100644
--- a/src/components/charts/mood-heatmap.tsx
+++ b/src/components/charts/mood-heatmap.tsx
@@ -1,5 +1,8 @@
"use client";
+import { openDay } from "@/components/day/day-layer-controller";
+import { isOpenableDay } from "@/components/day/day-url";
+import { useTodayKey } from "@/components/day/use-today-key";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { useState, useMemo, useEffect, useRef } from "react";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
@@ -75,7 +78,13 @@ export function MoodHeatmap({
y: number;
text: string;
pinned?: boolean;
+ /** v1.42 — the day a pinned (touch) tooltip offers to open. */
+ day?: string;
} | null>(null);
+ // v1.42 — a click opens the day on a fine pointer; a tap pins the tooltip,
+ // which then offers the day. The pointer type of the press decides.
+ const lastPointer = useRef("mouse");
+ const today = useTodayKey();
useEffect(() => {
if (!tooltip?.pinned) return;
@@ -299,6 +308,7 @@ export function MoodHeatmap({
// quiet `--secondary` empty-state tint.
fillOpacity={1}
className="cursor-pointer"
+ data-day={cell.dateKey}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
@@ -318,14 +328,26 @@ export function MoodHeatmap({
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
+ lastPointer.current = e.pointerType;
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
+ day:
+ cell.cell !== null && isOpenableDay(cell.dateKey, today)
+ ? cell.dateKey
+ : undefined,
});
}}
+ onClick={() => {
+ if (lastPointer.current === "touch") return;
+ if (cell.cell === null) return;
+ if (!isOpenableDay(cell.dateKey, today)) return;
+ setTooltip(null);
+ openDay(cell.dateKey, { trigger: containerRef.current });
+ }}
/>
);
})}
@@ -346,7 +368,10 @@ export function MoodHeatmap({
{tooltip && (
+ {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/stream-events.ts b/src/lib/ai/coach/stream-events.ts
index e228a7496..a3ec1c57a 100644
--- a/src/lib/ai/coach/stream-events.ts
+++ b/src/lib/ai/coach/stream-events.ts
@@ -109,6 +109,13 @@ export const coachStepSchema = z
resultRef: resultRefSchema
.optional()
.describe("The table this step produced (`r1`..), when it produced one."),
+ day: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
+ .optional()
+ .describe(
+ "The calendar day a `get_day` step read, `YYYY-MM-DD`. A settled answer lists these days as links to each day; set only on `get_day` steps.",
+ ),
})
.meta({
id: "CoachStep",
diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts
index 7c9307d1f..7626b014c 100644
--- a/src/lib/ai/coach/types.ts
+++ b/src/lib/ai/coach/types.ts
@@ -521,6 +521,12 @@ export interface CoachStep {
reason?: CoachStepReason;
/** `r1` when the step produced a table. */
resultRef?: string;
+ /**
+ * v1.42 — the calendar day (`YYYY-MM-DD`) a `get_day` step read. A settled
+ * answer lists the days its tools read as chips that open each day; the
+ * prose is never parsed for dates. Set only on `get_day` steps.
+ */
+ day?: string;
}
// ── Results (v1.39.4) ───────────────────────────────────────────────────