diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts new file mode 100644 index 000000000..af3b4c2ea --- /dev/null +++ b/e2e/day-doors.spec.ts @@ -0,0 +1,291 @@ +import type { Locator, Page, TestInfo } from "@playwright/test"; + +import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture"; +import { mockDay } from "./setup/day-mock"; +import { + DAY_DOORS_STORAGE_STATE_PATH, + MOBILE_ROUTES_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** + * v1.42 — every chart drawn in days opens its days, the same way. + * + * The day view first reached six metric pages; the recovery page beside them + * offered no day at all. These journeys walk the surfaces that came after, + * each through the same door: a click on a point (or a calendar cell) opens + * the day beside the page, and on a touch screen a tap does the same as a + * bottom sheet, the tapped value on top. The dashed line (or outline) marks + * the open day, the row of dots and the caption sit under every chart, and + * the data table's dates are the keyboard way to the same days. + * + * - recovery (the page the beta reported), sleep, a lab marker, a chart + * with its comparison period on and a dashboard chart: line charts; + * - the mood calendar: a heatmap; + * - a metric's "all values" list: a date in a row. + * + * The day itself is a route mock in the contract's shape, as in + * `day-view.spec.ts`: these journeys pin the client's doors, not the day. + * Stable data attributes only. + */ + +const panel = (page: Page) => page.locator('[data-slot="day-panel"]'); +const isPhone = (testInfo: TestInfo) => + testInfo.project.name === "chromium-mobile"; + +/** Open the day of the point at 80 % across a day-linked plot. */ +async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) { + await plot.scrollIntoViewIfNeeded(); + await expect(plot).toHaveAttribute("data-day-links", "true"); + const box = (await plot.boundingBox())!; + const x = box.x + box.width * 0.8; + const y = box.y + box.height / 2; + if (isPhone(testInfo)) { + // One tap, the same door as the click: no tooltip step in between. + await page.touchscreen.tap(x, y); + await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + } else { + await page.mouse.move(x, y); + await page.mouse.click(x, y); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + } + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + await expect(page.locator('[data-slot="day-view"]')).toBeVisible(); +} + +test.describe("every chart drawn in days opens its days", () => { + test.use({ storageState: DAY_DOORS_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, { index: "full" }); + }); + + test("recovery: a point opens its day, and the open day is marked", async ({ + page, + }, testInfo) => { + if (!isPhone(testInfo)) { + await page.setViewportSize({ width: 1440, height: 900 }); + } + await page.goto("/insights/recovery"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + // Under the chart: the caption and the row of day dots, which draws the + // days the index names, one of them ringed as notable. + const rug = page.locator('[data-slot="day-rug"]').first(); + await expect( + page.locator('[data-slot="chart-day-caption"]').first(), + ).toBeVisible(); + await expect(rug.locator("[data-day]").first()).toBeAttached(); + await expect(rug.locator('[data-notable="true"]')).toHaveCount(1); + + await openFromPlot(page, plot, testInfo); + const day = await page + .locator('[data-slot="day-view"]') + .getAttribute("data-day"); + // The value the person came from heads the day. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + // The open day's dot is the filled one. + await expect(rug.locator(`[data-day="${day}"]`)).toHaveAttribute( + "data-open", + "true", + ); + }); + + test("recovery: the data table's dates are the keyboard way to a day", async ({ + page, + }, testInfo) => { + test.skip(isPhone(testInfo), "keyboard path"); + await page.goto("/insights/recovery"); + const toggle = page + .locator('[data-slot="chart-data-table-toggle"]') + .first(); + await toggle.scrollIntoViewIfNeeded(); + await toggle.focus(); + await page.keyboard.press("Enter"); + const link = page + .locator('[data-slot="chart-data-table-region"] [data-slot="day-link"]') + .first(); + await link.focus(); + const day = await link.getAttribute("data-day"); + await page.keyboard.press("Enter"); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + // Escape closes the day and gives the keyboard back to the date. + await page.keyboard.press("Escape"); + await expect(panel(page)).toHaveCount(0); + }); + + test("sleep: a night's point opens its day", async ({ page }, testInfo) => { + await page.goto("/insights/sleep"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await openFromPlot(page, plot, testInfo); + }); + + test("the mood calendar: a logged day's cell opens it", async ({ + page, + }, testInfo) => { + await page.goto("/insights/mood"); + // A logged, openable cell: it carries the pointer cursor. + const cell = page.locator("rect.cursor-pointer[data-day]").last(); + await cell.scrollIntoViewIfNeeded(); + await expect(cell).toBeVisible(); + const day = await cell.getAttribute("data-day"); + if (isPhone(testInfo)) { + await cell.tap(); + await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + } else { + await cell.click(); + } + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + // The open day's cell is outlined. + await expect(page.locator('[data-slot="heatmap-open-day"]')).toHaveCount(1); + }); + + test("a lab marker's reading opens the day it was taken", async ({ + page, + }, testInfo) => { + await page.goto("/labs"); + await page + .locator('[data-slot="lab-list-analyte"]') + .filter({ hasText: DAY_DOORS_ANALYTE }) + .first() + .click(); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await openFromPlot(page, plot, testInfo); + // The reading on top, with the one before it beside it. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); + + test("a chart with its comparison period on still opens its days", async ({ + page, + }, testInfo) => { + await page.goto("/insights/weight"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await page + .locator('[data-slot="chart-overlay-controls-trigger"]') + .first() + .click(); + await page + .locator('[data-slot="chart-overlay-comparison-lastMonth"]') + .click(); + await page.keyboard.press("Escape"); + await expect( + page.locator('[data-slot="chart-compare-caption"]').first(), + ).toBeAttached(); + await openFromPlot(page, plot, testInfo); + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); +}); + +test.describe("the dashboard and the list of all values", () => { + test.use({ storageState: DAY_DOORS_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, { index: "full" }); + }); + + test("a dashboard chart opens its day; the today hero offers none", async ({ + page, + }, testInfo) => { + if (!isPhone(testInfo)) { + await page.setViewportSize({ width: 1440, height: 900 }); + } + await page.goto("/"); + await expect( + page.locator( + '[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]', + ), + ).toHaveCount(0); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible({ timeout: 20_000 }); + await openFromPlot(page, plot, testInfo); + // Closing leaves the dashboard on today. + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/$/); + }); + + test("a date in a metric's list of all values opens its day", async ({ + page, + }, testInfo) => { + await page.goto("/insights/values/WEIGHT"); + const link = page.locator('[data-slot="day-link"]:visible').first(); + await expect(link).toBeVisible(); + const day = await link.getAttribute("data-day"); + if (isPhone(testInfo)) await link.tap(); + else await link.click(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + }); + + test("the docked day moves the Coach button off its footer", async ({ + page, + }, testInfo) => { + test.skip(isPhone(testInfo), "docked from 1280 px"); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/insights/weight"); + const fab = page.locator('[data-slot="coach-fab"]'); + test.skip((await fab.count()) === 0, "no Coach on this account"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await openFromPlot(page, plot, testInfo); + const panelBox = (await panel(page).boundingBox())!; + // Wait out the 150 ms move, then the launcher sits left of the column. + await expect + .poll(async () => { + const b = (await fab.boundingBox())!; + return b.x + b.width; + }) + .toBeLessThanOrEqual(panelBox.x); + }); +}); + +test.describe("the readiness link into a medication's edit form", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test("?edit=1 opens the medication's edit form and leaves the URL", async ({ + page, + }) => { + const res = await page.request.get("/api/medications"); + const body = (await res.json()) as { + data: Array<{ id: string }> | { medications?: Array<{ id: string }> }; + }; + const list = Array.isArray(body.data) + ? body.data + : (body.data.medications ?? []); + expect(list.length).toBeGreaterThan(0); + await page.goto(`/medications/${list[0]!.id}?edit=1`); + await expect(page.getByRole("dialog")).toBeVisible(); + // A reload after closing the form must not open it again. + await expect(page).not.toHaveURL(/[?&]edit=1/); + }); +}); diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index b0439aec8..43806f074 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -1,5 +1,6 @@ -import type { Page, Route } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { mockDay } from "./setup/day-mock"; 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"; @@ -14,103 +15,20 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; * 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; + * - on a touch screen a tap on a point opens the day at once, as a bottom + * sheet with the tapped value on top (the maintainer's call on the beta: + * the two-step tooltip broke the flow); * - 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. + * - the dashboard's today area offers no door; its charts below do. * * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`, - * `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` / + * `chart-plot[data-day-links]`, `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 { @@ -233,7 +151,7 @@ test.describe("the day view", () => { await expect(page).toHaveURL(/\/mood$/); }); - test("on a phone the first tap shows the value, the second opens the day", async ({ + test("on a phone a tap on a point opens its day at once", async ({ page, }, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "touch only"); @@ -244,14 +162,12 @@ test.describe("the day view", () => { await plot.scrollIntoViewIfNeeded(); await expect(plot).toBeVisible(); const box = (await plot.boundingBox())!; + // One tap, the same door as a click on a desktop: the day opens as a + // bottom sheet with the tapped value on top. 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"); + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); }); test("the dashboard's today area offers no day door", async ({ page }) => { @@ -259,8 +175,15 @@ test.describe("the day view", () => { await expect( page.locator('[data-slot="main-content-wrapper"]'), ).toBeVisible(); + // The hero is today: nothing in it opens another day. (The charts + // further down do; `day-doors.spec.ts` walks one.) await expect( - page.locator('[data-slot="chart-plot"][data-day-links="true"]'), + page.locator('[data-slot^="today-hero"]').first(), + ).toBeVisible(); + await expect( + page.locator( + '[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]', + ), ).toHaveCount(0); }); }); diff --git a/e2e/setup/day-doors-fixture.ts b/e2e/setup/day-doors-fixture.ts new file mode 100644 index 000000000..ddc7b5382 --- /dev/null +++ b/e2e/setup/day-doors-fixture.ts @@ -0,0 +1,90 @@ +/** + * The record `day-doors.spec.ts` opens days from. + * + * Every chart the journey clicks needs points to click: ten days of the + * autonomic charge (the recovery page), of sleep and of mood (the sleep + * chart, the mood line and the mood calendar), weight over the last ten days + * and the same ten days a month earlier (the comparison chart draws a prior + * period only where one exists), and two readings of one lab marker. + * + * Written through the app's own routes, once, from `globalSetup`. An account + * that already holds the lab marker is taken as seeded, so a local re-run + * against a reused database does not double every row. + */ +import { request as playwrightRequest } from "@playwright/test"; + +export const DAY_DOORS_ANALYTE = "Ferritin"; + +export async function seedDayDoorsRecord( + baseURL: string, + storageStatePath: string, +): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: storageStatePath, + }); + try { + const labs = await ctx.get("/api/labs"); + const body = (await labs.json()) as { data: unknown }; + if (JSON.stringify(body.data ?? null).includes(DAY_DOORS_ANALYTE)) return; + + const post = async (path: string, data: unknown): Promise => { + const res = await ctx.post(path, { data }); + if (res.status() === 409 || res.ok()) return; + throw new Error( + `[day-doors-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`, + ); + }; + const daysAgo = (days: number, hour = 8) => { + const at = new Date(Date.now() - days * 86_400_000); + at.setUTCHours(hour, 0, 0, 0); + return at.toISOString(); + }; + + for (let day = 1; day <= 10; day += 1) { + await post("/api/measurements", { + type: "ANS_CHARGE", + value: 5 + day, + measuredAt: daysAgo(day, 5), + }); + await post("/api/measurements", { + type: "SLEEP_DURATION", + value: 400 + day * 4, + measuredAt: daysAgo(day, 6), + }); + await post("/api/measurements", { + type: "WEIGHT", + value: 72 + day / 10, + measuredAt: daysAgo(day, 7), + }); + // The same ten days, a month before: the prior period the comparison + // draws beside them. + await post("/api/measurements", { + type: "WEIGHT", + value: 73 + day / 10, + measuredAt: daysAgo(day + 30, 7), + }); + await post("/api/mood-entries", { + mood: day % 2 ? "GUT" : "OKAY", + moodLoggedAt: daysAgo(day, 19), + }); + } + + for (const [days, value] of [ + [60, 41], + [4, 58], + ] as const) { + await post("/api/labs", { + analyte: DAY_DOORS_ANALYTE, + panel: "Eisenstoffwechsel", + value, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 300, + takenAt: daysAgo(days), + }); + } + } finally { + await ctx.dispose(); + } +} diff --git a/e2e/setup/day-mock.ts b/e2e/setup/day-mock.ts new file mode 100644 index 000000000..cde790c4d --- /dev/null +++ b/e2e/setup/day-mock.ts @@ -0,0 +1,115 @@ +/** + * The day routes, answered in the contract's shape. + * + * The day itself (`GET /api/day/{date}`) and the index (`GET /api/day/index`) + * are route mocks, so a journey pins the client: which door opens which day, + * what the history does, where no door is. The server's own answers are + * pinned by the integration suite. + */ +import type { Page, Route } from "@playwright/test"; + +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: {}, + }; +} + +/** + * Every day from `from` to `to`, each holding values: what the row of day + * dots under a chart reads when the whole window has entries. + */ +function everyDay(from: string, to: string): Record { + const days: Record = {}; + if (!from || !to) return days; + const at = new Date(`${from}T12:00:00.000Z`); + const end = new Date(`${to}T12:00:00.000Z`); + while (at <= end) { + days[at.toISOString().slice(0, 10)] = ["values"]; + at.setUTCDate(at.getUTCDate() + 1); + } + return days; +} + +export async function mockDay( + page: Page, + options: { index?: "empty" | "full" } = {}, +) { + 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") ?? ""; + const to = url.searchParams.get("to") ?? ""; + const days = options.index === "full" ? everyDay(from, to) : {}; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + from, + to, + days, + // The first day of the window stands out: the rug draws a ring. + notable: Object.keys(days).slice(0, 1), + }, + 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 }), + }); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 2b0b6d675..f3ed47952 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,6 +33,7 @@ import { } from "@playwright/test"; import pg from "pg"; +import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; // Resolve relative to the project root rather than __dirname/import.meta.url — @@ -249,6 +250,26 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateTimeline.json", ); +/** + * The day-doors journey's own account (`day-doors.spec.ts`). + * + * It turns a chart's comparison overlay on, which is a per-account + * preference; on a shared account a parallel spec would find its chart + * drawing a prior period it never asked for. Its record carries the series + * the journey clicks (recovery, sleep, mood, weight, a lab marker). + */ +export const E2E_DAY_DOORS = { + email: "e2e-day-doors@healthlog.test", + username: "e2e-day-doors", + password: "Dy4!Rk8wQm2vTz6P", + role: "USER", +} as const; + +export const DAY_DOORS_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateDayDoors.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -950,6 +971,30 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The day-doors journey's account. Every module the journey reaches is + // on; its record is written after the login capture. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL`, + [ + cuid(), + E2E_DAY_DOORS.username, + E2E_DAY_DOORS.email, + await hashPassword(E2E_DAY_DOORS.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1556,6 +1601,10 @@ export default async function globalSetup(config: FullConfig): Promise { // The timeline journey's jar — see `E2E_TIMELINE`. await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH); + // The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`. + await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); + await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/messages/de.json b/messages/de.json index b9b9399de..2aa8f3350 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12201,11 +12201,11 @@ "captureShort": "Erfassen", "inTimelineShort": "Zeitleiste", "askCoachPrompt": "Was war am {date}?", - "chartHintTouch": "Antippen zeigt den Wert.", + "chartHintTouch": "Antippen eines Punkts öffnet den Tag.", "chartHintBar": "Klick auf einen Balken öffnet den Tag.", - "chartHintTouchBar": "Antippen zeigt die Werte.", + "chartHintTouchBar": "Antippen eines Balkens öffnet den Tag.", "chartHintCell": "Klick auf einen Tag öffnet ihn.", - "chartHintTouchCell": "Antippen zeigt den Wert des Tages.", + "chartHintTouchCell": "Antippen eines Tages öffnet ihn.", "coachChips": "Tage in dieser Antwort", "description": "Alles, was deine Einträge für diesen Tag enthalten.", "openDocument": "Dokument öffnen", diff --git a/messages/en.json b/messages/en.json index 7efb7a78b..f5e55df8b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12201,11 +12201,11 @@ "captureShort": "Log", "inTimelineShort": "Timeline", "askCoachPrompt": "What happened on {date}?", - "chartHintTouch": "Tap a point to see its value.", + "chartHintTouch": "Tap a point to open its day.", "chartHintBar": "Click a bar to open its day.", - "chartHintTouchBar": "Tap a bar to see its values.", + "chartHintTouchBar": "Tap a bar to open its day.", "chartHintCell": "Click a day to open it.", - "chartHintTouchCell": "Tap a day to see its value.", + "chartHintTouchCell": "Tap a day to open it.", "coachChips": "Days in this answer", "description": "Everything your records hold for this day.", "openDocument": "Open document", diff --git a/messages/es.json b/messages/es.json index e38b9e1a8..2a458aa35 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12201,11 +12201,11 @@ "captureShort": "Registrar", "inTimelineShort": "Cronología", "askCoachPrompt": "¿Qué pasó el {date}?", - "chartHintTouch": "Toca un punto para ver su valor.", + "chartHintTouch": "Toca un punto para abrir su día.", "chartHintBar": "Haz clic en una barra para abrir su día.", - "chartHintTouchBar": "Toca una barra para ver sus valores.", + "chartHintTouchBar": "Toca una barra para abrir su día.", "chartHintCell": "Haz clic en un día para abrirlo.", - "chartHintTouchCell": "Toca un día para ver su valor.", + "chartHintTouchCell": "Toca un día para abrirlo.", "coachChips": "Días en esta respuesta", "description": "Todo lo que tus registros contienen para este día.", "openDocument": "Abrir documento", diff --git a/messages/fr.json b/messages/fr.json index 9070b5fdc..70d2bd366 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12201,11 +12201,11 @@ "captureShort": "Enregistrer", "inTimelineShort": "Chronologie", "askCoachPrompt": "Que s’est-il passé le {date} ?", - "chartHintTouch": "Touchez un point pour voir sa valeur.", + "chartHintTouch": "Touchez un point pour ouvrir son jour.", "chartHintBar": "Cliquez sur une barre pour ouvrir son jour.", - "chartHintTouchBar": "Touchez une barre pour voir ses valeurs.", + "chartHintTouchBar": "Touchez une barre pour ouvrir son jour.", "chartHintCell": "Cliquez sur un jour pour l’ouvrir.", - "chartHintTouchCell": "Touchez un jour pour voir sa valeur.", + "chartHintTouchCell": "Touchez un jour pour l’ouvrir.", "coachChips": "Jours dans cette réponse", "description": "Tout ce que vos entrées contiennent pour ce jour.", "openDocument": "Ouvrir le document", diff --git a/messages/it.json b/messages/it.json index 7da2126a2..9dfef1da6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12201,11 +12201,11 @@ "captureShort": "Registra", "inTimelineShort": "Cronologia", "askCoachPrompt": "Cosa è successo il {date}?", - "chartHintTouch": "Tocca un punto per vederne il valore.", + "chartHintTouch": "Tocca un punto per aprire il suo giorno.", "chartHintBar": "Fai clic su una barra per aprire il suo giorno.", - "chartHintTouchBar": "Tocca una barra per vederne i valori.", + "chartHintTouchBar": "Tocca una barra per aprire il suo giorno.", "chartHintCell": "Fai clic su un giorno per aprirlo.", - "chartHintTouchCell": "Tocca un giorno per vederne il valore.", + "chartHintTouchCell": "Tocca un giorno per aprirlo.", "coachChips": "Giorni in questa risposta", "description": "Tutto ciò che le tue voci contengono per questo giorno.", "openDocument": "Apri documento", diff --git a/messages/ko.json b/messages/ko.json index ccd0eb935..484effaaa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12201,11 +12201,11 @@ "captureShort": "기록", "inTimelineShort": "타임라인", "askCoachPrompt": "{date}에 무슨 일이 있었나요?", - "chartHintTouch": "점을 탭하면 값이 보여요.", + "chartHintTouch": "점을 탭하면 그날이 열려요.", "chartHintBar": "막대를 클릭하면 그날이 열려요.", - "chartHintTouchBar": "막대를 탭하면 값이 보여요.", + "chartHintTouchBar": "막대를 탭하면 그날이 열려요.", "chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.", - "chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.", + "chartHintTouchCell": "날짜 칸을 탭하면 그날이 열려요.", "coachChips": "이 답변에 쓰인 날", "description": "이 날에 대한 모든 기록이에요.", "openDocument": "문서 열기", diff --git a/messages/pl.json b/messages/pl.json index 032c99b02..d07ef24d2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12201,11 +12201,11 @@ "captureShort": "Zapisz", "inTimelineShort": "Oś czasu", "askCoachPrompt": "Co się działo {date}?", - "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.", + "chartHintTouch": "Dotknij punktu, aby otworzyć jego dzień.", "chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.", - "chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.", + "chartHintTouchBar": "Dotknij słupka, aby otworzyć jego dzień.", "chartHintCell": "Kliknij dzień, aby go otworzyć.", - "chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.", + "chartHintTouchCell": "Dotknij dnia, aby go otworzyć.", "coachChips": "Dni w tej odpowiedzi", "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.", "openDocument": "Otwórz dokument", diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index dbc186d1a..615e5214c 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1693,6 +1693,7 @@ export default function DashboardPageClient({ compareBaseline={compareBaseline} userTimezone={user?.timezone} valueScale={unitDisplay.transformFor("WEIGHT").factor} + dayLinks /> ), }); @@ -1746,6 +1747,7 @@ export default function DashboardPageClient({ }, ]} compareBaseline={compareBaseline} + dayLinks /> ), }); @@ -1772,6 +1774,7 @@ export default function DashboardPageClient({ targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); @@ -1800,6 +1803,7 @@ export default function DashboardPageClient({ valueBands={hasRestingHr ? pulseBands : undefined} compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); @@ -1824,6 +1828,7 @@ export default function DashboardPageClient({ valueBands={bodyFatBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); @@ -1840,6 +1845,7 @@ export default function DashboardPageClient({ onDataReady={() => markChartReady("mood-chart")} compareBaseline={compareBaseline} chartKey="mood" + dayLinks /> ), }); @@ -1861,6 +1867,7 @@ export default function DashboardPageClient({ unit="h" compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); @@ -1883,6 +1890,7 @@ export default function DashboardPageClient({ colors={["var(--success)"]} compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); @@ -1903,6 +1911,7 @@ export default function DashboardPageClient({ onDataReady={() => markChartReady("medications")} compareBaseline={compareBaseline} userTimezone={user?.timezone} + dayLinks /> ), }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 3f539c146..c31fe7eca 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -2404,11 +2404,6 @@ export function HealthChart({ : dateLabel } rows={rows} - action={chartDays.tooltipAction( - hoverPoint - ? dayLinkSource?.indexOf(hoverPoint) - : undefined, - )} /> ); }} diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index fa5aefa0e..4fb12ca5d 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -70,6 +70,12 @@ import { chooseTickInterval } from "@/lib/charts/x-axis-density"; import { apiGet } from "@/lib/api/api-fetch"; import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { chartPointDayKey } from "@/components/day/chart-day"; +import { + ChartDayFooter, + OPEN_DAY_LINE, + useChartDayLinks, +} from "@/components/day/chart-day-links"; interface DailyCompliancePoint { /** Berlin calendar day, "YYYY-MM-DD". */ @@ -205,6 +211,11 @@ interface MedicationComplianceChartProps { * Optional and repeat-safe. */ onDataReady?: () => void; + /** + * v1.42 — each day's point opens that day, through the doors every + * day-linked chart has (`useChartDayLinks`). + */ + dayLinks?: boolean; } export function MedicationComplianceChart({ @@ -212,6 +223,7 @@ export function MedicationComplianceChart({ userTimezone = DEFAULT_TIMEZONE, compareBaseline, onDataReady, + dayLinks = false, }: MedicationComplianceChartProps) { // v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass // `compareBaseline={compareBaseline}` uniformly across every chart. @@ -343,6 +355,17 @@ export function MedicationComplianceChart({ }; const displayTitle = title ?? t("dashboard.medications"); + + const chartDays = useChartDayLinks({ + enabled: dayLinks && chartData.length >= 3, + days: chartData.map((point) => chartPointDayKey(point.timestamp)), + focusFor: (index) => { + const point = chartData[index]; + return point + ? { label: displayTitle, value: `${fmt.integer(point.rate)} %` } + : null; + }, + }); const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`; const animationsEnabled = !prefersReducedMotion(); @@ -535,121 +558,145 @@ export function MedicationComplianceChart({ /> ) ) : ( -
- - - - - - { - const { active, payload } = props as unknown as { - active?: boolean; - payload?: Array<{ - value?: number; - color?: string; - payload?: ChartPoint; - }>; - }; - if (!active || !payload?.length) return null; - const ts = payload[0]?.payload?.timestamp; - const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; - const rate = payload[0]?.value; - if (typeof rate !== "number") return null; - // Delta vs. the 100 % goal — a positive delta means - // "100 % target hit" and reads as success; a - // negative delta is the gap to close. - const gap = 100 - rate; - let delta: string | undefined; - if (gap < 0.5) { - delta = t("charts.deltaUnchanged"); - } else { - const formatted = `−${fmt.integer(gap)} pp`; - delta = t("charts.deltaVsTarget").replace( - "{delta}", - formatted, + <> +
+ + + + + + {chartDays.openIndex !== undefined ? ( + + ) : null} + { + const { active, payload } = props as unknown as { + active?: boolean; + payload?: Array<{ + value?: number; + color?: string; + payload?: ChartPoint; + }>; + }; + if (!active || !payload?.length) return null; + const ts = payload[0]?.payload?.timestamp; + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; + const rate = payload[0]?.value; + if (typeof rate !== "number") return null; + // Delta vs. the 100 % goal — a positive delta means + // "100 % target hit" and reads as success; a + // negative delta is the gap to close. + const gap = 100 - rate; + let delta: string | undefined; + if (gap < 0.5) { + delta = t("charts.deltaUnchanged"); + } else { + const formatted = `−${fmt.integer(gap)} pp`; + delta = t("charts.deltaVsTarget").replace( + "{delta}", + formatted, + ); + } + const rows: RichTooltipRow[] = [ + { + name: t("dashboard.compliance7d"), + value: `${fmt.integer(rate)} %`, + color: payload[0]?.color ?? COLOR_LINE, + delta, + }, + ]; + return ( + ); - } - const rows: RichTooltipRow[] = [ - { - name: t("dashboard.compliance7d"), - value: `${fmt.integer(rate)} %`, - color: payload[0]?.color ?? COLOR_LINE, - delta, - }, - ]; - return ( - - ); - }} - /> - {/* v1.4.18 — minimum-acceptable threshold + goal line + }} + /> + {/* v1.4.18 — minimum-acceptable threshold + goal line are now opt-in via the "Target range" overlay toggle. Default OFF; chart renders as a clean line until the user activates the overlay from the settings popover. */} - {showTargetRange ? ( - <> - - - - ) : null} - - - -
+ {showTargetRange ? ( + <> + + + + ) : null} + +
+
+
+ + )} ); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index eeb46aec8..5f0371278 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -1015,16 +1015,7 @@ export function MoodChart({ } } if (rows.length === 0) return null; - return ( - - ); + return ; }} /> ); }} diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index e35a77dc8..af828a9da 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -239,7 +239,6 @@ export function CustomMetricChart({ active label={fmt.dateShortSmart(new Date(point.timestamp))} rows={rows} - action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index b29454d08..9e62520c2 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -24,7 +24,7 @@ * it surfaces a "log your morning temperature" hint rather than a flat dot. */ -import { useMemo, type ReactNode } from "react"; +import { useMemo } from "react"; import { Thermometer } from "lucide-react"; import { CartesianGrid, @@ -288,20 +288,7 @@ export function BbtChart({ strokeOpacity: 0.3, strokeDasharray: "3 3", }} - content={(props) => { - const payload = props.payload as unknown as - { payload: BbtPoint }[] | undefined; - const point = payload?.[0]?.payload; - return ( - - ); - }} + content={} /> {ovulationMs != null ? ( ) : null} - {props.action ?? null} ); } diff --git a/src/components/day/__tests__/day-door-coverage-guard.test.ts b/src/components/day/__tests__/day-door-coverage-guard.test.ts index 4dd79b891..3d5b534e8 100644 --- a/src/components/day/__tests__/day-door-coverage-guard.test.ts +++ b/src/components/day/__tests__/day-door-coverage-guard.test.ts @@ -9,17 +9,18 @@ import { describe, expect, it } from "vitest"; * The day view arrived on six metric pages and nowhere else, and the recovery * page beside them offered no day at all: nothing noticed, because every check * proved one chart. This guard walks `src/app` and `src/components` and holds - * three kinds of surface to the same doors (click, the touch tooltip's button, + * three kinds of surface to the same doors (a click or a tap opens the day, * the dashed line through the open day, the row of day dots, the caption): * * A. a mount of a chart whose doors are a prop (`HealthChart`, its dynamic - * wrapper, `MoodChart`, the nutrient bar chart) passes `dayLinks`, or is + * wrapper, `MoodChart`, the adherence chart, the nutrient bar chart) + * passes `dayLinks`, or is * a tile's `mini` chart (never a door, by the primitive's contract), or * forwards its props unchanged (a lazy wrapper); * B. a file that draws its own Recharts chart over a date axis (an * `` keyed on a date-shaped field, or `scale="time"`) calls * `useChartDayLinks` and renders its parts (`ChartDayFooter`, - * `OPEN_DAY_LINE`, the tooltip's props and action); + * `OPEN_DAY_LINE`, the tooltip's props); * C. a calendar heatmap (anything laid out by `heatmapDays`) calls * `useHeatmapDay`. * @@ -48,6 +49,7 @@ const PROP_GATED = [ "HealthChart", "HealthChartDynamic", "MoodChart", + "MedicationComplianceChart", "NutrientDailyBarChart", "NutrientDailyBarChartDynamic", ] as const; @@ -68,10 +70,6 @@ const DATE_AXIS_KEYS = [ * The integration plan's §1.2 and its table are the source of each reason. */ const NOT_HERE: Record = { - "src/app/page-client.tsx": - "The dashboard is today (decision D12): its charts are a summary of the record, and past days open as a layer over it from a date, never from the dashboard's own charts.", - "src/components/charts/medication-compliance-chart.tsx": - "Mounted on the dashboard only (see above); the adherence days open from the intake calendar on the medication insights page.", "src/components/insights/coach-panel/chat-bubble.tsx": "A Coach answer reaches its days through the chips built from the tool calls (`CoachDayChips`), never from what the answer draws or writes.", "src/components/insights/coach-panel/result-chart.tsx": @@ -197,15 +195,14 @@ function findMissingDoors(): { /\buseChartDayLinks\(/.test(text) && /\bChartDayFooter\b/.test(text) && /\bOPEN_DAY_LINE\b/.test(text) && - /\.tooltipProps\b/.test(text) && - /\.tooltipAction\(/.test(text); + /\.tooltipProps\b/.test(text); if (wired) { doors.charts += 1; doors.chartFiles.push(file); } else if (!listed) { missing.push({ file, - why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props and action)", + why: "a date-axis chart without the shared day doors (useChartDayLinks, ChartDayFooter, OPEN_DAY_LINE, tooltip props)", }); } } @@ -232,15 +229,17 @@ describe("every chart drawn in days opens its days, or says why not", () => { it("still finds the doors it is meant to find (an empty match set fails)", () => { // HealthChart on the metric pages (two mounts in the HealthKit page), // blood pressure, weight, pulse, BMI, recovery, sleep, the mood line, - // water and caffeine. - expect(doors.mounts).toBeGreaterThanOrEqual(11); + // water and caffeine; on the dashboard its seven charts, the mood line + // and the adherence chart. + expect(doors.mounts).toBeGreaterThanOrEqual(20); // HealthChart, the mood line and the nutrient bars themselves, sleep // stages, mood dimensions, lab marker, custom metric, questionnaire, - // basal temperature, dose strength, efficacy: eleven. + // basal temperature, dose strength, efficacy, the adherence chart: + // twelve. expect( doors.chartFiles.length, doors.chartFiles.join("\n"), - ).toBeGreaterThanOrEqual(11); + ).toBeGreaterThanOrEqual(12); // The mood and the intake calendars. expect(doors.heatmaps).toBeGreaterThanOrEqual(2); }); diff --git a/src/components/day/__tests__/day-not-here-guard.test.ts b/src/components/day/__tests__/day-not-here-guard.test.ts index 3b42f1a4c..bb4037924 100644 --- a/src/components/day/__tests__/day-not-here-guard.test.ts +++ b/src/components/day/__tests__/day-not-here-guard.test.ts @@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest"; * Where a date must NOT open the day (integration plan §1.2), frozen. * * - the dashboard's today area: the dashboard is today; past days open as - * a layer over it, never as date navigation inside it; - * - a tile's mini chart: too small a target, and the tile has its own - * destination (the chart side is pinned in `health-chart-day-links`); + * a layer over it, never as date navigation inside it. The dashboard's + * charts below it do open their days (the maintainer's call on the + * beta), like every chart drawn in days (`day-door-coverage-guard`); + * - a trend card: it shows the latest value and a direction, no day, and + * the whole tile leads to its metric page (the mini-chart side is + * pinned in `health-chart-day-links`); * - the cycle calendar: a tap there has meant "log this day" for years; * - model-written text (the briefing, the Coach's prose): never parsed for * dates; the Coach's days come from its tool calls as chips; @@ -64,11 +67,14 @@ describe("the day view stays out of the surfaces it must not reach", () => { expect(offenders.map((f) => relative(ROOT, f))).toEqual([]); }); - it("the dashboard's charts never ask for day links", () => { + it("the dashboard's today area hands no day door to its hero", () => { + // The charts below the hero open their days; the hero itself is today + // and stays on the list above. Pinned from the other side: the hero is + // still mounted by the dashboard, so the list entry is not stale. const dashboard = readFileSync( join(ROOT, "src/app/page-client.tsx"), "utf8", ); - expect(dashboard).not.toMatch(/\bdayLinks\b/); + expect(dashboard).toMatch(/ ({ useDay: () => ({ ...dayQuery, refetch: () => undefined }), + usePrefetchDay: () => () => undefined, })); vi.mock("next/navigation", () => ({ usePathname: () => pathname })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/day/chart-day-links.tsx b/src/components/day/chart-day-links.tsx index 12f8c70d5..5b0438f18 100644 --- a/src/components/day/chart-day-links.tsx +++ b/src/components/day/chart-day-links.tsx @@ -1,13 +1,13 @@ "use client"; -import { useCallback, useMemo, useRef, type ReactNode } from "react"; +import { useCallback, useMemo, useRef } from "react"; import type { DateKey } from "@/lib/day/contract"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { ChartDayCaption, DayRug, - TooltipDayAction, useCoarsePointer, type ChartDayAxis, type ChartDayMark, @@ -25,11 +25,11 @@ import { useTodayKey } from "./use-today-key"; * button, the dashed line through the open day and the row of dots under the * axis behave the same on every page: * - * - fine pointer: a click on the plot opens the day of the point under the - * cursor; hovering shows the tooltip as before; - * - touch: a tap shows the value and pins the tooltip, whose button - * ("View the whole day") opens the day. Two steps, so a scroll that - * grazes the chart never opens a sheet; + * - a click, or a tap, on the plot opens the day of the point under it. + * On a fine pointer hovering still shows the tooltip; on a touch screen + * the tap opens the day at once, and the day puts the tapped value at + * its top. A scroll that grazes the chart is a pan, not a tap, and opens + * nothing; * - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line) * at `openIndex`; * - `ChartDayFooter` draws the row of day dots and the one-line caption. @@ -90,13 +90,13 @@ export interface ChartDayLinks { /** Pass as the Recharts chart's `onClick`. */ onChartClick: ((state: ChartClickState | null | undefined) => void) | undefined; - /** Spread on ``: on touch it stays put and takes taps. */ - tooltipProps: { - trigger: "click" | "hover"; - wrapperStyle?: React.CSSProperties; - }; - /** The tooltip's button for point `index`, on a touch screen only. */ - tooltipAction: (index: number | undefined) => ReactNode | undefined; + /** + * Spread on ``. On touch, and under reduced motion, the tooltip + * does not glide: Recharts slides it from where it last was for 400 ms, + * which on a tap drew a box sweeping across the chart while the day was + * already opening. + */ + tooltipProps: { isAnimationActive?: boolean }; } export function useChartDayLinks({ @@ -134,17 +134,16 @@ export function useChartDayLinks({ return at === -1 ? undefined : at; }, [enabled, openKey, days]); - const onChartClick = - enabled && !coarse - ? (state: ChartClickState | null | undefined) => { - if (!state) return; - const index = indexOfClick - ? indexOfClick(state) - : Number(state.activeTooltipIndex); - if (index === null || !Number.isInteger(index)) return; - open(index); - } - : undefined; + const onChartClick = enabled + ? (state: ChartClickState | null | undefined) => { + if (!state) return; + const index = indexOfClick + ? indexOfClick(state) + : Number(state.activeTooltipIndex); + if (index === null || !Number.isInteger(index)) return; + open(index); + } + : undefined; return { active: enabled, @@ -160,16 +159,7 @@ export function useChartDayLinks({ plotClassName: enabled && !coarse ? "cursor-pointer" : "", onChartClick, tooltipProps: - enabled && coarse - ? { - trigger: "click", - wrapperStyle: { pointerEvents: "auto", zIndex: 20 }, - } - : { trigger: "hover" }, - tooltipAction: (index) => - enabled && coarse && index !== undefined && canOpen(index) ? ( - open(index)} /> - ) : undefined, + coarse || prefersReducedMotion() ? { isAnimationActive: false } : {}, }; } diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx index 8826fcb23..679fb690b 100644 --- a/src/components/day/chart-day.tsx +++ b/src/components/day/chart-day.tsx @@ -1,7 +1,6 @@ "use client"; import { useSyncExternalStore } from "react"; -import { ArrowRight } from "lucide-react"; import type { DateKey } from "@/lib/day/contract"; import { dateOnlyKey } from "@/lib/tz/date-only"; @@ -16,11 +15,10 @@ import { useTodayKey } from "./use-today-key"; /** * The day view's entry points on a value chart. * - * - On a fine pointer, a click on the plot opens the day of the point under - * the cursor; hovering keeps showing the tooltip as before. - * - On a touch screen a tap shows the value, as it always did, and the - * tooltip carries "View the whole day". Two steps on purpose: one tap - * that opened a sheet would fire on every scroll that grazed the chart. + * - A click, or a tap, on the plot opens the day of the point under it, + * with that value at the top of the day. Hovering on a fine pointer + * keeps showing the tooltip; a scroll that grazes the chart is a pan, + * not a tap, and opens nothing. * - Under the axis, a quiet row of dots marks the days that hold anything, * a ring the days with a notable observation. Decoration for the eye; the * keyboard and screen-reader way to a day is the chart's data table. @@ -59,26 +57,6 @@ export function chartPointDayKey(timestamp: number): DateKey { return dateOnlyKey(new Date(timestamp)); } -/** The tooltip's way to the day on a touch screen. */ -export function TooltipDayAction({ onOpen }: { onOpen: () => void }) { - const { t } = useTranslations(); - return ( - - ); -} - interface RugPoint { timestamp: number; } diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index d91ba729e..bb12eca77 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -51,9 +51,16 @@ interface LayerMemory { trigger: HTMLElement | null; /** The day the layer shows, as the mounted layer resolved it. */ open: DateKey | null; + /** The trigger's top edge on screen when it opened the layer. */ + triggerTop: number | null; } -const memory: LayerMemory = { focus: null, trigger: null, open: null }; +const memory: LayerMemory = { + focus: null, + trigger: null, + open: null, + triggerTop: null, +}; const listeners = new Set<() => void>(); function emit() { @@ -95,6 +102,17 @@ export function publishOpenDay(date: DateKey | null): void { emit(); } +/** + * The element that opened the layer and where it stood on screen at that + * moment, without handing it over: the docked column keeps it there while + * the page beside it reflows. + */ +export function peekDayTriggerAt(): { el: HTMLElement; top: number } | null { + return memory.trigger && memory.triggerTop !== null + ? { el: memory.trigger, top: memory.triggerTop } + : null; +} + /** The element focus returns to when the layer closes. */ export function takeDayTrigger(): HTMLElement | null { const trigger = memory.trigger; @@ -124,6 +142,9 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void { ? active : null; memory.trigger = options.trigger ?? focused ?? memory.trigger; + memory.triggerTop = memory.trigger + ? memory.trigger.getBoundingClientRect().top + : null; emit(); const { pathname, search } = currentLocation(); const href = withDayHref(pathname, search, date); diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index bb19ae540..7d6f39751 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useRef, useState, useSyncExternalStore, @@ -26,13 +27,15 @@ import { cn } from "@/lib/utils"; import { closeDay, + peekDayTriggerAt, publishOpenDay, stepDay, takeDayTrigger, useDayFocus, } from "./day-layer-controller"; -import { parseDayParam, withDayHref } from "./day-url"; +import { parseDayParam, shiftDateKey, withDayHref } from "./day-url"; import { DayView, useLongDayLabel } from "./day-view"; +import { usePrefetchDay } from "./use-day"; import { useTodayKey } from "./use-today-key"; /** @@ -125,6 +128,12 @@ function DayLayer() { ? "docked" : "sheet"; + // The docked column narrows the page beside it, and the page reflows. Keep + // what opened the day where it was on screen, through the open and the + // close, so the chart under the pointer does not jump away from it. + useKeepAnchorInView(date !== null && shell === "docked"); + + const prefetchDay = usePrefetchDay(); const focus = useDayFocus(date); const longLabel = useLongDayLabel(); const titleId = useId(); @@ -163,6 +172,13 @@ function DayLayer() { if (date === null) return; const onKey = (event: KeyboardEvent) => { if (event.defaultPrevented) return; + // Alt is the first half of the step shortcut: read both neighbours + // while the arrow is still on its way, so the step paints a day. + if (event.key === "Alt") { + prefetchDay(shiftDateKey(date, -1)); + if (date < today) prefetchDay(shiftDateKey(date, 1)); + return; + } const inside = panelRef.current?.contains(event.target as Node | null) ?? false; if (event.key === "Escape" && shell === "docked") { @@ -184,7 +200,7 @@ function DayLayer() { }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); - }, [date, shell, onStep]); + }, [date, shell, onStep, prefetchDay, today]); const live = (

{view(DockedTitle, { headerClassName: cn( @@ -254,6 +273,55 @@ function DayLayer() { ); } +/** + * Keep the element that opened the day at the same place on screen while the + * page beside the docked column reflows (the column taking or giving back its + * width). Chromium does this itself (CSS scroll anchoring); Safari does not, + * and there the chart the person clicked slid away under the pointer by the + * height the reflow added above it. Measured after layout, so where the + * browser already anchored, the correction is zero and nothing moves twice. + */ +function useKeepAnchorInView(active: boolean) { + const anchor = useRef<{ el: HTMLElement; top: number } | null>(null); + const wasActive = useRef(false); + + // Before paint, so the corrected position is the first one drawn. + useLayoutEffect(() => { + if (active === wasActive.current) return; + wasActive.current = active; + const main = document.getElementById("main-content"); + if (!main) return; + if (active) { + // Where the opener was when it was clicked, before the column came. + const opened = peekDayTriggerAt(); + anchor.current = + opened && main.contains(opened.el) + ? { el: opened.el, top: opened.top } + : null; + } + const a = anchor.current; + if (a && a.el.isConnected) { + const delta = a.el.getBoundingClientRect().top - a.top; + if (Math.abs(delta) > 1) main.scrollTop += delta; + a.top = a.el.getBoundingClientRect().top; + } + if (!active) anchor.current = null; + }, [active]); + + // While the column is open, the page may scroll; the anchor follows. + useEffect(() => { + if (!active) return; + const main = document.getElementById("main-content"); + if (!main) return; + const record = () => { + const a = anchor.current; + if (a && a.el.isConnected) a.top = a.el.getBoundingClientRect().top; + }; + main.addEventListener("scroll", record, { passive: true }); + return () => main.removeEventListener("scroll", record); + }, [active]); +} + function DockedTitle({ ref, ...props @@ -362,7 +430,9 @@ function DaySheet({ returnFocus(); }} className={cn( - "bg-card gap-0 p-0", + // Quicker than the primitive's 500 ms, which read as lag on a + // layer opened from a tap; no slide at all under reduced motion. + "bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none", bottom ? cn( "rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none", diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index b92fd2830..0b7ff57a1 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -8,8 +8,9 @@ import { ChevronRight, History, MessageCircle, + PanelBottomClose, + PanelRightClose, Plus, - X, } from "lucide-react"; import { @@ -38,7 +39,8 @@ import { DayValues, } from "./day-sections"; import { curateDayValues, tileKeyOf } from "./day-values-model"; -import { useDay } from "./use-day"; +import { shiftDateKey } from "./day-url"; +import { useDay, usePrefetchDay } from "./use-day"; import { useDayValueFormat } from "./use-day-value-format"; /** @@ -91,6 +93,16 @@ export interface DayViewProps { const ICON_BUTTON = "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9"; +/** + * The close control is the Coach panel's own toggle, in the same place: the + * panel's top-left corner, the panel icon mirrored so it points the way the + * panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as a + * copy so the day layer does not pull the Coach's panel into the shell). + */ +const PANEL_TOGGLE = + "text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7"; +const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4"; + export function DayView({ date, today, @@ -114,6 +126,15 @@ export function DayView({ const capabilities = useRecordCapabilities(); const { user } = useAuth(); const [captureOpen, setCaptureOpen] = useState(false); + // The neighbouring day is read while the pointer rests on its arrow (or + // the keyboard reaches it, or a finger lands on it), so the step paints a + // day, not a skeleton. + const prefetchDay = usePrefetchDay(); + const warm = (delta: number) => () => { + const next = shiftDateKey(date, delta); + if (delta > 0 && next > today) return; + prefetchDay(next); + }; const canCapture = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules) @@ -163,28 +184,42 @@ export function DayView({ data-slot="day-header" className={cn( "flex shrink-0 items-center gap-1", - compact - ? "pt-1.5 pr-2 pb-2.5 pl-4" - : "border-border border-b pr-3 pl-6", + compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3", headerClassName, )} > -

- - {longLabel(date)} - -

- {meta ?? ·} -

-
+ {/* Top left, where the Coach panel keeps its toggle: the way out. */} + + {/* One line, centred in the band: the date and nothing under it. */} + + {longLabel(date)} + -
+ {/* What the day holds, as meta under the header. The line keeps its + height while the day loads, so nothing below it moves. */} +

+ {meta ?? ·} +

{focus ? (
) { const [tooltip, setTooltip] = useState(null); - // The pointer type of the last press decides whether the click that - // follows opens the day (mouse, pen) or only pinned the tooltip (touch). - const lastPointer = useRef("mouse"); const today = useTodayKey(); const openKey = useOpenDay(); const coarse = useCoarsePointer(); @@ -109,18 +104,17 @@ export function useHeatmapDay(containerRef: RefObject) { setTooltip((prev) => (prev?.pinned ? prev : null)); }, onPointerDown: (e: React.PointerEvent) => { - lastPointer.current = e.pointerType; - if (e.pointerType !== "touch") return; + // A touch on a cell with a day to open opens it (the click below); + // any other cell pins its tooltip so the tap still answers. + if (e.pointerType !== "touch" || openable) return; setTooltip({ x: e.clientX, y: e.clientY, text: cell.describe(), pinned: true, - day: openable ? cell.dateKey : undefined, }); }, onClick: () => { - if (lastPointer.current === "touch") return; if (!openable) return; open(cell.dateKey); }, @@ -135,9 +129,7 @@ export function useHeatmapDay(containerRef: RefObject) { const tooltipNode = tooltip ? (
) { }} > {tooltip.text} - {tooltip.day ? ( - open(tooltip.day as DateKey)} /> - ) : null}
) : null; diff --git a/src/components/day/use-day.ts b/src/components/day/use-day.ts index 5dcc8fc97..8f2792f5f 100644 --- a/src/components/day/use-day.ts +++ b/src/components/day/use-day.ts @@ -1,6 +1,7 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -18,15 +19,34 @@ import { queryKeys } from "@/lib/query-keys"; * 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 ?? ""), +function dayQuery(date: DateKey) { + return { + 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, - }); + }; +} + +export function useDay(date: DateKey | null) { + return useQuery({ ...dayQuery(date ?? ""), enabled: date !== null }); +} + +/** + * Read a day ahead of time, so the step to it paints the day instead of a + * skeleton: the neighbouring day when the pointer rests on, or the keyboard + * reaches, the arrow that leads there. A day already read (and fresh) is not + * read again; the query layer dedupes one that is already on its way. + */ +export function usePrefetchDay(): (date: DateKey) => void { + const queryClient = useQueryClient(); + return useCallback( + (date: DateKey) => { + void queryClient.prefetchQuery(dayQuery(date)); + }, + [queryClient], + ); } /** diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 45d3a2069..07a5466c4 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -90,6 +90,10 @@ export function DocumentBulkBar({ aria-label={t("documents.bulk.barLabel")} className={cn( "bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6", + // v1.42 — while a day is docked on the right, the bar sits against + // the page's right edge instead of the window's centre, clear of the + // day's footer actions and of an expanded sidebar. + "[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0", "flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg", )} > diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index f30bfe79b..e27243b14 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -641,7 +641,10 @@ describe(" composite states", () => { const rest = atRest(html); // The onset is an instant; it reads as the day it falls on in the // profile timezone, in the user's date format, never as raw ISO. - expect(rest).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./); + // v1.42 — the date is a link to its day; the sentence reads the same. + expect(rest.replace(/<[^>]+>/g, "")).toMatch( + /Rest Mode active since \d{2}\/\d{2}\/2026\./, + ); expect(rest).not.toContain("T23:30:00"); expect(rest).toContain('data-slot="health-score-tension"'); expect(rest).toContain('data-slot="health-score-return-to-band"'); @@ -669,7 +672,11 @@ describe(" composite states", () => { />, ); const rest = atRest(html); - expect(rest).toMatch(/Rest Mode active since (07\/20\/2026|20\/07\/2026)/); + expect(rest.replace(/<[^>]+>/g, "")).toMatch( + /Rest Mode active since (07\/20\/2026|20\/07\/2026)/, + ); + // The date opens the day it names, in the profile timezone. + expect(rest).toContain('data-day="2026-07-20"'); expect(rest).not.toContain("2026-07-19T"); }); diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx index c60c2abee..6c9bea58a 100644 --- a/src/components/insights/ecg-detail.tsx +++ b/src/components/insights/ecg-detail.tsx @@ -1,7 +1,10 @@ "use client"; +import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; +import { DayLinkAt } from "@/components/day/day-link"; + import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -111,7 +114,12 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { > + {fmt.dateTime(new Date(data.recordedAt))} + + } />
{label}
diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index fdd8563dc..2741a7d8c 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -15,6 +15,8 @@ import { type EcgClassification, } from "@/lib/insights/ecg-classification"; import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit"; +import { DayLinkAt } from "@/components/day/day-link"; + import { InsightSectionCard } from "./insight-section-card"; // Re-exported so the tests (and any teaser call site) can keep importing the @@ -140,7 +142,11 @@ export function EcgSection({ the recording, it is not a second fact about it. */}

- {fmt.dateTime(new Date(rec.recordedAt))} + {/* v1.42 — the date opens the day; the rest of the + row opens the recording. Above the row's link. */} + + {fmt.dateTime(new Date(rec.recordedAt))} +

{label && ( {rec.hasWaveform ? ( - + // v1.42 — two targets in one row: the date opens the day, + // the row opens the recording. The row's link is stretched + // under the content (the workout list's pattern), so the + // date can sit above it instead of inside it. +
{rowInner} -
) : (
{rowInner}
)} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 45b3d5302..ebcceddd3 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -24,6 +24,11 @@ import { type PillarDetailContext, } from "@/components/insights/health-score-pillar-detail"; import { useAuth } from "@/hooks/use-auth"; +import { + DAY_LINK_SLOT, + DayLink, + withDayLinkSlot, +} from "@/components/day/day-link"; import { formatDate } from "@/lib/date-format"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { useUnitDisplay } from "@/hooks/use-unit-display"; @@ -530,18 +535,30 @@ export function HealthScoreCard({ data-slot="health-score-rest-mode" className="text-muted-foreground text-xs" > - {t("insights.healthScore.restMode", { - since: report.restMode.since - ? formatDate( - userDayKey( + {/* v1.42 — the date opens the day rest mode began. */} + {report.restMode.since + ? withDayLinkSlot( + t("insights.healthScore.restMode", { since: DAY_LINK_SLOT }), + + {formatDate( + userDayKey( + new Date(report.restMode.since), + user?.timezone || DEFAULT_TIMEZONE, + ), + dateFormat, + locale, + )} + , + ) + : t("insights.healthScore.restMode", { + since: t("insights.healthScore.none"), + })}

) : null} {tensionLine ? ( diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index df0f850de..f8815d6fe 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -250,7 +250,12 @@ export function LayoutCoachFab() { // The default ring alone is hard to see against the gradient; // the offset ring draws a clear halo around the circle. "focus-visible:ring-offset-background focus-visible:ring-offset-2", - "transition-opacity duration-150 motion-reduce:transition-none", + "transition-[opacity,right] duration-150 motion-reduce:transition-none", + // v1.42 — while a day is docked on the right (from 1280 px), the + // launcher moves left with the page's own edge, so it stays over + // the page and never over the day's footer actions. The `:has()` + // gate keys off the docked panel's slot, like the yields below. + "[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]", // Yield to the data-list selection bar: its delete action lands // in the same lower-right band, and the destructive control // wins. The `:has()` gate keys off the bar's `data-slot`. diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx index 52a860719..bb20d8aff 100644 --- a/src/components/insights/mood/mood-dimension-trends.tsx +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -216,7 +216,6 @@ export function MoodDimensionTrends({ active label={fmt.dateShortSmartCalendar(String(row.date))} rows={rows} - action={chartDays.tooltipAction(chartData.indexOf(row))} /> ); }} diff --git a/src/components/insights/rest-mode-banner.tsx b/src/components/insights/rest-mode-banner.tsx index 3f4e1af87..cdda468eb 100644 --- a/src/components/insights/rest-mode-banner.tsx +++ b/src/components/insights/rest-mode-banner.tsx @@ -2,6 +2,11 @@ import { HeartPulse } from "lucide-react"; +import { + DAY_LINK_SLOT, + DayLinkStated, + withDayLinkSlot, +} from "@/components/day/day-link"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { RestModeAnnotation } from "@/lib/analytics/score/types"; @@ -62,8 +67,14 @@ export function RestModeBanner({ {t("insights.restMode.title")}

- {sinceLabel - ? t("insights.restMode.bodySince", { since: sinceLabel }) + {/* v1.42 — the onset date opens the day the episode began. */} + {sinceLabel && since + ? withDayLinkSlot( + t("insights.restMode.bodySince", { since: DAY_LINK_SLOT }), + + {sinceLabel} + , + ) : t("insights.restMode.body")}

diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 2367a516a..670d21a7f 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; import { InsightSectionCard } from "./insight-section-card"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { DayLinkAt } from "@/components/day/day-link"; /** * v1.10.0 — device-flagged event awareness surface (categorical events, @@ -174,7 +175,10 @@ export function RhythmEventsCard({

)}

- {fmt.dateTime(new Date(event.occurredAt))} + {/* v1.42 — the date opens the day around the event. */} + + {fmt.dateTime(new Date(event.occurredAt))} +

diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index ccf2e94c0..bd09ae618 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -469,7 +469,6 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const row = payload[0]?.payload as Record | undefined; const label = row?.label; - const rowIndex = row ? data.indexOf(row) : -1; const napCount = typeof row?.napCount === "number" ? row.napCount : 0; // The footer's first line is the NIGHT. The nap is listed @@ -550,9 +549,6 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { )}
)} - {chartDays.tooltipAction( - rowIndex === -1 ? undefined : rowIndex, - )} ); }} diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 7a8828340..97f78dcf8 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -406,7 +406,6 @@ export function LabBiomarkerChart({ active label={labShortDate(point.timestamp)} rows={rows} - action={chartDays.tooltipAction(points.indexOf(point))} /> ); }} diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx index 0d0307570..36bab7d32 100644 --- a/src/components/medications/detail/efficacy/efficacy-chart.tsx +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -378,18 +378,11 @@ export function EfficacyChart({ color: item.color ?? "var(--chart-1)", }; }); - const index = - typeof x === "number" - ? displaySeries.findIndex((p) => p.t === x) - : -1; return ( ); }} diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 4b08d8082..6040fb7c7 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -326,7 +326,6 @@ export function DoseStrengthCurve({ active label={fmt.dateShortSmart(new Date(point.t))} rows={rows} - action={chartDays.tooltipAction(chartData.indexOf(point))} /> ); }} diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 1cdf57adb..548b2165b 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -183,7 +183,6 @@ export function AssessmentHistoryChart({ active label={fmt.dateShortSmart(new Date(point.timestamp))} rows={rowsOut} - action={chartDays.tooltipAction(points.indexOf(point))} /> ); }}