feat: open the day with one tap and from every place a day is shown

A tap on a point, a bar, a calendar cell or a date now opens the day at
once, as a click does; the tooltip's second step is gone and the tapped
value heads the day. The dashboard's charts (not its today area), the
adherence chart, the rhythm events, the ECG list and detail and the rest
mode notes open their days too.

The day's header carries the close control top left, the Coach panel's
own toggle and glyph, the date on one centred line and the arrows on the
right. While the day is docked, the Coach button and the documents bar
move clear of it.

Smooth the layer: the tooltip no longer glides into place on touch, the
neighbouring day is read before a step, the sheet animates in 300 ms and
not at all under reduced motion, and the opener stays put while the page
beside the docked day reflows.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 15:56:40 +02:00
1 parent c41ec02e92
commit fee6088e08
43 files changed
+1114 -451

No files matched your search

+291
View File
@@ -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/);
});
});
+19 -96
View File
@@ -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);
});
});
+90
View File
@@ -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<void> {
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<void> => {
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();
}
}
+115
View File
@@ -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<string, string[]> {
const days: Record<string, string[]> = {};
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 }),
});
});
}
+49
View File
@@ -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<void> {
],
);
// 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<void> {
// 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);
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -12201,11 +12201,11 @@
"captureShort": "기록",
"inTimelineShort": "타임라인",
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
"chartHintTouch": "점을 탭하면 값이 보여요.",
"chartHintTouch": "점을 탭하면 그날이 열려요.",
"chartHintBar": "막대를 클릭하면 그날이 열려요.",
"chartHintTouchBar": "막대를 탭하면 값이 보여요.",
"chartHintTouchBar": "막대를 탭하면 그날이 열려요.",
"chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.",
"chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.",
"chartHintTouchCell": "날짜 칸을 탭하면 그날이 열려요.",
"coachChips": "이 답변에 쓰인 날",
"description": "이 날에 대한 모든 기록이에요.",
"openDocument": "문서 열기",
+3 -3
View File
@@ -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",
+9
View File
@@ -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
/>
),
});
-5
View File
@@ -2404,11 +2404,6 @@ export function HealthChart({
: dateLabel
}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint
? dayLinkSource?.indexOf(hoverPoint)
: undefined,
)}
/>
);
}}
@@ -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({
/>
)
) : (
<div
className="h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]"
role="img"
aria-label={complianceAriaLabel}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={yAxisFormatter}
/>
<Tooltip
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
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,
<>
<div
className={cn(
"h-[var(--chart-height,240px)] touch-pan-y md:h-[var(--chart-height-md,280px)]",
chartDays.plotClassName,
)}
role="img"
aria-label={complianceAriaLabel}
data-slot="chart-plot"
{...chartDays.plotProps}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
onClick={chartDays.onChartClick}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={48}
tickFormatter={yAxisFormatter}
/>
{chartDays.openIndex !== undefined ? (
<ReferenceLine
x={chartData[chartDays.openIndex]?.date}
{...OPEN_DAY_LINE}
/>
) : null}
<Tooltip
{...chartDays.tooltipProps}
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
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 (
<RichChartTooltip active label={dateLabel} rows={rows} />
);
}
const rows: RichTooltipRow[] = [
{
name: t("dashboard.compliance7d"),
value: `${fmt.integer(rate)} %`,
color: payload[0]?.color ?? COLOR_LINE,
delta,
},
];
return (
<RichChartTooltip active label={dateLabel} rows={rows} />
);
}}
/>
{/* 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 ? (
<>
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
</>
) : null}
<Line
type="monotone"
dataKey="rate"
stroke={COLOR_LINE}
strokeWidth={2}
dot={{ r: 2, fill: COLOR_LINE }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{showTargetRange ? (
<>
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
</>
) : null}
<Line
type="monotone"
dataKey="rate"
stroke={COLOR_LINE}
strokeWidth={2}
dot={{ r: 2, fill: COLOR_LINE }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<ChartDayFooter
links={chartDays}
points={chartData}
axis="band"
// The margin (8) + the y axis (48) on the left, the margin (8)
// on the right.
insetLeft={8 + 48}
insetRight={8}
/>
</>
)}
</div>
);
+1 -10
View File
@@ -1015,16 +1015,7 @@ export function MoodChart({
}
}
if (rows.length === 0) return null;
return (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
action={chartDays.tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
return <RichChartTooltip active label={dateLabel} rows={rows} />;
}}
/>
<Line
@@ -155,7 +155,6 @@ export function NutrientDailyBarChart({
active
label={fmt.dateShortSmartCalendar(point.day)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
@@ -239,7 +239,6 @@ export function CustomMetricChart({
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
+2 -18
View File
@@ -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 (
<BbtTooltip
active={props.active}
payload={payload}
action={chartDays.tooltipAction(
point ? points.indexOf(point) : undefined,
)}
/>
);
}}
content={<BbtTooltip />}
/>
{ovulationMs != null ? (
<ReferenceLine
@@ -387,8 +374,6 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) {
export function BbtTooltip(props: {
active?: boolean;
payload?: { payload: BbtPoint }[];
/** v1.42 — the tooltip's way to the day on a touch screen. */
action?: ReactNode;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -422,7 +407,6 @@ export function BbtTooltip(props: {
{t(`cycle.ovulationTest.${p.ovulationTest}`)}
</p>
) : null}
{props.action ?? null}
</div>
);
}
@@ -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
* `<XAxis>` 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<string, string> = {
"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);
});
@@ -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(/<TodayHero\b/);
});
});
@@ -108,6 +108,7 @@ let canWrite = true;
vi.mock("../use-day", () => ({
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
usePrefetchDay: () => () => undefined,
}));
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
vi.mock("@/hooks/use-auth", () => ({
+25 -35
View File
@@ -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 `<Tooltip>`: 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 `<Tooltip>`. 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) ? (
<TooltipDayAction onOpen={() => open(index)} />
) : undefined,
coarse || prefersReducedMotion() ? { isAnimationActive: false } : {},
};
}
+4 -26
View File
@@ -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 (
<button
type="button"
data-slot="chart-tooltip-open-day"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
onPointerDown={(event) => event.stopPropagation()}
className="bg-muted hover:bg-muted/80 focus-visible:ring-ring/50 mt-2.5 flex min-h-11 w-full items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("day.viewWholeDay")}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
);
}
interface RugPoint {
timestamp: number;
}
+22 -1
View File
@@ -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);
+74 -4
View File
@@ -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 = (
<p
@@ -227,7 +243,10 @@ function DayLayer() {
aria-labelledby={titleId}
data-slot="day-panel"
data-shell="docked"
className="bg-card text-card-foreground border-border flex h-full w-105 shrink-0 flex-col border-l"
// A short fade, not a slide: the column takes its width at once
// (the page beside it reflows in the same frame either way), so
// only its content eases in. None under reduced motion.
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
>
{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",
+70 -33
View File
@@ -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,
)}
>
<div className="min-w-0 flex-1">
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="truncate text-base leading-snug font-semibold focus-visible:outline-none"
>
{longLabel(date)}
</Title>
<p
className="text-muted-foreground truncate text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
</p>
</div>
{/* Top left, where the Coach panel keeps its toggle: the way out. */}
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={PANEL_TOGGLE}
>
{compact ? (
<PanelBottomClose
className={PANEL_TOGGLE_ICON}
aria-hidden="true"
/>
) : (
<PanelRightClose
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
)}
</Button>
{/* One line, centred in the band: the date and nothing under it. */}
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{longLabel(date)}
</Title>
<Button
type="button"
variant={compact ? "ghost" : "outline"}
@@ -192,6 +227,9 @@ export function DayView({
data-slot="day-prev"
aria-label={t("day.previousDay")}
title={t("day.previousDayShortcut")}
onPointerEnter={warm(-1)}
onPointerDown={warm(-1)}
onFocus={warm(-1)}
onClick={() => onStep(-1)}
className={ICON_BUTTON}
>
@@ -208,6 +246,9 @@ export function DayView({
aria-label={t("day.nextDay")}
title={t("day.nextDayShortcut")}
disabled={date >= today}
onPointerEnter={warm(1)}
onPointerDown={warm(1)}
onFocus={warm(1)}
onClick={() => onStep(1)}
className={ICON_BUTTON}
>
@@ -216,18 +257,6 @@ export function DayView({
aria-hidden="true"
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={ICON_BUTTON}
>
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
</Button>
</div>
<div
@@ -237,6 +266,14 @@ export function DayView({
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
)}
>
{/* What the day holds, as meta under the header. The line keeps its
height while the day loads, so nothing below it moves. */}
<p
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
</p>
{focus ? (
<div className="space-y-2.5">
<DayFocusCard
+10 -21
View File
@@ -1,10 +1,10 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useEffect, useState, type RefObject } from "react";
import type { DateKey } from "@/lib/day/contract";
import { TooltipDayAction, useCoarsePointer } from "./chart-day";
import { useCoarsePointer } from "./chart-day";
import { openDay, useOpenDay } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
import { useTodayKey } from "./use-today-key";
@@ -13,10 +13,10 @@ import { useTodayKey } from "./use-today-key";
* The day view's doors on a calendar heatmap (mood, intake), the cell twin of
* `useChartDayLinks`:
*
* - fine pointer: hovering a cell shows its tooltip, a click opens its day;
* - touch: a tap pins the tooltip, and the tooltip's button ("View the
* whole day", the same button every chart's tooltip carries) opens it;
* a tap outside or Escape clears it;
* - a click, or a tap, on a cell opens its day; hovering a cell on a fine
* pointer shows its tooltip. A tap on a cell with nothing to open (no
* entry, or a day still ahead) pins its tooltip instead, so the cell
* still says what it is; a tap outside or Escape clears it;
* - the open day's cell carries a dashed outline, the heatmap's form of
* the dashed line a chart draws through it (`OPEN_DAY_CELL`);
* - the keyboard and screen-reader way to a day is the heatmap's data
@@ -39,8 +39,6 @@ interface HeatmapTooltip {
text: string;
/** A touch tooltip stays after the finger lifts. */
pinned?: boolean;
/** The day a pinned tooltip offers to open. */
day?: DateKey;
}
export interface HeatmapCellInput {
@@ -53,9 +51,6 @@ export interface HeatmapCellInput {
export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
const [tooltip, setTooltip] = useState<HeatmapTooltip | null>(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<string>("mouse");
const today = useTodayKey();
const openKey = useOpenDay();
const coarse = useCoarsePointer();
@@ -109,18 +104,17 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
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<HTMLElement | null>) {
const tooltipNode = tooltip ? (
<div
data-slot="heatmap-tooltip"
className={`bg-popover text-popover-foreground border-border fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md ${
tooltip.day ? "pointer-events-auto" : "pointer-events-none"
}`}
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 max-w-[calc(100vw-1rem)] rounded-md border px-2.5 py-1.5 text-xs shadow-md"
// Clamp the left edge so a tap near the right border doesn't push
// the pinned label off-screen on a narrow viewport.
style={{
@@ -149,9 +141,6 @@ export function useHeatmapDay(containerRef: RefObject<HTMLElement | null>) {
}}
>
{tooltip.text}
{tooltip.day ? (
<TooltipDayAction onOpen={() => open(tooltip.day as DateKey)} />
) : null}
</div>
) : null;
+26 -6
View File
@@ -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<DayResponse>(`/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],
);
}
/**
@@ -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",
)}
>
@@ -641,7 +641,10 @@ describe("<HealthScoreCard> 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("<HealthScoreCard> 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");
});
+10 -2
View File
@@ -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 }) {
>
<MetaItem
label={t("insights.ecg.meta.recorded")}
value={fmt.dateTime(new Date(data.recordedAt))}
// v1.42 — the date opens the day around the recording.
value={
<DayLinkAt at={data.recordedAt}>
{fmt.dateTime(new Date(data.recordedAt))}
</DayLinkAt>
}
/>
<MetaItem
label={t("insights.ecg.meta.duration")}
@@ -149,7 +157,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
function MetaItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
+30 -11
View File
@@ -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. */}
<div className="flex flex-wrap items-center gap-2">
<p className="text-foreground text-sm font-medium">
{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. */}
<DayLinkAt at={rec.recordedAt} className="z-10">
{fmt.dateTime(new Date(rec.recordedAt))}
</DayLinkAt>
</p>
{label && (
<span
@@ -166,19 +172,32 @@ export function EcgSection({
key={rec.id}
data-slot="ecg-row"
data-classification={rec.classification ?? "NONE"}
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
className="border-border/60 relative border-b pb-3 last:border-b-0 last:pb-0"
>
{rec.hasWaveform ? (
<Link
href={`/insights/ecg/${rec.id}`}
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
>
// 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.
<div className="has-[a[data-slot=ecg-row-link]:hover]:bg-muted/40 has-[a[data-slot=ecg-row-link]:focus-visible]:ring-ring/50 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors has-[a[data-slot=ecg-row-link]:focus-visible]:ring-2">
{rowInner}
<ChevronRight
className="text-muted-foreground mt-1.5 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<Link
href={`/insights/ecg/${rec.id}`}
data-slot="ecg-row-link"
aria-label={[
fmt.dateTime(new Date(rec.recordedAt)),
label,
]
.filter(Boolean)
.join(", ")}
className="mt-1.5 shrink-0 after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
>
<ChevronRight
className="text-muted-foreground size-4"
aria-hidden="true"
/>
</Link>
</div>
) : (
<div className="flex items-start gap-3">{rowInner}</div>
)}
+27 -10
View File
@@ -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 }),
<DayLink
date={userDayKey(
new Date(report.restMode.since),
user?.timezone || DEFAULT_TIMEZONE,
),
dateFormat,
locale,
)
: t("insights.healthScore.none"),
})}
)}
size="xs"
>
{formatDate(
userDayKey(
new Date(report.restMode.since),
user?.timezone || DEFAULT_TIMEZONE,
),
dateFormat,
locale,
)}
</DayLink>,
)
: t("insights.healthScore.restMode", {
since: t("insights.healthScore.none"),
})}
</p>
) : null}
{tensionLine ? (
+6 -1
View File
@@ -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`.
@@ -216,7 +216,6 @@ export function MoodDimensionTrends({
active
label={fmt.dateShortSmartCalendar(String(row.date))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(row))}
/>
);
}}
+13 -2
View File
@@ -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")}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{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 }),
<DayLinkStated at={since} size="xs">
{sinceLabel}
</DayLinkStated>,
)
: t("insights.restMode.body")}
</p>
</div>
@@ -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({
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
{/* v1.42 — the date opens the day around the event. */}
<DayLinkAt at={event.occurredAt} size="xs">
{fmt.dateTime(new Date(event.occurredAt))}
</DayLinkAt>
</p>
</div>
</li>
@@ -469,7 +469,6 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const row = payload[0]?.payload as
Record<string, number | string> | 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) {
)}
</div>
)}
{chartDays.tooltipAction(
rowIndex === -1 ? undefined : rowIndex,
)}
</div>
);
}}
@@ -406,7 +406,6 @@ export function LabBiomarkerChart({
active
label={labShortDate(point.timestamp)}
rows={rows}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}
@@ -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 (
<RichChartTooltip
active
label={typeof x === "number" ? fmt.date(new Date(x)) : ""}
rows={rows}
action={chartDays.tooltipAction(
index === -1 ? undefined : index,
)}
/>
);
}}
@@ -326,7 +326,6 @@ export function DoseStrengthCurve({
active
label={fmt.dateShortSmart(new Date(point.t))}
rows={rows}
action={chartDays.tooltipAction(chartData.indexOf(point))}
/>
);
}}
@@ -183,7 +183,6 @@ export function AssessmentHistoryChart({
active
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rowsOut}
action={chartDays.tooltipAction(points.indexOf(point))}
/>
);
}}