mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the right-side panel strips, lazy day panel and calendar, and the layout write lock into release/v1.42.0
This commit is contained in:
commit
25d0839296
25 files changed
+1184
-411
No files matched your search
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 429,
|
||||
"/insights/page": 304,
|
||||
@@ -8,6 +8,6 @@
|
||||
"/settings/[section]/page": 403
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4470,
|
||||
"totalClientKbGz": 4760,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+119
-66
@@ -13,15 +13,17 @@ import { mockDay } from "./setup/day-mock";
|
||||
*
|
||||
* Contracts under test, at 1920, 1440, 1280 and 390 px:
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as folded. Folded, the panel keeps a 48 px edge whose
|
||||
* button (in the place of the open panel's fold control) opens it
|
||||
* again; the choice survives a reload. The panel is flush with the
|
||||
* viewport's right edge and runs from the top of the window to the
|
||||
* bottom; the top bar ends at its left edge and the panel's header
|
||||
* border is the top bar's. Focus moves between the fold control and
|
||||
* the edge's button, and Escape inside the open panel folds it.
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit.
|
||||
* From 1440 px the reading column (thread and composer) is exactly as
|
||||
* wide with the panel open as folded; at 1280 the two strips leave the
|
||||
* open column below its cap, so folding widens it up to the cap. Its 40 px strip stays at the right edge,
|
||||
* left of the day's, open or not, and never moves: a click opens the
|
||||
* panel left of the strips, a second click shuts it; the choice
|
||||
* survives a reload. The panel is flush with the strips and runs from
|
||||
* the top of the window to the bottom; the top bar ends at its left
|
||||
* edge and the panel's header border is the top bar's. Focus moves
|
||||
* between the fold control and the strip, and Escape inside the open
|
||||
* panel folds it.
|
||||
* 2. New conversation is a round 56 px button in the conversation column,
|
||||
* never over the panel, never over the composer's field, and absent
|
||||
* from the panel's header.
|
||||
@@ -216,7 +218,10 @@ async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const collapse = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-collapse"]');
|
||||
const expand = (page: Page) => page.locator('[data-slot="coach-panel-expand"]');
|
||||
const expand = (page: Page) =>
|
||||
page.locator('[data-slot="coach-panel-strip-toggle"]');
|
||||
const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]');
|
||||
const dayStrip = (page: Page) => page.locator('[data-slot="day-strip"]');
|
||||
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
|
||||
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
const panel = (page: Page) =>
|
||||
@@ -247,50 +252,72 @@ async function expectNoSidewaysScroll(page: Page) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The docked panel is flush with the window: its right edge is the
|
||||
* viewport's (no scrollbar gutter beside it), it runs from the top of the
|
||||
* window to the bottom, the top bar ends at its left edge, its header row's
|
||||
* bottom border is the top bar's, and its own border is the left one only.
|
||||
* The docked panel is flush with its strips: the conversations' strip sits
|
||||
* right of the panel and the day's right of that, against the viewport's
|
||||
* right edge (no scrollbar gutter beside them). The panel runs from the top
|
||||
* of the window to the bottom, the top bar ends at its left edge, an open
|
||||
* panel's header row has the top bar's bottom border, and its own border
|
||||
* is the left one only. Both strips start below the top bar's band.
|
||||
*/
|
||||
async function expectPanelFlush(page: Page, width: number, height: number) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
const aside = document.querySelector(
|
||||
const box = (selector: string) =>
|
||||
document.querySelector(selector)?.getBoundingClientRect() ?? null;
|
||||
const outer = document.querySelector(
|
||||
'[data-slot="coach-conversations-panel"]',
|
||||
)!;
|
||||
const inner = aside.firstElementChild as HTMLElement;
|
||||
// Open, the header row; folded, the edge's band.
|
||||
const header =
|
||||
aside.querySelector('[data-slot="coach-conversations-panel-header"]') ??
|
||||
aside.querySelector('[data-slot="coach-panel-expand"]')!.parentElement!;
|
||||
const bar = document.querySelector('[data-slot="top-bar"]')!;
|
||||
const inner = outer.querySelector("aside");
|
||||
const style = inner ? getComputedStyle(inner) : null;
|
||||
const main = document.getElementById("main-content")!;
|
||||
const style = getComputedStyle(inner);
|
||||
const box = aside.getBoundingClientRect();
|
||||
const barBox = bar.getBoundingClientRect();
|
||||
const panel = outer.getBoundingClientRect();
|
||||
return {
|
||||
right: box.right,
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
left: box.left,
|
||||
headerBottom: header.getBoundingClientRect().bottom,
|
||||
barBottom: barBox.bottom,
|
||||
barRight: barBox.right,
|
||||
panel: {
|
||||
left: panel.left,
|
||||
right: panel.right,
|
||||
top: panel.top,
|
||||
bottom: panel.bottom,
|
||||
},
|
||||
header:
|
||||
box('[data-slot="coach-conversations-panel-header"]')?.bottom ?? null,
|
||||
bar: box('[data-slot="top-bar"]')!,
|
||||
list: box('[data-slot="coach-panel-strip"]')!,
|
||||
listButton: box('[data-slot="coach-panel-strip-toggle"]')!,
|
||||
day: box('[data-slot="day-strip"]')!,
|
||||
dayDock: box("#day-docked-panel")!,
|
||||
gutter: main.offsetWidth - main.clientWidth,
|
||||
borders: [
|
||||
style.borderTopWidth,
|
||||
style.borderRightWidth,
|
||||
style.borderBottomWidth,
|
||||
style.borderLeftWidth,
|
||||
],
|
||||
borders: style
|
||||
? [
|
||||
style.borderTopWidth,
|
||||
style.borderRightWidth,
|
||||
style.borderBottomWidth,
|
||||
style.borderLeftWidth,
|
||||
]
|
||||
: null,
|
||||
};
|
||||
});
|
||||
expect(geometry.right).toBe(width);
|
||||
expect(geometry.top).toBe(0);
|
||||
expect(geometry.bottom).toBe(height);
|
||||
expect(geometry.barRight).toBe(geometry.left);
|
||||
expect(geometry.headerBottom).toBe(geometry.barBottom);
|
||||
expect(geometry.day.right).toBe(width);
|
||||
expect(geometry.list.right).toBe(geometry.day.left);
|
||||
expect(Math.round(geometry.list.width)).toBe(40);
|
||||
expect(Math.round(geometry.day.width)).toBe(40);
|
||||
// Conversations, then the day (open or slid to nothing), then the strips.
|
||||
expect(geometry.panel.right).toBe(geometry.dayDock.left);
|
||||
expect(geometry.dayDock.right).toBe(geometry.list.left);
|
||||
expect(geometry.panel.top).toBe(0);
|
||||
expect(geometry.panel.bottom).toBe(height);
|
||||
expect(geometry.bar.right).toBe(geometry.panel.left);
|
||||
// The strips start below the band; the palette's corner stays the bar's.
|
||||
expect(geometry.listButton.top).toBe(geometry.bar.bottom);
|
||||
if (geometry.header !== null) {
|
||||
expect(geometry.header).toBe(geometry.bar.bottom);
|
||||
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
|
||||
}
|
||||
expect(geometry.gutter).toBe(0);
|
||||
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
|
||||
}
|
||||
|
||||
/** Where a strip stands on screen. */
|
||||
async function stripBox(locator: Locator) {
|
||||
const box = (await locator.boundingBox())!;
|
||||
return { x: Math.round(box.x), width: Math.round(box.width) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,7 +457,10 @@ test.describe("Coach page frame", () => {
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
const open = await columnWidths(page);
|
||||
expect(open.thread).toBeCloseTo(672, 0);
|
||||
// The full 672 px column at 1440; at 1280 the two strips take 80 px
|
||||
// of it and the column stays well above the 560 px floor.
|
||||
if (width === 1440) expect(open.thread).toBeCloseTo(672, 0);
|
||||
else expect(open.thread).toBeGreaterThanOrEqual(560);
|
||||
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
|
||||
// The panel sits right of the column, never over it.
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
@@ -444,11 +474,16 @@ test.describe("Coach page frame", () => {
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
// Folded: a 48 px edge stays, its button takes the focus, and the
|
||||
// top bar ends at the edge.
|
||||
// The strip stands still through a fold and an open.
|
||||
const stripOpen = await stripBox(strip(page));
|
||||
await expect(expand(page)).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Folded: the panel slides to nothing, the strip takes the focus, and
|
||||
// the top bar ends at the strips.
|
||||
await collapse(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 48);
|
||||
await waitForWidth(panel(page), 0);
|
||||
expect(await stripBox(strip(page))).toEqual(stripOpen);
|
||||
await expect(expand(page)).toBeFocused();
|
||||
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(expand(page)).toHaveAttribute(
|
||||
@@ -460,8 +495,15 @@ test.describe("Coach page frame", () => {
|
||||
).toHaveCount(0);
|
||||
await expectPanelFlush(page, width, 900);
|
||||
const closed = await columnWidths(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
if (width === 1440) {
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
} else {
|
||||
// At 1280 the strips leave the open column below its 672 px cap, so
|
||||
// folding the list gives the column back what it lacked, and no more.
|
||||
expect(closed.thread).toBeGreaterThanOrEqual(open.thread);
|
||||
expect(closed.thread).toBeLessThanOrEqual(672 + 1);
|
||||
}
|
||||
expect(Math.abs(closed.composer - closed.thread)).toBeLessThanOrEqual(1);
|
||||
await expectNewChatFab(page);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
@@ -470,8 +512,18 @@ test.describe("Coach page frame", () => {
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
|
||||
// A click on the strip opens it, a second click shuts it, and the
|
||||
// strip does not move either time.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
expect(await stripBox(strip(page))).toEqual(stripOpen);
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(expand(page)).toBeFocused();
|
||||
expect(await stripBox(strip(page))).toEqual(stripOpen);
|
||||
|
||||
// Opened from the keyboard, focus lands on the fold control; Escape
|
||||
// from inside folds it again and focus goes back to the edge.
|
||||
// from inside folds it again and focus goes back to the strip.
|
||||
await expand(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
@@ -500,15 +552,17 @@ test.describe("Coach page frame", () => {
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The day opens docked, right of the conversations, which fold to
|
||||
// their edge for it.
|
||||
// The day opens docked, left of the strips, and the conversations
|
||||
// close for it; both strips stay.
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 48);
|
||||
const rail = (await panel(page).boundingBox())!;
|
||||
await waitForWidth(panel(page), 0);
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(rail.x + rail.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
expect(Math.round(dayBox.x + dayBox.width)).toBe(1440);
|
||||
const listStrip = (await strip(page).boundingBox())!;
|
||||
expect(Math.round(dayBox.x + dayBox.width)).toBe(Math.round(listStrip.x));
|
||||
expect(Math.round(listStrip.x + listStrip.width)).toBe(1400);
|
||||
await expect(dayStrip(page)).toHaveAttribute("data-state", "open");
|
||||
await expectPanelFlush(page, 1440, 900);
|
||||
await expectNewChatFab(page);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
@@ -517,30 +571,28 @@ test.describe("Coach page frame", () => {
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-day");
|
||||
|
||||
// Opening the conversations folds the day to its edge; focus goes to
|
||||
// the conversations' fold control, the day is out of the URL, and the
|
||||
// conversation stays the same.
|
||||
// Opening the conversations closes the day; its strip keeps the day,
|
||||
// focus goes to the conversations' fold control, the day is out of the
|
||||
// URL, and the conversation stays the same.
|
||||
await expand(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(dayPanel(page)).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toHaveAttribute(
|
||||
"data-day",
|
||||
day,
|
||||
);
|
||||
await expect(dayStrip(page)).toHaveAttribute("data-day", day);
|
||||
await expect(dayStrip(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(collapse(page)).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
|
||||
await expectNewChatFab(page);
|
||||
await shot(page, testInfo, "coach-frame-1440-list");
|
||||
|
||||
// And back: the day's edge opens the day, the conversations fold.
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
// And back: the day's strip opens the day, the conversations close.
|
||||
await page.locator('[data-slot="day-strip-toggle"]').click();
|
||||
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
|
||||
|
||||
// Switching conversations keeps the day open.
|
||||
await expand(page).click();
|
||||
await page.locator('[data-slot="day-expand"]').click();
|
||||
await page.locator('[data-slot="day-strip-toggle"]').click();
|
||||
await expect(dayPanel(page)).toBeVisible();
|
||||
await fab(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
|
||||
@@ -565,6 +617,7 @@ test.describe("Coach page frame", () => {
|
||||
const list = (await panel(page).boundingBox())!;
|
||||
const dayBox = (await dayPanel(page).boundingBox())!;
|
||||
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
|
||||
await expectPanelFlush(page, 1920, 1000);
|
||||
expect(
|
||||
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
|
||||
.width,
|
||||
|
||||
@@ -291,9 +291,9 @@ test.describe("the dashboard and the list of all values", () => {
|
||||
})
|
||||
.toBeLessThanOrEqual(panelBox.x);
|
||||
|
||||
// Collapsed, the day keeps a narrow edge; the launcher stays clear of it.
|
||||
// Folded, the day keeps its strip; the launcher stays clear of it.
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
const edge = (await page.locator('[data-slot="day-rail"]').boundingBox())!;
|
||||
const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = (await fab.boundingBox())!;
|
||||
|
||||
+15
-11
@@ -29,8 +29,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
|
||||
* the header's `day-date-button` and its `day-date-picker` (days keyed by
|
||||
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
|
||||
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
|
||||
* `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`)
|
||||
* with its `day-expand`.
|
||||
* `day-next` / `day-close`, and the docked day's strip `day-strip`
|
||||
* (+ `data-day`, `data-state`) with its `day-strip-toggle`.
|
||||
*/
|
||||
|
||||
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
|
||||
@@ -145,7 +145,7 @@ test.describe("the day view", () => {
|
||||
await expect(tiles).toHaveCount(2);
|
||||
await expect(
|
||||
scores.locator('[data-slot="day-score"][data-score="healthScore"]'),
|
||||
).toHaveAttribute("href", "/insights");
|
||||
).toHaveAttribute("href", "/insights/health-score");
|
||||
await expect(
|
||||
scores.locator('[data-slot="day-score"][data-score="strain"]'),
|
||||
).toHaveAttribute("href", "/insights/scores/strain");
|
||||
@@ -164,7 +164,7 @@ test.describe("the day view", () => {
|
||||
expect(order).toEqual(["day-values", "day-scores", "day-events"]);
|
||||
});
|
||||
|
||||
test("the docked day collapses to an edge and comes back on another page", async ({
|
||||
test("the docked day folds beside its strip and comes back on another page", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -178,14 +178,16 @@ test.describe("the day view", () => {
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
// A narrow edge stays, holding the day, and focus waits on its button.
|
||||
const rail = page.locator('[data-slot="day-rail"]');
|
||||
const expand = page.locator('[data-slot="day-expand"]');
|
||||
// The strip stays, holding the day, and focus waits on it.
|
||||
const rail = page.locator('[data-slot="day-strip"]');
|
||||
const expand = page.locator('[data-slot="day-strip-toggle"]');
|
||||
await expect(rail).toHaveAttribute("data-day", day);
|
||||
await expect(rail).toHaveAttribute("data-state", "closed");
|
||||
await expect(expand).toBeFocused();
|
||||
await expect(expand).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
|
||||
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
|
||||
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(48);
|
||||
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
|
||||
|
||||
// Another page: the edge is still there and brings the same day back,
|
||||
// from the keyboard too.
|
||||
@@ -200,7 +202,9 @@ test.describe("the day view", () => {
|
||||
);
|
||||
await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
|
||||
await expect(panel(page).locator("h2").first()).toBeFocused();
|
||||
await expect(rail).toHaveCount(0);
|
||||
// Open, the strip stays where it was and says so.
|
||||
await expect(rail).toHaveAttribute("data-state", "open");
|
||||
await expect(expand).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
|
||||
@@ -278,7 +282,7 @@ test.describe("the day view", () => {
|
||||
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test("a sheet closes for good and leaves no edge behind", async ({
|
||||
test("a sheet closes for good and leaves no strip behind", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const desktop = testInfo.project.name === "chromium-desktop";
|
||||
@@ -298,7 +302,7 @@ test.describe("the day view", () => {
|
||||
await page.locator('[data-slot="day-close"]').click();
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a future or malformed ?day= is dropped without a word", async ({
|
||||
|
||||
+103
-1
@@ -626,7 +626,10 @@ test("an open day stays open from the timeline to another page", async ({
|
||||
await expect(panel).toHaveCount(0);
|
||||
await page.locator('a[href="/timeline"]').first().click();
|
||||
await expect(page).toHaveURL(/\/timeline$/);
|
||||
await expect(page.locator('[data-slot="day-rail"]')).toBeVisible();
|
||||
await expect(page.locator('[data-slot="day-strip"]')).toHaveAttribute(
|
||||
"data-state",
|
||||
"closed",
|
||||
);
|
||||
await expect(panel).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -775,3 +778,102 @@ test("listing all of a day's values scrolls the panel, never the page", async ({
|
||||
expect(Math.abs(after.doc - before.doc)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(after.main - before.main)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("the day's strip stays at the right edge, opens and closes the day, and never moves", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await setTimelineModuleOn(page);
|
||||
await mockTimeline(page, "full", TODAY);
|
||||
const strip = page.locator('[data-slot="day-strip"]');
|
||||
const toggle = page.locator('[data-slot="day-strip-toggle"]');
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
const where = async () => {
|
||||
const box = (await strip.boundingBox())!;
|
||||
return [box.x, box.y, box.width, box.height].map(Math.round);
|
||||
};
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
const doc = document.scrollingElement!;
|
||||
return {
|
||||
docSw: doc.scrollWidth - doc.clientWidth,
|
||||
docSh: doc.scrollHeight - doc.clientHeight,
|
||||
mainSw: main.scrollWidth - main.clientWidth,
|
||||
bar: document
|
||||
.querySelector('[data-slot="top-bar"]')!
|
||||
.getBoundingClientRect().bottom,
|
||||
};
|
||||
});
|
||||
|
||||
for (const width of [1280, 1440, 1920]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/timeline");
|
||||
await expect(
|
||||
page.locator('[data-slot="timeline-chart"] > svg'),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Closed: the strip is there, at the right edge, below the top bar's
|
||||
// band, holding today.
|
||||
await expect(strip).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const closed = await where();
|
||||
expect(closed[0] + closed[2], `${width}px right edge`).toBe(width);
|
||||
const before = await measure();
|
||||
expect(
|
||||
Math.round((await toggle.boundingBox())!.y),
|
||||
`${width}px below the band`,
|
||||
).toBe(Math.round(before.bar));
|
||||
expect(before.docSw).toBeLessThanOrEqual(0);
|
||||
expect(before.mainSw).toBeLessThanOrEqual(0);
|
||||
|
||||
// A click opens the day left of the strip; the strip stays put.
|
||||
await toggle.click();
|
||||
await expect(panel).toHaveAttribute("data-shell", "docked");
|
||||
await expect(strip).toHaveAttribute("data-state", "open");
|
||||
await expect(page.locator("#day-docked-panel")).toHaveAttribute(
|
||||
"data-state",
|
||||
"open",
|
||||
);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await panel.boundingBox())!;
|
||||
return Math.round(box.x + box.width);
|
||||
})
|
||||
.toBe(closed[0]);
|
||||
expect(await where()).toEqual(closed);
|
||||
await expect(panel.locator("h2").first()).toBeFocused();
|
||||
const open = await measure();
|
||||
expect(open.docSw, `${width}px open, sideways`).toBeLessThanOrEqual(0);
|
||||
expect(open.mainSw, `${width}px open, sideways`).toBeLessThanOrEqual(0);
|
||||
expect(open.docSh, `${width}px open, page height`).toBeLessThanOrEqual(
|
||||
before.docSh,
|
||||
);
|
||||
await testInfo.attach(`day-strip-${width}-open`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// A second click closes it; focus stays on the strip, which stays put.
|
||||
await toggle.click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(strip).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle).toBeFocused();
|
||||
expect(await where()).toEqual(closed);
|
||||
await expect(page).toHaveURL(/\/timeline$/);
|
||||
}
|
||||
|
||||
// Both themes, open at 1440.
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await page.goto("/timeline");
|
||||
await toggle.click();
|
||||
await expect(panel).toHaveAttribute("data-shell", "docked");
|
||||
await testInfo.attach(`day-strip-1440-${colorScheme}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await toggle.click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
@@ -97,39 +97,41 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Read-modify-write inside a Serializable transaction so two
|
||||
// concurrent toggles (e.g. user opens two tabs and flips overlays
|
||||
// on different charts) can't drop one another's update by reading
|
||||
// the same layout snapshot and clobbering each other on write.
|
||||
// Read-modify-write under a row lock, so two concurrent toggles (two
|
||||
// tabs, two charts flipped in quick succession) can neither drop one
|
||||
// another's update nor fail. `FOR UPDATE` queues every writer for this
|
||||
// account behind the one holding the row, and the read happens through
|
||||
// the same `tx`, after the lock, so it sees the previous writer's commit.
|
||||
// A Serializable transaction used to guard this instead: it kept the
|
||||
// updates from clobbering each other by aborting all but one of them
|
||||
// with a serialization failure, which surfaced as a 500.
|
||||
// Resolver normalises legacy / missing fields, so layouts saved
|
||||
// before v1.4.18 pick up the new field with default-empty prefs
|
||||
// without a one-off migration.
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const row = await tx.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`;
|
||||
const row = await tx.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
|
||||
const current = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const next: DashboardLayout = {
|
||||
...current,
|
||||
chartOverlayPrefs: {
|
||||
...(current.chartOverlayPrefs ?? {}),
|
||||
[parsed.data.chartKey]: parsed.data.prefs,
|
||||
},
|
||||
};
|
||||
const normalized = serializeDashboardLayout(next);
|
||||
const current = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const next: DashboardLayout = {
|
||||
...current,
|
||||
chartOverlayPrefs: {
|
||||
...(current.chartOverlayPrefs ?? {}),
|
||||
[parsed.data.chartKey]: parsed.data.prefs,
|
||||
},
|
||||
};
|
||||
const normalized = serializeDashboardLayout(next);
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The partial-update path mutates the same `User.dashboardWidgetsJson`
|
||||
// blob the `/api/dashboard/widgets` GET reads from. Bust the cache so
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const transactionLock = vi.hoisted(() => vi.fn(async () => []));
|
||||
const transactionUser = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -123,8 +124,12 @@ beforeEach(() => {
|
||||
// test below the dead-switch block assumes.
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
callback: (tx: { user: typeof transactionUser }) => Promise<unknown>,
|
||||
) => callback({ user: transactionUser })) as never);
|
||||
callback: (tx: {
|
||||
user: typeof transactionUser;
|
||||
$queryRaw: typeof transactionLock;
|
||||
}) => Promise<unknown>,
|
||||
) =>
|
||||
callback({ user: transactionUser, $queryRaw: transactionLock })) as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => {
|
||||
@@ -942,9 +947,13 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toEqual({
|
||||
isolationLevel: "Serializable",
|
||||
});
|
||||
// A row lock, not Serializable isolation, which failed concurrent
|
||||
// writers with a serialization error.
|
||||
expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toBeUndefined();
|
||||
expect(transactionLock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
(transactionLock.mock.calls[0] as unknown[] | undefined)?.[0],
|
||||
).toEqual(expect.arrayContaining([expect.stringContaining("FOR UPDATE")]));
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(transactionUser.findUnique).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -494,32 +494,33 @@ export const DELETE = apiHandler(async () => {
|
||||
// dashboard to defaults is the loudest possible version of redecorating it.
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { normalized, updatedAt } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const existing = await tx.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
const normalized = serializeDashboardLayout({
|
||||
...existingLayout,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
|
||||
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
|
||||
// A reset brings a hidden top card back.
|
||||
todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible,
|
||||
});
|
||||
// Under a row lock rather than Serializable isolation: a reset racing a
|
||||
// chart-overlay toggle on the same account waits for it instead of
|
||||
// failing with a serialization error (a 500).
|
||||
const { normalized, updatedAt } = await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`;
|
||||
const existing = await tx.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
const normalized = serializeDashboardLayout({
|
||||
...existingLayout,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
|
||||
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
|
||||
// A reset brings a hidden top card back.
|
||||
todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible,
|
||||
});
|
||||
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return { normalized, updatedAt: updated.updatedAt.toISOString() };
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return { normalized, updatedAt: updated.updatedAt.toISOString() };
|
||||
});
|
||||
|
||||
annotate({ action: { name: "dashboard.widgets.reset" } });
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, CalendarDayButton } from "@/components/ui/calendar";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { resolveDateFnsLocale } from "@/components/ui/date-field";
|
||||
import { resolveDateFnsLocale } from "@/components/ui/calendar-locale";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* "Saturday, 3 January 2026", for the day's header, its spoken name and its
|
||||
* strip. Its own module, so the shell's day layer can name a day without
|
||||
* loading the day view.
|
||||
*/
|
||||
export function useLongDayLabel(
|
||||
length: "long" | "short" = "long",
|
||||
): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: length,
|
||||
day: "numeric",
|
||||
month: length,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
}
|
||||
+179
-113
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
@@ -12,11 +13,17 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import { PanelRightOpen } from "lucide-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import {
|
||||
DOCK_SLIDE,
|
||||
DOCK_SLIDE_MS,
|
||||
DockStrip,
|
||||
ShellStrip,
|
||||
useLingering,
|
||||
} from "@/components/layout/shell-dock";
|
||||
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -48,13 +55,14 @@ import {
|
||||
takeDayYield,
|
||||
useDayFocus,
|
||||
} from "./day-layer-controller";
|
||||
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
|
||||
import {
|
||||
DayView,
|
||||
PANEL_TOGGLE,
|
||||
PANEL_TOGGLE_ICON,
|
||||
useLongDayLabel,
|
||||
} from "./day-view";
|
||||
parseDayParam,
|
||||
shiftDateKey,
|
||||
stripDayOf,
|
||||
withDayHref,
|
||||
} from "./day-url";
|
||||
import { useLongDayLabel } from "./day-label";
|
||||
import type { DayViewProps } from "./day-view";
|
||||
import { usePrefetchDay } from "./use-day";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
@@ -67,10 +75,12 @@ import { useTodayKey } from "./use-today-key";
|
||||
* - from 1280 px, a column docked beside the page, the same place and the
|
||||
* same breakpoint as the Coach's conversations panel. Not modal: the
|
||||
* chart beside it stays usable, and another point swaps the day. It is a
|
||||
* landmark (`complementary`) named by the date. It collapses like a
|
||||
* sidebar instead of going away: hidden, a narrow edge stays on the
|
||||
* right with a button that brings the last day back, on this page or any
|
||||
* other (`last-day.ts` remembers it per browser and account).
|
||||
* landmark (`complementary`) named by the date. Its strip stays at the
|
||||
* right edge of the window whether the day is open or not
|
||||
* (`shell-dock.tsx`): it names the day and opens or closes it, on this
|
||||
* page or any other (`last-day.ts` remembers the day per browser and
|
||||
* account; with nothing remembered the strip holds today). The column
|
||||
* slides open and shut left of the strip the way the conversations do.
|
||||
* - from 768 px, a sheet from the right, modal.
|
||||
* - on a phone, a sheet from the bottom at a bit over half the height, so
|
||||
* the chart stays in view above it; dragging the handle up (or tapping
|
||||
@@ -78,14 +88,36 @@ import { useTodayKey } from "./use-today-key";
|
||||
*
|
||||
* On `/coach` the day docks too, right of the Coach's conversations: page,
|
||||
* conversations, day. Below 1600 px only one of the two is open at a time;
|
||||
* opening the day folds the conversations to their edge, and opening the
|
||||
* conversations folds the day to its edge (`yieldDay`).
|
||||
* opening the day closes the conversations, and opening the conversations
|
||||
* closes the day (`yieldDay`).
|
||||
*
|
||||
* The parameter is the state. A date that is not a calendar date, or lies in
|
||||
* the future, is removed from the URL without a word. Collapsing the docked
|
||||
* day removes the parameter like closing does; only the remembered date
|
||||
* stays, and expanding opens it again.
|
||||
*/
|
||||
export const DAY_PANEL_ID = "day-docked-panel";
|
||||
|
||||
/**
|
||||
* The day's content loads when a day first opens (or when the pointer
|
||||
* reaches the strip), not with the shell: the scores, the calendar and the
|
||||
* sections are no part of a page until a day is open. Until it arrives the
|
||||
* frame shows its header band and nothing else.
|
||||
*/
|
||||
const loadDayView = () => import("./day-view").then((m) => m.DayView);
|
||||
function preloadDayView() {
|
||||
void loadDayView();
|
||||
}
|
||||
const DayView = dynamic(loadDayView, {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
data-slot="day-view-loading"
|
||||
className={cn(SHELL_HEADER_BAND, "border-border shrink-0")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export function DayLayerMount() {
|
||||
// `useSearchParams` suspends a statically rendered route until the client
|
||||
// has the URL; the boundary keeps that from blanking the page around it.
|
||||
@@ -205,19 +237,48 @@ function DayLayer() {
|
||||
const shortLabel = useLongDayLabel("short");
|
||||
const { t } = useTranslations();
|
||||
const titleId = useId();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null);
|
||||
// Focus asked for before the day's content had loaded: the heading takes
|
||||
// it the moment it mounts.
|
||||
const titleFocusPending = useRef(false);
|
||||
const attachTitle = useCallback((el: HTMLHeadingElement | null) => {
|
||||
titleRef.current = el;
|
||||
if (el && titleFocusPending.current) {
|
||||
titleFocusPending.current = false;
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
}, []);
|
||||
const focusTitle = useCallback(() => {
|
||||
const el = titleRef.current;
|
||||
if (el) el.focus({ preventScroll: true });
|
||||
else titleFocusPending.current = true;
|
||||
}, []);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// Set by the collapse control: focus then lands on the control that
|
||||
// brings the day back, in the same place, as a sidebar's toggle does.
|
||||
const stripRef = useRef<HTMLButtonElement>(null);
|
||||
// Set by a fold (the header's control, the strip): focus then lands on the
|
||||
// strip, which brings the day back from the same place.
|
||||
const collapsing = useRef(false);
|
||||
|
||||
// The collapsed edge: the docked frame only, and only with a day to bring
|
||||
// back that is still a day the layer would open.
|
||||
// The remembered day, while the docked day is shut, if it is still a day
|
||||
// the layer would open.
|
||||
const railDay =
|
||||
shell === "docked" && date === null && lastDay !== null
|
||||
? parseDayParam(lastDay, today)
|
||||
: null;
|
||||
// A page change drops `?day=` before the new page puts the remembered day
|
||||
// back (below); a day left open stays open through that gap instead of
|
||||
// sliding shut and open again.
|
||||
const heldOpen = shell === "docked" && date === null && leftOpen;
|
||||
const dockedDay =
|
||||
shell === "docked" ? (date ?? (heldOpen ? railDay : null)) : null;
|
||||
// The docked column keeps painting the day it showed while it slides shut.
|
||||
const shownDay = useLingering(dockedDay);
|
||||
// No slide on arrival: a day restored with the page is simply there.
|
||||
const [settled, setSettled] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setSettled(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
collapsing.current = true;
|
||||
@@ -253,7 +314,7 @@ function DayLayer() {
|
||||
// (on the arrow the person pressed), and so does a day restored on a
|
||||
// new page.
|
||||
if (before === null && shell === "docked" && !restoring.current) {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
focusTitle();
|
||||
}
|
||||
restoring.current = false;
|
||||
return;
|
||||
@@ -272,7 +333,7 @@ function DayLayer() {
|
||||
collapsing.current = false;
|
||||
return;
|
||||
}
|
||||
const rail = expandRef.current;
|
||||
const rail = stripRef.current;
|
||||
if (collapsing.current && rail) {
|
||||
takeDayTrigger();
|
||||
rail.focus({ preventScroll: true });
|
||||
@@ -281,7 +342,7 @@ function DayLayer() {
|
||||
}
|
||||
}
|
||||
collapsing.current = false;
|
||||
}, [date, shell, owner]);
|
||||
}, [date, shell, owner, focusTitle]);
|
||||
|
||||
const onStep = useCallback(
|
||||
(delta: number) => {
|
||||
@@ -347,71 +408,13 @@ function DayLayer() {
|
||||
</p>
|
||||
);
|
||||
|
||||
if (date === null) {
|
||||
if (railDay === null) return live;
|
||||
const label = t("day.showPanel", { date: longLabel(railDay) });
|
||||
const expand = () =>
|
||||
openDay(railDay, { trigger: expandRef.current ?? undefined });
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<ShellSidePanel>
|
||||
<div
|
||||
data-slot="day-rail"
|
||||
data-day={railDay}
|
||||
className="bg-card text-card-foreground border-border order-2 flex h-full w-12 shrink-0 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the two bottom borders draw one line;
|
||||
the control sits where the open day keeps its own. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-expand"
|
||||
aria-expanded={false}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={expand}
|
||||
className={PANEL_TOGGLE}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the day it
|
||||
holds; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expand}
|
||||
className="hover:bg-muted/60 text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{shortLabel(railDay)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const view = (
|
||||
Title: Parameters<typeof DayView>[0]["Title"],
|
||||
extra?: Partial<Parameters<typeof DayView>[0]>,
|
||||
shown: DateKey,
|
||||
Title: DayViewProps["Title"],
|
||||
extra?: Partial<DayViewProps>,
|
||||
) => (
|
||||
<DayView
|
||||
date={date}
|
||||
date={shown}
|
||||
today={today}
|
||||
focus={focus}
|
||||
shell={shell}
|
||||
@@ -420,46 +423,92 @@ function DayLayer() {
|
||||
onPick={onPick}
|
||||
Title={Title}
|
||||
titleId={titleId}
|
||||
titleRef={titleRef}
|
||||
titleRef={attachTitle}
|
||||
{...extra}
|
||||
/>
|
||||
);
|
||||
|
||||
if (shell === "docked") {
|
||||
// What the strip holds: the open day, else the remembered one, else today.
|
||||
const stripDay = stripDayOf(date, railDay, today);
|
||||
const open = dockedDay !== null;
|
||||
const toggle = () => {
|
||||
if (open) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
openDay(stripDay, { trigger: stripRef.current ?? undefined });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<ShellSidePanel>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
// 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 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
<div
|
||||
id={DAY_PANEL_ID}
|
||||
data-slot="day-dock"
|
||||
data-state={open ? "open" : "closed"}
|
||||
className={cn(
|
||||
DOCK_SLIDE,
|
||||
"order-2",
|
||||
open ? "w-105" : "w-0",
|
||||
!settled && "transition-none",
|
||||
)}
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
// the app shell, which takes it (`shell-safe-area`).
|
||||
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
|
||||
})}
|
||||
</aside>
|
||||
{shownDay !== null ? (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
data-state={open ? "open" : "closing"}
|
||||
// Sliding shut, it paints the day it showed and takes no
|
||||
// input.
|
||||
inert={!open}
|
||||
className="bg-card text-card-foreground border-border absolute inset-y-0 left-0 flex w-105 flex-col border-l"
|
||||
>
|
||||
{view(shownDay, DockedTitle, {
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
// the app shell, which takes it (`shell-safe-area`).
|
||||
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
|
||||
})}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
<ShellStrip>
|
||||
<DockStrip
|
||||
ref={stripRef}
|
||||
slot="day-strip"
|
||||
order={2}
|
||||
controls={DAY_PANEL_ID}
|
||||
expanded={open}
|
||||
label={shortLabel(stripDay)}
|
||||
actionLabel={
|
||||
open
|
||||
? t("day.hidePanel")
|
||||
: t("day.showPanel", { date: longLabel(stripDay) })
|
||||
}
|
||||
icon={CalendarDays}
|
||||
onToggle={toggle}
|
||||
onPreload={preloadDayView}
|
||||
data={{ "data-day": stripDay }}
|
||||
/>
|
||||
</ShellStrip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (date === null) return live;
|
||||
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<DaySheet
|
||||
shell={shell}
|
||||
panelRef={panelRef}
|
||||
titleRef={titleRef}
|
||||
focusTitle={focusTitle}
|
||||
onDismiss={closeByPerson}
|
||||
render={(extra) => view(SheetTitle, extra)}
|
||||
render={(extra) => view(date, SheetTitle, extra)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -472,10 +521,13 @@ function DayLayer() {
|
||||
* 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.
|
||||
* The column slides, so the page reflows on every frame of the slide; the
|
||||
* correction follows it frame by frame until the slide has run.
|
||||
*/
|
||||
function useKeepAnchorInView(active: boolean) {
|
||||
const anchor = useRef<{ el: HTMLElement; top: number } | null>(null);
|
||||
const wasActive = useRef(false);
|
||||
const frame = useRef(0);
|
||||
|
||||
// Before paint, so the corrected position is the first one drawn.
|
||||
useLayoutEffect(() => {
|
||||
@@ -491,15 +543,28 @@ function useKeepAnchorInView(active: boolean) {
|
||||
? { 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;
|
||||
}
|
||||
const held = anchor.current;
|
||||
if (!active) anchor.current = null;
|
||||
if (!held) return;
|
||||
const correct = () => {
|
||||
if (!held.el.isConnected) return;
|
||||
const delta = held.el.getBoundingClientRect().top - held.top;
|
||||
if (Math.abs(delta) > 1) main.scrollTop += delta;
|
||||
held.top = held.el.getBoundingClientRect().top;
|
||||
};
|
||||
correct();
|
||||
cancelAnimationFrame(frame.current);
|
||||
const until = performance.now() + DOCK_SLIDE_MS + 50;
|
||||
const tick = () => {
|
||||
correct();
|
||||
if (performance.now() < until)
|
||||
frame.current = requestAnimationFrame(tick);
|
||||
};
|
||||
frame.current = requestAnimationFrame(tick);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(frame.current), []);
|
||||
|
||||
// While the column is open, the page may scroll; the anchor follows.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -552,16 +617,17 @@ function returnFocus(fallback?: HTMLElement | null) {
|
||||
function DaySheet({
|
||||
shell,
|
||||
panelRef,
|
||||
titleRef,
|
||||
focusTitle,
|
||||
onDismiss,
|
||||
render,
|
||||
}: {
|
||||
shell: "sheet" | "bottom";
|
||||
panelRef: React.RefObject<HTMLElement | null>;
|
||||
titleRef: React.RefObject<HTMLHeadingElement | null>;
|
||||
/** Focus the day's heading, now or as soon as it has loaded. */
|
||||
focusTitle: () => void;
|
||||
/** The person closed the sheet (a swipe down, Escape, the overlay). */
|
||||
onDismiss: () => void;
|
||||
render: (extra: Partial<Parameters<typeof DayView>[0]>) => React.ReactNode;
|
||||
render: (extra: Partial<DayViewProps>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
@@ -625,7 +691,7 @@ function DaySheet({
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
focusTitle();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -55,6 +55,19 @@ export function parseDayParam(
|
||||
return isOpenableDay(raw, todayKey) ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the docked day's strip holds: the open day, else the remembered
|
||||
* one while the layer would still open it, else today. The strip always
|
||||
* names a day, so it always has something to open.
|
||||
*/
|
||||
export function stripDayOf(
|
||||
open: DateKey | null,
|
||||
remembered: string | null,
|
||||
todayKey: DateKey,
|
||||
): DateKey {
|
||||
return open ?? parseDayParam(remembered, todayKey) ?? todayKey;
|
||||
}
|
||||
|
||||
/** `key` moved by `delta` calendar days. */
|
||||
export function shiftDateKey(key: DateKey, delta: number): DateKey {
|
||||
const anchor = new Date(`${key}T12:00:00.000Z`);
|
||||
|
||||
@@ -26,10 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLongDayLabel } from "./day-label";
|
||||
import { DayDatePicker } from "./day-date-picker";
|
||||
import type { DayFocus } from "./day-layer-controller";
|
||||
import {
|
||||
@@ -55,22 +55,6 @@ import { useDayValueFormat } from "./use-day-value-format";
|
||||
* three frames cannot drift apart.
|
||||
*/
|
||||
|
||||
/** "Saturday, 3 January 2026", for the header and the spoken name. */
|
||||
export function useLongDayLabel(
|
||||
length: "long" | "short" = "long",
|
||||
): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: length,
|
||||
day: "numeric",
|
||||
month: length,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
}
|
||||
|
||||
export interface DayViewProps {
|
||||
date: DateKey;
|
||||
today: DateKey;
|
||||
|
||||
@@ -61,7 +61,7 @@ function VerbLabel({ children }: { children: string }) {
|
||||
// Icon-only again while a day is docked: the bar then shares the width
|
||||
// with the day panel and the labelled row would no longer fit one line.
|
||||
return (
|
||||
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
|
||||
<span className="hidden lg:inline [body:has([data-slot=day-strip][data-state=open])_&]:hidden">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -116,10 +116,11 @@ export function DocumentBulkBar({
|
||||
// the bar shows, which is the shell's call, not a width's: a phone
|
||||
// held sideways is wider than `md` and still has the bar.
|
||||
"bg-card border-border shell-desktop:bottom-6 fixed bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] left-1/2 z-40 -translate-x-1/2",
|
||||
// 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",
|
||||
// v1.42 — while a day is docked on the right (its strip open), the
|
||||
// bar sits against the page's right edge instead of the window's
|
||||
// centre, clear of the day's footer actions, its strip and an
|
||||
// expanded sidebar.
|
||||
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+1rem)] [body:has([data-slot=day-strip][data-state=open])_&]:left-auto [body:has([data-slot=day-strip][data-state=open])_&]:max-w-[calc(100vw-46.75rem)] [body:has([data-slot=day-strip][data-state=open])_&]:translate-x-0",
|
||||
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -67,10 +67,11 @@ describe("the folded list", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps an edge with its button, and sits left of the day", () => {
|
||||
expect(source).toContain('data-slot="coach-panel-rail"');
|
||||
expect(source).toContain('data-slot="coach-panel-expand"');
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
|
||||
it("slides to nothing, keeps its own strip, and sits left of the day", () => {
|
||||
expect(source).toContain('slot="coach-panel-strip"');
|
||||
expect(source).toContain("order={1}");
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-0"');
|
||||
expect(source).toContain("DOCK_SLIDE");
|
||||
expect(source).toMatch(/order-1/);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
|
||||
import {
|
||||
MessagesSquare,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Target,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +19,12 @@ import {
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DOCK_SLIDE,
|
||||
DockStrip,
|
||||
ShellStrip,
|
||||
useLingering,
|
||||
} from "@/components/layout/shell-dock";
|
||||
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { TopBarActions } from "@/components/layout/top-bar-actions";
|
||||
@@ -43,25 +55,27 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Docked, the panel is a column of the shell beside the content column
|
||||
* (top bar and page): it runs the full height of the window, and the top bar
|
||||
* ends at its left edge. The panel's header row is the top bar's band, so
|
||||
* the two bottom borders draw one line. It folds like the day beside it: the
|
||||
* header's first control folds it to a narrow edge, and the edge's button
|
||||
* (in the same place) opens it again. Folded, the edge names the list.
|
||||
* the two bottom borders draw one line. Its strip stays at the right edge of
|
||||
* the window, left of the day's (`shell-dock.tsx`), open or not: a click
|
||||
* on the strip opens the panel left of the strips and a second click shuts
|
||||
* it; the header's first control shuts it too. The panel slides open and
|
||||
* shut (a 200 ms width transition, none under reduced motion).
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
*
|
||||
* The day docks right of the panel on this page too (page, conversations,
|
||||
* day). Below 1600 px only one of the two is open: a day opening folds the
|
||||
* list to its edge without touching the remembered choice, and opening the
|
||||
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
|
||||
* list without touching the remembered choice, and opening the list closes
|
||||
* the day (`yieldDay`). From 1600 px both stay open.
|
||||
*
|
||||
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
|
||||
* the top bar.
|
||||
*
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key; focus moves to the edge's button, and opening
|
||||
* from there lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* already claimed the key; focus moves to the strip, and opening from the
|
||||
* strip lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
@@ -121,6 +135,17 @@ export function listYields(state: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the docked list closes an open day: below 1600 px only one
|
||||
* of the two is open, and the one the person just opened wins.
|
||||
*/
|
||||
export function dayYields(state: {
|
||||
fitsBoth: boolean;
|
||||
dayOpen: boolean;
|
||||
}): boolean {
|
||||
return !state.fitsBoth && state.dayOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone sheet is open. A settings deep link opens it only once
|
||||
* the viewport is known: the server and hydration renders take the narrow
|
||||
@@ -263,6 +288,8 @@ export function ConversationsPanel({
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// The list keeps painting while it slides shut.
|
||||
const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false;
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -282,7 +309,7 @@ export function ConversationsPanel({
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
// Below 1600 px the day makes room: it folds to its edge.
|
||||
if (!fitsBoth && openDay !== null) yieldDay();
|
||||
if (dayYields({ fitsBoth, dayOpen: openDay !== null })) yieldDay();
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
@@ -425,89 +452,65 @@ export function ConversationsPanel({
|
||||
)}
|
||||
|
||||
{docked ? (
|
||||
<ShellSidePanel>
|
||||
<aside
|
||||
id={COACH_PANEL_ID}
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
<>
|
||||
<ShellSidePanel>
|
||||
<div
|
||||
id={COACH_PANEL_ID}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so
|
||||
// it runs from the top of the window to the bottom beside the
|
||||
// top bar, left of a docked day (`order-1`, the day is
|
||||
// `order-2`). The width slides between the open column and
|
||||
// nothing; the content keeps 18rem, pinned left, so it slides
|
||||
// instead of squeezing (`DOCK_SLIDE`).
|
||||
DOCK_SLIDE,
|
||||
"bg-sidebar text-sidebar-foreground order-1",
|
||||
dockedOpen ? "w-72" : "w-0",
|
||||
)}
|
||||
>
|
||||
{shownOpen ? (
|
||||
<aside
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
// Sliding shut, it paints the list and takes no input.
|
||||
inert={!dockedOpen}
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l"
|
||||
>
|
||||
{body(false)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
<ShellStrip>
|
||||
<DockStrip
|
||||
ref={expandRef}
|
||||
slot="coach-panel-strip"
|
||||
order={1}
|
||||
controls={COACH_PANEL_ID}
|
||||
expanded={dockedOpen}
|
||||
label={t("insights.coach.historyTitle")}
|
||||
actionLabel={
|
||||
dockedOpen
|
||||
? t("insights.coach.frame.hidePanel")
|
||||
: t("insights.coach.frame.showPanel")
|
||||
}
|
||||
event.preventDefault();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so it
|
||||
// runs from the top of the window to the bottom beside the top
|
||||
// bar, left of a docked day (`order-1`, the day is `order-2`).
|
||||
// The width animates between the open column and the folded
|
||||
// edge; the open column keeps 18rem and is pinned to the
|
||||
// panel's left edge, so it slides instead of squeezing.
|
||||
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
|
||||
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
|
||||
dockedOpen ? "w-72" : "w-12",
|
||||
)}
|
||||
>
|
||||
{dockedOpen ? (
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="coach-panel-rail"
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the bottom borders draw one line;
|
||||
the button sits where the open panel keeps its fold
|
||||
control. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-sidebar-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={expandDocked}
|
||||
data-slot="coach-panel-expand"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={false}
|
||||
aria-label={t("insights.coach.frame.showPanel")}
|
||||
title={t("insights.coach.frame.showPanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the
|
||||
list; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expandDocked}
|
||||
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
icon={MessagesSquare}
|
||||
onToggle={dockedOpen ? collapseDocked : expandDocked}
|
||||
/>
|
||||
</ShellStrip>
|
||||
</>
|
||||
) : (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent
|
||||
|
||||
@@ -256,14 +256,13 @@ export function LayoutCoachFab() {
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
"transition-[opacity,right] duration-150 motion-reduce:transition-none",
|
||||
// v1.42 — while a day is docked on the right (from 1280 px), the
|
||||
// v1.42 — from 1280 px the day's 2.5 rem strip stays at the right
|
||||
// edge; the launcher stays clear of it. While the day is open, 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)]",
|
||||
// Collapsed, the day keeps a 3 rem edge on the right; the launcher
|
||||
// stays clear of it the same way.
|
||||
"[body:has([data-slot=day-rail])_&]:right-[calc(3rem+2rem)]",
|
||||
// gates key off the strip's state, so exactly one of them holds.
|
||||
"[body:has([data-slot=day-strip][data-state=closed])_&]:right-[calc(2.5rem+2rem)]",
|
||||
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+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`.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import { stripDayOf } from "@/components/day/day-url";
|
||||
import {
|
||||
dayYields,
|
||||
listYields,
|
||||
BOTH_PANELS_QUERY,
|
||||
} from "@/components/insights/coach-panel/conversations-panel";
|
||||
|
||||
import { DOCK_SLIDE, DockStrip } from "../shell-dock";
|
||||
|
||||
/**
|
||||
* The docked panels' strips (v1.42). Each docked panel keeps its own strip
|
||||
* at the right edge; the strip is a toggle for its panel, never moves, and
|
||||
* below 1600 px only one panel is open. The click paths, focus and the
|
||||
* geometry run in `e2e/dock-strips.spec.ts`.
|
||||
*/
|
||||
function strip(expanded: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
<DockStrip
|
||||
slot="day-strip"
|
||||
order={2}
|
||||
controls="day-docked-panel"
|
||||
expanded={expanded}
|
||||
label="Thu, 4 Jun 2026"
|
||||
actionLabel={expanded ? "Hide day" : "Show Thursday, 4 June 2026"}
|
||||
icon={CalendarDays}
|
||||
onToggle={() => {}}
|
||||
data={{ "data-day": "2026-06-04" }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DockStrip", () => {
|
||||
it("is a toggle button for its panel, closed", () => {
|
||||
const html = strip(false);
|
||||
expect(html).toContain('data-slot="day-strip"');
|
||||
expect(html).toContain('data-state="closed"');
|
||||
expect(html).toContain('data-day="2026-06-04"');
|
||||
expect(html).toMatch(/<button[^>]*type="button"/);
|
||||
expect(html).toContain('data-slot="day-strip-toggle"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="day-docked-panel"');
|
||||
expect(html).toContain('aria-label="Show Thursday, 4 June 2026"');
|
||||
// The label reads top to bottom.
|
||||
expect(html).toContain("[writing-mode:vertical-rl]");
|
||||
expect(html).toContain("Thu, 4 Jun 2026");
|
||||
});
|
||||
|
||||
it("says it is open and what a click does then", () => {
|
||||
const html = strip(true);
|
||||
expect(html).toContain('data-state="open"');
|
||||
expect(html).toContain('aria-expanded="true"');
|
||||
expect(html).toContain('aria-label="Hide day"');
|
||||
});
|
||||
|
||||
it("keeps the same shape open and closed, below the top bar's band", () => {
|
||||
const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1];
|
||||
expect(shape(strip(true))).toBe(shape(strip(false)));
|
||||
expect(shape(strip(false))).toContain("w-10");
|
||||
// The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden.
|
||||
expect(strip(false)).toMatch(
|
||||
/^<div[^>]*><div aria-hidden="true" class="h-16 border-b/,
|
||||
);
|
||||
});
|
||||
|
||||
it("orders the strips like their panels: conversations, then the day", () => {
|
||||
const list = renderToStaticMarkup(
|
||||
<DockStrip
|
||||
slot="coach-panel-strip"
|
||||
order={1}
|
||||
controls="coach-conversations-panel"
|
||||
expanded={false}
|
||||
label="Conversations"
|
||||
actionLabel="Show conversations"
|
||||
icon={CalendarDays}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(list).toMatch(/^<div[^>]*class="[^"]*\border-1\b/);
|
||||
expect(strip(false)).toMatch(/^<div[^>]*class="[^"]*\border-2\b/);
|
||||
});
|
||||
|
||||
it("slides by width, with no motion under reduced motion", () => {
|
||||
expect(DOCK_SLIDE).toContain("transition-[width]");
|
||||
expect(DOCK_SLIDE).toContain("duration-200");
|
||||
expect(DOCK_SLIDE).toContain("motion-reduce:transition-none");
|
||||
expect(DOCK_SLIDE).toContain("overflow-hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one panel open below 1600 px", () => {
|
||||
it("closes the day when the list opens below 1600 px", () => {
|
||||
expect(dayYields({ fitsBoth: false, dayOpen: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the day open beside the list from 1600 px", () => {
|
||||
expect(dayYields({ fitsBoth: true, dayOpen: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("has nothing to close without an open day", () => {
|
||||
expect(dayYields({ fitsBoth: false, dayOpen: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the list when a day opens below 1600 px, and not from 1600 px", () => {
|
||||
const base = {
|
||||
docked: true,
|
||||
listOpen: true,
|
||||
dayOpen: true,
|
||||
dayOpened: true,
|
||||
narrowed: false,
|
||||
};
|
||||
expect(listYields({ ...base, fitsBoth: false })).toBe(true);
|
||||
expect(listYields({ ...base, fitsBoth: true })).toBe(false);
|
||||
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the day strip's day", () => {
|
||||
const today = "2026-06-04";
|
||||
|
||||
it("is the open day while one is open", () => {
|
||||
expect(stripDayOf("2026-05-30", "2026-05-01", today)).toBe("2026-05-30");
|
||||
});
|
||||
|
||||
it("is the remembered day while the day is shut, across pages", () => {
|
||||
expect(stripDayOf(null, "2026-05-01", today)).toBe("2026-05-01");
|
||||
});
|
||||
|
||||
it("is today when nothing is remembered, or the memory is not a day to open", () => {
|
||||
expect(stripDayOf(null, null, today)).toBe(today);
|
||||
expect(stripDayOf(null, "2999-01-01", today)).toBe(today);
|
||||
expect(stripDayOf(null, "garbage", today)).toBe(today);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { CommandPaletteMount } from "@/components/command-palette/command-palett
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SharedRecordBanner } from "./shared-record-banner";
|
||||
import { ShellStripOutlet } from "./shell-dock";
|
||||
import { ShellSidePanelOutlet } from "./shell-side-panel";
|
||||
import { SharedRecordUnavailable } from "./shared-record-unavailable";
|
||||
import {
|
||||
@@ -540,6 +541,9 @@ export function AuthShell({
|
||||
bottom, and the top bar ends at its left edge. Empty and out of
|
||||
the row on every other page. */}
|
||||
<ShellSidePanelOutlet className="flex min-h-0 shrink-0 empty:hidden" />
|
||||
{/* The docked panels' strips, right of the panels: one per panel,
|
||||
always shown from 1280 px (`shell-dock.tsx`). */}
|
||||
<ShellStripOutlet className="flex min-h-0 shrink-0 empty:hidden" />
|
||||
<BottomNav />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SHELL_HEADER_BAND } from "./shell-metrics";
|
||||
import { createShellSlot } from "./shell-slot";
|
||||
|
||||
/**
|
||||
* The docked panels' strips (v1.42): from 1280 px every docked panel (the
|
||||
* day on every page, the Coach's conversations on `/coach`) keeps its own
|
||||
* narrow vertical strip at the right edge of the window, visible whether the
|
||||
* panel is open or not. The strips stand side by side in one column of the
|
||||
* shell, right of the panels (`ShellSidePanel`), in the same order as their
|
||||
* panels: conversations (`order-1`), then the day (`order-2`). A panel opens
|
||||
* to the left of the strips; the strips never move or change shape.
|
||||
*
|
||||
* Each strip starts below the top bar's band. The band above them is empty
|
||||
* and draws the top bar's bottom line on through, so nothing in the strips
|
||||
* meets the command palette at the end of the top bar.
|
||||
*/
|
||||
const strips = createShellSlot("shell-strips");
|
||||
|
||||
/** Where the shell paints the strips. Empty unless a docked panel fills it. */
|
||||
export const ShellStripOutlet = strips.Outlet;
|
||||
|
||||
/** Render a strip into the shell's strip column. */
|
||||
export const ShellStrip = strips.Portal;
|
||||
|
||||
/**
|
||||
* The slide every docked panel shares: the panel's box animates its width
|
||||
* between the open column and nothing, and its content keeps the open width,
|
||||
* pinned to the box's left edge, so it slides out under the strip instead of
|
||||
* squeezing. The Coach's conversations introduced it; the day takes the same
|
||||
* duration and easing. No motion under reduced motion.
|
||||
*/
|
||||
export const DOCK_SLIDE =
|
||||
"relative h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-linear motion-reduce:transition-none";
|
||||
|
||||
/** The slide's length in milliseconds, for content that waits it out. */
|
||||
export const DOCK_SLIDE_MS = 200;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return true;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* `value` while it is set, and for the length of the slide after it is
|
||||
* cleared: a closing panel keeps painting what it showed while its width
|
||||
* runs to nothing. Cleared at once under reduced motion, where nothing
|
||||
* slides.
|
||||
*/
|
||||
export function useLingering<T>(value: T | null): T | null {
|
||||
const [held, setHeld] = useState<T | null>(value);
|
||||
if (value !== null && held !== value) setHeld(value);
|
||||
useEffect(() => {
|
||||
if (value !== null || held === null) return;
|
||||
const id = window.setTimeout(
|
||||
() => setHeld(null),
|
||||
prefersReducedMotion() ? 0 : DOCK_SLIDE_MS,
|
||||
);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, held]);
|
||||
return value ?? held;
|
||||
}
|
||||
|
||||
export interface DockStripProps {
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
/** `data-slot` of the strip; its button is `<slot>-toggle`. */
|
||||
slot: string;
|
||||
/** Left to right: conversations 1, day 2. */
|
||||
order: 1 | 2;
|
||||
/** The id of the panel the strip opens and closes. */
|
||||
controls: string;
|
||||
expanded: boolean;
|
||||
/** The strip's visible, vertical label. */
|
||||
label: string;
|
||||
/** The button's accessible name (what a click does). */
|
||||
actionLabel: string;
|
||||
icon: LucideIcon;
|
||||
onToggle: () => void;
|
||||
/** Called when the pointer or focus reaches the strip (load ahead). */
|
||||
onPreload?: () => void;
|
||||
/** Extra attributes for the strip (`data-day`). */
|
||||
data?: Record<`data-${string}`, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One strip: an empty cell of the top bar's band, then a button the rest of
|
||||
* the window's height that opens and closes its panel. The label reads
|
||||
* top to bottom; the strip is marked while its panel is open.
|
||||
*/
|
||||
export function DockStrip({
|
||||
ref,
|
||||
slot,
|
||||
order,
|
||||
controls,
|
||||
expanded,
|
||||
label,
|
||||
actionLabel,
|
||||
icon: Icon,
|
||||
onToggle,
|
||||
onPreload,
|
||||
data,
|
||||
}: DockStripProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot={slot}
|
||||
data-state={expanded ? "open" : "closed"}
|
||||
{...data}
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-10 shrink-0 flex-col",
|
||||
order === 1 ? "order-1" : "order-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")}
|
||||
/>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot={`${slot}-toggle`}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={controls}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
onClick={onToggle}
|
||||
onPointerEnter={onPreload}
|
||||
onFocus={onPreload}
|
||||
className={cn(
|
||||
"border-sidebar-border flex min-h-0 flex-1 cursor-pointer flex-col items-center gap-2 border-l py-3 text-xs font-medium transition-colors outline-none",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:ring-inset",
|
||||
expanded
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate [writing-mode:vertical-rl]">{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { createShellSlot } from "./shell-slot";
|
||||
* `<main>` rather than inside `<main>`. A panel portalled here runs the full
|
||||
* height of the content row, from the top of the window (below any banner
|
||||
* strip) to the bottom, and the top bar ends at its left edge instead of
|
||||
* running underneath it. The Coach's docked conversations panel is the one
|
||||
* user. See `shell-slot.tsx`.
|
||||
* running underneath it. The docked day and the Coach's docked
|
||||
* conversations live here; their strips sit right of this column
|
||||
* (`shell-dock.tsx`). See `shell-slot.tsx`.
|
||||
*/
|
||||
const slot = createShellSlot("shell-side-panel");
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Map the app locale → the date-fns locale so the calendar's month / weekday
|
||||
* names match the UI language. Defaults to enUS for anything unmapped.
|
||||
* Imported only by the calendars, which load on demand, so the seven
|
||||
* locales never ride a page's first load.
|
||||
*/
|
||||
export function resolveDateFnsLocale(locale: Locale): DateFnsLocale {
|
||||
switch (locale) {
|
||||
case "de":
|
||||
return de;
|
||||
case "es":
|
||||
return es;
|
||||
case "fr":
|
||||
return fr;
|
||||
case "it":
|
||||
return it;
|
||||
case "pl":
|
||||
return pl;
|
||||
case "ko":
|
||||
return ko;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { Matcher } from "react-day-picker";
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
import { resolveDateFnsLocale } from "./calendar-locale";
|
||||
|
||||
/**
|
||||
* `DateField`'s single-day calendar, in its own module so the field can
|
||||
* load it on demand (`date-field.tsx`). Takes the app locale and resolves
|
||||
* the date-fns one here, beside react-day-picker, so neither rides the
|
||||
* field.
|
||||
*/
|
||||
export function DateFieldCalendar({
|
||||
appLocale,
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
defaultMonth,
|
||||
}: {
|
||||
appLocale: Locale;
|
||||
selected: Date | undefined;
|
||||
onSelect: (date: Date | undefined) => void;
|
||||
disabled: Matcher[] | undefined;
|
||||
defaultMonth: Date;
|
||||
}) {
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
defaultMonth={defaultMonth}
|
||||
locale={resolveDateFnsLocale(appLocale)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,35 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { Matcher } from "react-day-picker";
|
||||
import { de, enUS, es, fr, it, ko, pl } from "date-fns/locale";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context";
|
||||
import { formatDate, parseIsoDate } from "@/lib/date-format";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
/**
|
||||
* The calendar (react-day-picker and the date-fns month names of every
|
||||
* interface language) loads when the popover first opens, not with every
|
||||
* form that holds a date field: it is the heaviest part of the field and
|
||||
* the only one a person may never use. Until it arrives the popover keeps
|
||||
* the calendar's footprint.
|
||||
*/
|
||||
const DateFieldCalendar = dynamic(
|
||||
() => import("./date-field-calendar").then((m) => m.DateFieldCalendar),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div data-slot="date-field-calendar-loading" className="h-72 w-64" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Dependency-free date input that DISPLAYS the value in the user's date-order
|
||||
* preference (AUTO follows the locale; DMY / MDY / YMD pin the field order)
|
||||
@@ -236,8 +251,7 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
<DateFieldCalendar
|
||||
selected={selectedDate}
|
||||
onSelect={(d) => {
|
||||
commitIso(d ? dateToIso(d) : "");
|
||||
@@ -245,8 +259,7 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
|
||||
}}
|
||||
disabled={disabledMatchers.length ? disabledMatchers : undefined}
|
||||
defaultMonth={selectedDate ?? new Date()}
|
||||
locale={resolveDateFnsLocale(locale)}
|
||||
autoFocus
|
||||
appLocale={locale}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -311,31 +324,6 @@ function resolveOrder(
|
||||
return locale === "en" ? "MDY" : "DMY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the app locale → the date-fns locale so the calendar's month / weekday
|
||||
* names match the UI language. Defaults to enUS for anything unmapped.
|
||||
*/
|
||||
export function resolveDateFnsLocale(
|
||||
locale: ReturnType<typeof useTranslations>["locale"],
|
||||
): DateFnsLocale {
|
||||
switch (locale) {
|
||||
case "de":
|
||||
return de;
|
||||
case "es":
|
||||
return es;
|
||||
case "fr":
|
||||
return fr;
|
||||
case "it":
|
||||
return it;
|
||||
case "pl":
|
||||
return pl;
|
||||
case "ko":
|
||||
return ko;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO `yyyy-MM-dd` → a LOCAL `Date` at midnight (no UTC shift — the value is a
|
||||
* plain calendar date, so it must read back as the same day in the user's
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Concurrent writes to one account's dashboard layout blob.
|
||||
*
|
||||
* `PUT /api/dashboard/chart-overlay-prefs` read-modifies-writes
|
||||
* `User.dashboardWidgetsJson`. It used to do so in a Serializable
|
||||
* transaction, which kept parallel toggles from clobbering one another by
|
||||
* aborting all but one with a serialization failure, and every aborted one
|
||||
* answered 500. A row lock queues them instead: every request succeeds, and
|
||||
* the stored layout holds every toggle, because each writer reads after the
|
||||
* previous one committed. A reset (`DELETE /api/dashboard/widgets`) racing
|
||||
* the toggles takes the same lock and keeps the per-chart preferences.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { CHART_OVERLAY_KEYS } from "@/lib/dashboard-layout";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function seedUser(): Promise<{ userId: string }> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "chart-overlay-race-user",
|
||||
email: "chart-overlay-race@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return { userId: user.id };
|
||||
}
|
||||
|
||||
type Handler = (req: Request) => Promise<Response>;
|
||||
|
||||
function put(chartKey: string, showTrendArrow: boolean) {
|
||||
return new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chartKey,
|
||||
prefs: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function storedPrefs(userId: string) {
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
const layout = row.dashboardWidgetsJson as {
|
||||
chartOverlayPrefs?: Record<string, { showTrendArrow: boolean }>;
|
||||
} | null;
|
||||
return layout?.chartOverlayPrefs ?? {};
|
||||
}
|
||||
|
||||
describe("concurrent chart-overlay writes from one account", () => {
|
||||
it("answers 200 to every parallel PUT and keeps every chart's toggle", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } =
|
||||
await import("@/app/api/dashboard/chart-overlay-prefs/route");
|
||||
const keys = CHART_OVERLAY_KEYS.slice(0, 8);
|
||||
expect(keys.length).toBe(8);
|
||||
|
||||
const responses = await Promise.all(
|
||||
keys.map((key) => (PUT as Handler)(put(key, true))),
|
||||
);
|
||||
expect(responses.map((res) => res.status)).toEqual(keys.map(() => 200));
|
||||
|
||||
const prefs = await storedPrefs(userId);
|
||||
for (const key of keys) {
|
||||
expect(prefs[key]?.showTrendArrow, key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles parallel writes to the same chart on one of the written values", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } =
|
||||
await import("@/app/api/dashboard/chart-overlay-prefs/route");
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
(PUT as Handler)(put("weight", i % 2 === 0)),
|
||||
),
|
||||
);
|
||||
expect(responses.map((res) => res.status)).toEqual(
|
||||
Array.from({ length: 10 }, () => 200),
|
||||
);
|
||||
const prefs = await storedPrefs(userId);
|
||||
expect(typeof prefs.weight?.showTrendArrow).toBe("boolean");
|
||||
});
|
||||
|
||||
it("keeps every toggle when a layout reset races them", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { PUT } =
|
||||
await import("@/app/api/dashboard/chart-overlay-prefs/route");
|
||||
const { DELETE } = await import("@/app/api/dashboard/widgets/route");
|
||||
const keys = CHART_OVERLAY_KEYS.slice(0, 6);
|
||||
|
||||
const responses = await Promise.all([
|
||||
...keys.map((key) => (PUT as Handler)(put(key, true))),
|
||||
(DELETE as Handler)(
|
||||
new Request("http://localhost/api/dashboard/widgets", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
expect(responses.map((res) => res.status)).toEqual(
|
||||
responses.map(() => 200),
|
||||
);
|
||||
|
||||
const prefs = await storedPrefs(userId);
|
||||
for (const key of keys) {
|
||||
expect(prefs[key]?.showTrendArrow, key).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user