diff --git a/docs/README.md b/docs/README.md index 3cfade9eb..00ea3e200 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ Markdown files in this `docs/` directory are automatically mirrored to [docs.com - Auth or session behavior: [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md), [Password Auth Flow](./features/password-auth-flow.md), [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - Event shape or recurrence behavior: [Event And Task Domain Model](./architecture/event-and-task-domain-model.md), [Recurrence Handling](./features/recurring-events-handling.md) - Event caching, reads, or optimistic writes: [Event Caching](./frontend/event-caching.md) +- Dragging/resizing events on the week grid: [Week Drag Interaction](./frontend/week-drag-interaction.md) - Local-first or storage behavior: [Offline Storage And Migrations](./features/offline-storage-and-migrations.md) - Backend routes and API behavior: [Backend Route Map](./backend/README.md), [Backend Request Flow](./backend/backend-request-flow.md), [Backend Error Handling](./backend/backend-error-handling.md) @@ -26,6 +27,7 @@ Markdown files in this `docs/` directory are automatically mirrored to [docs.com - [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md) - [Event Caching](./frontend/event-caching.md) +- [Week Drag Interaction](./frontend/week-drag-interaction.md) - [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - [Password Auth Flow](./features/password-auth-flow.md) diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index 06e60c7aa..962bffcba 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -49,7 +49,7 @@ Use this document to find the first files to inspect for common Compass changes. - Day keyboard shortcuts (includes `[` toggle): `packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts` - Day view hooks: `packages/web/src/views/Day/hooks` - Week view: `packages/web/src/views/Week` -- Shared responsive sidebar state hook (`xl` breakpoint behavior): `packages/web/src/common/hooks/useSidebarState.ts` +- Responsive layout controller (auto-collapse on breakpoint crossings): `packages/web/src/common/hooks/useResponsiveLayout.ts` - Dedication dialog implementation (native `dialog` + hotkeys): `packages/web/src/views/Week/components/Dedication/Dedication.tsx` - Dedication dialog mount points: - week view: `packages/web/src/views/Week/WeekView.tsx` diff --git a/docs/development/testing-playbook.md b/docs/development/testing-playbook.md index d2a20a43e..29dac7826 100644 --- a/docs/development/testing-playbook.md +++ b/docs/development/testing-playbook.md @@ -183,19 +183,19 @@ await act(async () => { }); ``` -### Testing Responsive Sidebar State (`useSidebarState`) +### Testing Responsive Layout State (`useResponsiveLayout`) Files: -- `packages/web/src/common/hooks/useSidebarState.ts` +- `packages/web/src/common/hooks/useResponsiveLayout.ts` - `packages/web/src/views/Day/components/ShortcutsSidebar/ShortcutsSidebar.tsx` - `packages/web/src/views/Day/view/DayViewContent.tsx` Reliable setup pattern: -- set `window.innerWidth` explicitly in each test scenario (`>= 1280` for open, `< 1280` for closed) -- mock `window.matchMedia` with `addEventListener`/`removeEventListener` support +- mock `window.matchMedia` with `addEventListener`/`removeEventListener` support (mount state comes from the mocked `matches`, breakpoint crossings from `change` events) - expose a small test helper to trigger media-query changes and wrap the trigger in `act` +- assert against the view store (`selectIsSidebarOpen(useViewStore.getState())`) — the hook writes to the store rather than returning state Assertions to prefer: diff --git a/docs/frontend/frontend-runtime-flow.md b/docs/frontend/frontend-runtime-flow.md index ed152507a..67c3c80a0 100644 --- a/docs/frontend/frontend-runtime-flow.md +++ b/docs/frontend/frontend-runtime-flow.md @@ -46,7 +46,7 @@ Important behavior: `packages/web/src/views/Root.tsx`: -- blocks mobile with `MobileGate` +- blocks mobile-OS devices with `MobileGate` (`isMobileOS` user-agent check; narrow desktop windows get the responsive layout instead) - wraps authenticated layout with `UserProvider` - wires SSE listeners through `SSEProvider` @@ -243,6 +243,14 @@ The web app currently uses two styling systems in parallel: Use the existing `c-*` component utility convention and semantic colors from `packages/web/src/index.css`. Runtime theme values belong in `--compass-*` CSS variables so alternate themes can override values without rebuilding component styles. +## Week Grid Drag Interaction + +Dragging a saved event on the week/day calendar grid resolves the target day +from a layout cache built at drag start, not from the event's own date +arithmetic. See [Week Drag Interaction](./week-drag-interaction.md) for the +coordinate model and why it matters once the week view can render fewer than +7 days. + ## Day Task Drag Handle Positioning File: diff --git a/docs/frontend/week-drag-interaction.md b/docs/frontend/week-drag-interaction.md new file mode 100644 index 000000000..eaca79708 --- /dev/null +++ b/docs/frontend/week-drag-interaction.md @@ -0,0 +1,90 @@ +# Week Drag Interaction + +How dragging a saved event on the calendar grid resolves the day it lands on. + +## The one-sentence model + +**A drag column knows its own date.** The layout cache built at drag start +carries `{ index, left, width, date }` for every rendered day column, sourced +from the same React render that painted them — so drag geometry and drop +dates can never disagree with what is on screen, even mid-gesture. + +## Why this exists + +The week view renders a *window* of 1–7 day columns (not always the full +week — see [Responsive Layout](../architecture/repo-architecture.md)). +Column **index** is window-relative (`0..N-1`), but earlier code seeded a +drag's starting day from `event.startDate.getDay()` — a week-absolute value +(`0=Sun..6=Sat`). The two only agreed when 7 columns rendered starting +Sunday. Once the window could shrink or start mid-week, the mismatch caused: + +- drags that "stuck" partway across the grid (the wrong reference column + corrupted the pointer-delta math) +- drops landing on the wrong day +- both getting worse after a mid-drag edge navigation, which used to bump a + `weekOffsetDays` counter by a hardcoded `±7` — wrong whenever paging shifts + by something other than a full week + +## How it works now + +```mermaid +flowchart LR + A["React render
weekProps.weekDays
['06-29', …, '07-04']"] -->|"runtime().getVisibleDays()
(fresh every render)"| B["Drag start
layout cache columns
{index:0, date:'06-29'}
{index:1, date:'06-30'}, …"] + B -->|"pointer moves to column N"| C["visual.dayDate = column N's date"] + C -->|"pointerup"| D["Commit
dayjs(visual.dayDate).add(minutes)"] +``` + +Files: + +- `packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.ts` — + `buildCalendarDayColumns` stamps each column with its date. +- `packages/web/src/views/Week/interaction/adapter/geometry/weekLayoutCache.ts` — + builds the week's timed/all-day caches from `visibleDays: string[]`. +- `packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx` / + `SomedayInteractionCoordinator.tsx` — supply `getVisibleDays()` on the + runtime from `weekProps.component.weekDays`. +- `packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts`, + `AllDayDragVisual.ts` — visuals track `dayDate` / `initialDayDate` instead of + a day-index-plus-offset pair. + +Commit math differs by event type: + +- **Timed** events assign the target day *absolutely*: + `dayjs(visual.dayDate).startOf("day")` — safe because a timed event always + renders in the column matching its own start date. +- **All-day** events use a *date-diff delta*: + `dayjs(dayDate).diff(dayjs(initialDayDate), "day")` — required because + multi-day spans are clamped to the visible window, so the initial column's + date is the clamped visible edge, not necessarily the event's real start. + +## Mid-drag week navigation + +Dragging into the edge zone triggers `onRequestWeekNavigation`, which pages +the React window (by the visible day count, not always 7). No day-count +bookkeeping happens in the adapter — it only marks the layout cache dirty. +The pointer-engine already re-runs `updateVisual` (which rebuilds the cache) +immediately before `commit` on pointerup, so the drop always resolves against +the *freshest* rendered columns, whatever the navigation shifted. + +```mermaid +sequenceDiagram + participant Pointer as Pointer (dwell at edge) + participant Adapter as WeekInteractionAdapter + participant React as React (weekProps) + participant Cache as Layout cache + + Pointer->>Adapter: dwell exceeds threshold + Adapter->>React: onRequestWeekNavigation("next") + Adapter->>Adapter: mark layout cache dirty + React->>React: page window (shift by visible day count) + Pointer->>Adapter: pointerup + Adapter->>Cache: rebuild (updateVisual, pre-commit) + Cache-->>Adapter: fresh columns + dates + Adapter->>Adapter: commit using visual.dayDate +``` + +## Pitfall + +Do not reintroduce a day-index-only visual (no `date` field) for any new drag +interaction on the week grid — window-relative indices are only meaningful +alongside the column dates they were built from in the same render. diff --git a/e2e/navigation/responsive-layout.spec.ts b/e2e/navigation/responsive-layout.spec.ts new file mode 100644 index 000000000..30306d487 --- /dev/null +++ b/e2e/navigation/responsive-layout.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; + +// Auto-collapse only fires when a breakpoint is crossed (matchMedia change +// events), so manual toggles stick until the next crossing. Breakpoints live +// in packages/web/src/common/constants/responsive.constants.ts: +// sidebar 1280px, day-view task list 720px. + +test.describe("Responsive sidebar", () => { + test("auto-collapses on crossing 1280px and honors manual reopen", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/week"); + await expect(page.locator("#sidebar")).toBeVisible(); + + // Crossing below 1280px collapses the sidebar + await page.setViewportSize({ width: 1100, height: 900 }); + await expect(page.locator("#sidebar")).toHaveCount(0); + + // Manual reopen wins while narrow + await page.keyboard.press("["); + await expect(page.locator("#sidebar")).toBeVisible(); + + // Resizing without crossing a breakpoint keeps the manual choice + await page.setViewportSize({ width: 1000, height: 900 }); + await expect(page.locator("#sidebar")).toBeVisible(); + + // Crossing back above 1280px keeps it open + await page.setViewportSize({ width: 1300, height: 900 }); + await expect(page.locator("#sidebar")).toBeVisible(); + }); + + test("starts collapsed when loading in a narrow window", async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 900 }); + await page.goto("/week"); + await page.locator("#timedColumns").waitFor(); + await expect(page.locator("#sidebar")).toHaveCount(0); + }); +}); + +test.describe("Responsive day view task list", () => { + const taskList = '[aria-label="daily-tasks"]'; + + test("hides the task list below 720px", async ({ page }) => { + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto("/day"); + await expect(page.locator(taskList)).toBeVisible(); + + // Crossing below 720px collapses the task list (sidebar is already gone) + await page.setViewportSize({ width: 680, height: 900 }); + await expect(page.locator(taskList)).toHaveCount(0); + await expect(page.locator("#sidebar")).toHaveCount(0); + + // The daily agenda stays visible + await expect(page.locator("#mainSection")).toBeVisible(); + }); + + test("starts without the task list when loading in a narrow window", async ({ + page, + }) => { + await page.setViewportSize({ width: 680, height: 900 }); + await page.goto("/day"); + await expect(page.locator("#mainSection")).toBeVisible(); + await expect(page.locator(taskList)).toHaveCount(0); + }); +}); diff --git a/e2e/navigation/week-view-layout.spec.ts b/e2e/navigation/week-view-layout.spec.ts index 77c25fb5a..8ce11202a 100644 --- a/e2e/navigation/week-view-layout.spec.ts +++ b/e2e/navigation/week-view-layout.spec.ts @@ -1,20 +1,33 @@ import { expect, type Page, test } from "@playwright/test"; +import { getVisibleDayDates } from "../utils/event-test-utils"; -const layoutWidths = [900, 1728]; +// The week view drops days instead of squishing or scrolling them. Expected +// counts mirror computeVisibleDayCount in +// packages/web/src/views/Week/util/week-window.util.ts: +// clamp(floor((trackWidth - 50) / 140), 1, 7). +const layoutCases = [ + // 900px: sidebar auto-collapsed (<1280), track ~868px -> 5 days + { width: 900, expectedDays: 5 }, + // 1728px: sidebar open, track ~1411px -> capped at the full week + { width: 1728, expectedDays: 7 }, +]; const maxLayoutDelta = 1; -const daysInWeek = 7; test.describe("Week view layout", () => { - for (const width of layoutWidths) { - test(`aligns day headers with calendar columns at ${width}px`, async ({ + for (const { width, expectedDays } of layoutCases) { + test(`aligns ${expectedDays} day headers with calendar columns at ${width}px`, async ({ page, }) => { await page.setViewportSize({ width, height: 1000 }); await page.goto("/week"); await page.locator("#allDayColumns").waitFor(); await page.locator("#timedColumns").waitFor(); + // On load, the sidebar briefly animates toward its settled open/closed + // state (see useCollapsiblePanel); wait for the day count driven by + // that width to settle before measuring columns. + await waitForDayCount(page, expectedDays); - const layout = await getWeekColumnLayout(page); + const layout = await getWeekColumnLayout(page, expectedDays); const mainGridScrollbarWidth = await page .locator("#mainGrid") .evaluate( @@ -22,17 +35,13 @@ test.describe("Week view layout", () => { ); const horizontalScrollState = await getHorizontalScrollState(page); - expect(layout.allDayColumns).toHaveLength(daysInWeek); - expect(layout.dayLabels).toHaveLength(daysInWeek); - expect(layout.timedColumns).toHaveLength(daysInWeek); + expect(layout.allDayColumns).toHaveLength(expectedDays); + expect(layout.dayLabels).toHaveLength(expectedDays); + expect(layout.timedColumns).toHaveLength(expectedDays); expect(mainGridScrollbarWidth).toBe("0px"); expect(horizontalScrollState.scrollbarHeight).toBe("0px"); - if (width === 900) { - expect(horizontalScrollState.isScrollable).toBe(true); - await expectWeekGridCanScrollHorizontally(page); - } else { - expect(horizontalScrollState.isScrollable).toBe(false); - } + // The visible days always fit, so the grid never scrolls horizontally + expect(horizontalScrollState.isScrollable).toBe(false); for (const [index, dayLabel] of layout.dayLabels.entries()) { expectColumnsToAlign(dayLabel, layout.allDayColumns[index]); @@ -40,10 +49,72 @@ test.describe("Week view layout", () => { } }); } + + test("shows a single day anchored on today at phone-like widths", async ({ + page, + }) => { + // 320px: track ~288px -> floor((288 - 50) / 140) = 1 day + await page.setViewportSize({ width: 320, height: 800 }); + await page.goto("/week"); + await page.locator("#timedColumns").waitFor(); + await waitForDayCount(page, 1); + + const layout = await getWeekColumnLayout(page, 1); + expect(layout.dayLabels).toHaveLength(1); + expect(layout.timedColumns).toHaveLength(1); + + // The one visible day is today (title is the compact YYYYMMDD day label) + const today = new Date(); + const todayLabel = `${today.getFullYear()}${String( + today.getMonth() + 1, + ).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; + await expect( + page.locator(`#weekGridScroller [title="${todayLabel}"]`), + ).toBeVisible(); + }); + + test("pages by the visible day count with keyboard navigation", async ({ + page, + }) => { + // ~600px viewport: sidebar collapsed, track ~568px -> 3 visible days + await page.setViewportSize({ width: 600, height: 800 }); + await page.goto("/week"); + await page.locator("#timedColumns").waitFor(); + await waitForDayCount(page, 3); + + const before = await getVisibleDayDates(page); + expect(before).toHaveLength(3); + + await page.keyboard.press("k"); + await expect + .poll(async () => (await getVisibleDayDates(page))[0]) + .not.toBe(before[0]); + + const after = await getVisibleDayDates(page); + expect(after).toHaveLength(3); + expect(after).not.toEqual(before); + + await page.keyboard.press("j"); + await expect + .poll(async () => await getVisibleDayDates(page)) + .toEqual(before); + }); }); -const getWeekColumnLayout = async (page: Page) => - page.evaluate((daysInView) => { +/** + * On load, an already-open sidebar/task list briefly animates toward its + * settled state (see useCollapsiblePanel), so the grid track's width — and + * therefore the visible day count — can take a moment to reach its final + * value. Poll for it instead of asserting immediately after mount. + */ +const waitForDayCount = async (page: Page, expectedDays: number) => { + await expect + .poll(async () => (await getVisibleDayDates(page)).length) + .toBe(expectedDays); +}; + +const getWeekColumnLayout = async (page: Page, daysInView: number) => + page.evaluate((visibleDays) => { const roundRect = (rect: DOMRect) => ({ right: Math.round(rect.right * 100) / 100, width: Math.round(rect.width * 100) / 100, @@ -54,7 +125,9 @@ const getWeekColumnLayout = async (page: Page) => ...document.querySelectorAll("#weekGridScroller [title]"), ] .filter((node): node is HTMLElement => node instanceof HTMLElement) - .slice(0, daysInView) + // Day labels use the compact YYYYMMDD format; skip e.g. the now line + .filter((node) => /^\d{8}$/.test(node.title)) + .slice(0, visibleDays) .map((node) => roundRect(node.getBoundingClientRect())); const getColumns = (selector: string) => @@ -68,15 +141,14 @@ const getWeekColumnLayout = async (page: Page) => height: rect.height, }; }) - .filter((rect) => rect.height > 20) - .slice(0, daysInView); + .filter((rect) => rect.height > 20); return { allDayColumns: getColumns("#allDayColumns > div"), dayLabels, timedColumns: getColumns("#timedColumns > div"), }; - }, daysInWeek); + }, daysInView); const getHorizontalScrollState = async (page: Page) => page.locator("#weekGridScroller").evaluate((node) => { @@ -86,17 +158,6 @@ const getHorizontalScrollState = async (page: Page) => }; }); -const expectWeekGridCanScrollHorizontally = async (page: Page) => { - const scrollLeft = await page - .locator("#weekGridScroller") - .evaluate((node) => { - node.scrollLeft = node.scrollWidth; - return node.scrollLeft; - }); - - expect(scrollLeft).toBeGreaterThan(0); -}; - const expectColumnsToAlign = ( dayLabel: { right: number; width: number; x: number }, gridColumn: { right: number; width: number; x: number }, diff --git a/e2e/timed/create-event-mouse.spec.ts b/e2e/timed/create-event-mouse.spec.ts index 87611a6a6..08c67c143 100644 --- a/e2e/timed/create-event-mouse.spec.ts +++ b/e2e/timed/create-event-mouse.spec.ts @@ -6,44 +6,13 @@ import { fillTitleAndSaveEventForm, fillTitleAndSubmitEventFormWithEnter, getMainGridPoint, + getVisibleDayDates, openTimedEventFormWithMouse, prepareCalendarPage, + type StoredTimedEvent, + waitForSavedEventByTitle, } from "../utils/event-test-utils"; -interface StoredTimedEvent { - endDate?: string; - startDate?: string; - title?: string; -} - -const getSavedEventsByTitle = (page: Page, title: string) => - page.evaluate(async (eventTitle) => { - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open("compass-local"); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - }); - - try { - return await new Promise((resolve, reject) => { - const transaction = db.transaction("events", "readonly"); - const request = transaction.objectStore("events").getAll(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - resolve( - request.result.filter( - (event: StoredTimedEvent) => event.title === eventTitle, - ), - ); - }; - }); - } finally { - db.close(); - } - }, title); - const getTimedDraftEvent = (page: Page) => page.locator('#timedEvents > [role="button"]:not([data-event-id])'); @@ -58,23 +27,6 @@ const getDurationMinutes = (event: StoredTimedEvent) => { ); }; -const waitForSavedEventByTitle = async (page: Page, title: string) => { - let savedEvent: StoredTimedEvent | null = null; - - await expect - .poll(async () => { - const savedEvents = await getSavedEventsByTitle(page, title); - if (savedEvents.length === 1) { - savedEvent = savedEvents[0]!; - } - - return savedEvents.length; - }) - .toBe(1); - - return savedEvent!; -}; - test.skip( ({ isMobile }) => isMobile, "Mouse flows are desktop-only in week view.", @@ -134,17 +86,22 @@ test("keeps drag-to-create duration when the pointer moves before release", asyn await expect(getTimedDraftEvent(page)).toHaveCount(0); }); -test("starts the timed draft in the day column under the pointer after horizontal scroll", async ({ +test("starts the timed draft in the day column under the pointer at a reduced day count", async ({ page, }) => { + // 900px with the sidebar open shows a reduced day window (no horizontal + // scroll); the draft must still land in the column under the pointer. await page.setViewportSize({ width: 900, height: 1000 }); await prepareCalendarPage(page); await ensureSidebarOpen(page); - await page.locator("#weekGridScroller").evaluate((node) => { - node.scrollLeft = node.scrollWidth; - }); - const targetDayLabel = page.locator("#weekGridScroller [title]").nth(6); + const visibleDayCount = (await getVisibleDayDates(page)).length; + expect(visibleDayCount).toBeGreaterThan(1); + expect(visibleDayCount).toBeLessThan(7); + + const targetDayLabel = page + .locator("#weekGridScroller [title]") + .nth(visibleDayCount - 1); const mainGrid = page.locator("#mainGrid"); const targetBox = await targetDayLabel.boundingBox(); const gridBox = await mainGrid.boundingBox(); diff --git a/e2e/timed/move-event-reduced-days.spec.ts b/e2e/timed/move-event-reduced-days.spec.ts new file mode 100644 index 000000000..10a58c4b9 --- /dev/null +++ b/e2e/timed/move-event-reduced-days.spec.ts @@ -0,0 +1,149 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + createEventTitle, + expectTimedEventVisible, + fillTitleAndSaveEventForm, + getSavedEventsByTitle, + getVisibleDayDates, + openTimedEventFormWithMouse, + prepareCalendarPage, +} from "../utils/event-test-utils"; + +test.skip( + ({ isMobile }) => isMobile, + "Mouse flows are desktop-only in week view.", +); + +// Regression: the drag layout cache used to assume 7 day columns and seed the +// event's day index week-absolutely, so at a reduced day count the mid-drag +// visual snapped between two days and drops could land on the wrong day. +test("aligns the mid-drag visual and drops on the hovered day at a reduced day count", async ({ + page, +}) => { + // 1000px: sidebar auto-collapsed, track ~968px -> 6 visible days + await page.setViewportSize({ width: 1000, height: 1000 }); + await prepareCalendarPage(page); + + const title = createEventTitle("Move Narrow"); + await openTimedEventFormWithMouse(page); + await fillTitleAndSaveEventForm(page, title); + await expectTimedEventVisible(page, title); + + const columns = await getDayColumnBoxes(page); + expect(columns.length).toBeGreaterThan(1); + expect(columns.length).toBeLessThan(7); + + const dayDates = await getVisibleDayDates(page); + expect(dayDates).toHaveLength(columns.length); + + const savedEvent = page + .locator('#timedEvents [role="button"][data-event-id]') + .filter({ hasText: title }); + const eventBox = await savedEvent.boundingBox(); + if (!eventBox) { + throw new Error("Expected the saved event to be visible."); + } + + const eventCenterX = eventBox.x + eventBox.width / 2; + const sourceColumnIndex = columns.findIndex( + (column) => eventCenterX >= column.left && eventCenterX < column.right, + ); + // Drag toward whichever neighbor exists + const targetColumnIndex = + sourceColumnIndex + 1 < columns.length + ? sourceColumnIndex + 1 + : sourceColumnIndex - 1; + const targetColumn = columns[targetColumnIndex]; + const targetX = (targetColumn.left + targetColumn.right) / 2; + + await page.mouse.move(eventCenterX, eventBox.y + eventBox.height / 2); + await page.mouse.down(); + await page.mouse.move(targetX, eventBox.y + eventBox.height / 2 + 60, { + steps: 10, + }); + // Let the interaction engine paint the moved visual + await page.waitForTimeout(150); + + // While the mouse is still down, the interaction overlay (the moving + // visual) must sit centered inside the target column, not between two + // columns + const overlay = page.locator("[data-calendar-interaction-overlay]"); + await expect(overlay).toBeVisible(); + const overlayBox = await overlay.boundingBox(); + if (!overlayBox) { + throw new Error("Expected the drag overlay to be visible."); + } + + const overlayCenterX = overlayBox.x + overlayBox.width / 2; + const targetCenterX = (targetColumn.left + targetColumn.right) / 2; + expect(Math.abs(overlayCenterX - targetCenterX)).toBeLessThanOrEqual(5); + + await page.mouse.up(); + + // The persisted event lands on the hovered column's rendered date + await expect + .poll(async () => (await getSavedEventsByTitle(page, title))[0]?.startDate) + .toContain(dayDates[targetColumnIndex]); +}); + +test("drops on the newly rendered day after a mid-drag edge navigation", async ({ + page, +}) => { + await page.setViewportSize({ width: 1000, height: 1000 }); + await prepareCalendarPage(page); + + const title = createEventTitle("Move Across"); + await openTimedEventFormWithMouse(page); + await fillTitleAndSaveEventForm(page, title); + await expectTimedEventVisible(page, title); + + const daysBefore = await getVisibleDayDates(page); + const columns = await getDayColumnBoxes(page); + + const savedEvent = page + .locator('#timedEvents [role="button"][data-event-id]') + .filter({ hasText: title }); + const eventBox = await savedEvent.boundingBox(); + if (!eventBox) { + throw new Error("Expected the saved event to be visible."); + } + + const dragY = eventBox.y + eventBox.height / 2; + + await page.mouse.move(eventBox.x + eventBox.width / 2, dragY); + await page.mouse.down(); + + // Park the pointer inside the right edge-navigation threshold (50px) and + // dwell until the view pages to the next window + const lastColumn = columns[columns.length - 1]; + await page.mouse.move(lastColumn.right - 20, dragY, { steps: 10 }); + await expect + .poll(async () => (await getVisibleDayDates(page))[0], { timeout: 5000 }) + .not.toBe(daysBefore[0]); + + const daysAfter = await getVisibleDayDates(page); + expect(daysAfter[0]).not.toBe(daysBefore[0]); + + // Release over the second column of the new window + const newColumns = await getDayColumnBoxes(page); + const dropColumnIndex = 1; + const dropColumn = newColumns[dropColumnIndex]; + await page.mouse.move((dropColumn.left + dropColumn.right) / 2, dragY, { + steps: 5, + }); + await page.waitForTimeout(100); + await page.mouse.up(); + + // The persisted event lands on that column's newly rendered date + await expect + .poll(async () => (await getSavedEventsByTitle(page, title))[0]?.startDate) + .toContain(daysAfter[dropColumnIndex]); +}); + +const getDayColumnBoxes = async (page: Page) => + page.evaluate(() => + [...document.querySelectorAll("#timedColumns > [role='columnheader']")] + .map((node) => node.getBoundingClientRect()) + .filter((rect) => rect.width > 0) + .map((rect) => ({ left: rect.left, right: rect.right })), + ); diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index b2c9de06b..3669015ed 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -4,6 +4,73 @@ type SomedaySection = "week" | "month"; const LOCAL_DB_NAME = "compass-local"; +export interface StoredTimedEvent { + endDate?: string; + startDate?: string; + title?: string; +} + +export const getSavedEventsByTitle = (page: Page, title: string) => + page.evaluate(async (eventTitle) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("compass-local"); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + try { + return await new Promise< + { endDate?: string; startDate?: string; title?: string }[] + >((resolve, reject) => { + const transaction = db.transaction("events", "readonly"); + const request = transaction.objectStore("events").getAll(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + resolve( + request.result.filter( + (event: { title?: string }) => event.title === eventTitle, + ), + ); + }; + }); + } finally { + db.close(); + } + }, title); + +/** Rendered week day-label dates in column order, as local YYYY-MM-DD. */ +export const getVisibleDayDates = (page: Page) => + page.evaluate(() => + [...document.querySelectorAll("#weekGridScroller [title]")] + .filter((node): node is HTMLElement => node instanceof HTMLElement) + .map((node) => node.title) + // Day labels use the compact YYYYMMDD title format; skips e.g. the now line + .filter((title) => /^\d{8}$/.test(title)) + .map( + (title) => + `${title.slice(0, 4)}-${title.slice(4, 6)}-${title.slice(6, 8)}`, + ), + ); + +export const waitForSavedEventByTitle = async (page: Page, title: string) => { + let savedEvent: StoredTimedEvent | null = null; + + await expect + .poll(async () => { + const savedEvents = await getSavedEventsByTitle(page, title); + if (savedEvents.length === 1) { + savedEvent = savedEvents[0]!; + } + + return savedEvents.length; + }) + .toBe(1); + + return savedEvent! as StoredTimedEvent; +}; + // Shared timeout for form operations - use a single reasonable timeout instead of short retries const FORM_TIMEOUT = 10000; diff --git a/packages/web/.claude/settings.local.json b/packages/web/.claude/settings.local.json new file mode 100644 index 000000000..c476bdbce --- /dev/null +++ b/packages/web/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(bun test:*)", + "Bash(TZ=Etc/UTC bun packages/scripts/src/testing/run.ts:*)", + "Bash(bun run test:web:*)", + "Bash(bun -e \"const { jest } = require\\(''bun:test''\\); console.log\\(Object.keys\\(jest\\).join\\('', ''\\)\\)\")", + "Bash(git push)" + ] + } +} diff --git a/packages/web/src/common/calendar-grid/calendarGrid.constants.ts b/packages/web/src/common/calendar-grid/calendarGrid.constants.ts index 08035ca95..9f184a1df 100644 --- a/packages/web/src/common/calendar-grid/calendarGrid.constants.ts +++ b/packages/web/src/common/calendar-grid/calendarGrid.constants.ts @@ -12,6 +12,9 @@ export const CALENDAR_GRID_EVENT_TITLE_LINE_HEIGHT = "16px"; export const CALENDAR_MIN_EVENT_HEIGHT_FOR_TIME_LABEL = 36; export const CALENDAR_MIN_EVENT_WIDTH_FOR_TIME_LABEL = 90; export const CALENDAR_EVENT_WIDTH_MINIMUM = 80; +// Narrowest a day column can get before the week view drops a day instead; +// wider than CALENDAR_EVENT_WIDTH_MINIMUM so titles/time labels stay legible. +export const CALENDAR_DAY_COLUMN_MIN_USABLE_WIDTH = 140; export const CALENDAR_DECK_INDENT = 16; export const CALENDAR_DECK_RIGHT_RESERVE = 24; export const CALENDAR_DECK_MIN_WIDTH = 72; diff --git a/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.test.ts b/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.test.ts index 4fe1efb92..30fb1ac1f 100644 --- a/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.test.ts +++ b/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.test.ts @@ -1,18 +1,60 @@ import { buildCalendarDayColumns } from "./calendarLayoutCache"; import { describe, expect, it } from "bun:test"; +const weekDates = [ + "2026-06-28", + "2026-06-29", + "2026-06-30", + "2026-07-01", + "2026-07-02", + "2026-07-03", + "2026-07-04", +]; + describe("calendarLayoutCache", () => { - it("builds seven columns for Week", () => { - const columns = buildCalendarDayColumns({ left: 10, width: 700 }, 7); + it("builds seven columns for Week, each carrying its date", () => { + const columns = buildCalendarDayColumns( + { left: 10, width: 700 }, + weekDates, + ); expect(columns).toHaveLength(7); - expect(columns[0]).toMatchObject({ index: 0, left: 10, width: 100 }); - expect(columns[6]).toMatchObject({ index: 6, left: 610, width: 100 }); + expect(columns[0]).toMatchObject({ + date: "2026-06-28", + index: 0, + left: 10, + width: 100, + }); + expect(columns[6]).toMatchObject({ + date: "2026-07-04", + index: 6, + left: 610, + width: 100, + }); + }); + + it("builds a windowed subset of the week", () => { + const columns = buildCalendarDayColumns( + { left: 0, width: 300 }, + weekDates.slice(3, 6), + ); + + expect(columns).toHaveLength(3); + expect(columns[0]).toMatchObject({ date: "2026-07-01", index: 0 }); + expect(columns[2]).toMatchObject({ date: "2026-07-03", index: 2 }); }); it("builds one column for Day", () => { - const columns = buildCalendarDayColumns({ left: 20, width: 320 }, 1); + const columns = buildCalendarDayColumns({ left: 20, width: 320 }, [ + "2026-07-04", + ]); + + expect(columns).toEqual([ + { date: "2026-07-04", index: 0, left: 20, width: 320 }, + ]); + }); - expect(columns).toEqual([{ index: 0, left: 20, width: 320 }]); + it("builds no columns without dates", () => { + expect(buildCalendarDayColumns({ left: 0, width: 700 }, [])).toEqual([]); }); }); diff --git a/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.ts b/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.ts index 52decc7df..56afd4a58 100644 --- a/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.ts +++ b/packages/web/src/common/calendar-grid/interaction/calendarLayoutCache.ts @@ -15,10 +15,13 @@ export interface CalendarLayoutCacheOptions { }; timedColumnsElementId?: string; timedVisibleHours: number; - visibleDateCount: number; + /** Local YYYY-MM-DD dates of the rendered day columns, in window order. */ + visibleDates: string[]; } export interface CalendarDayColumnCache { + /** Local YYYY-MM-DD date this column renders. */ + date: string; index: number; left: number; width: number; @@ -52,7 +55,7 @@ export interface CalendarLayoutCache { interface BuildCalendarDayColumnsInput { left: number; - visibleDateCount: number; + visibleDates: string[]; width: number; } @@ -65,12 +68,12 @@ export const buildTimedCalendarLayoutCache = ({ timedColumnsElement, timedColumnsElementId, timedVisibleHours, - visibleDateCount, + visibleDates, }: CalendarLayoutCacheOptions & CalendarLayoutCacheSources): CalendarLayoutCache | null => { const mainGrid = mainGridElement ?? getElementById(mainGridElementId); - if (!mainGrid) { + if (!mainGrid || visibleDates.length === 0) { return null; } @@ -81,7 +84,7 @@ export const buildTimedCalendarLayoutCache = ({ rect; return { - dayColumns: buildCalendarDayColumns(columnsRect, visibleDateCount), + dayColumns: buildCalendarDayColumns(columnsRect, visibleDates), edgeNavigation: { bottom: rect.bottom, edgeThresholdPx, @@ -113,19 +116,19 @@ export const buildAllDayCalendarLayoutCache = ({ allDayColumnsElementId, edgeThresholdPx, snapMinutes, - visibleDateCount, + visibleDates, }: CalendarLayoutCacheOptions & CalendarLayoutCacheSources): CalendarLayoutCache | null => { const rect = getElementRect( allDayColumnsElement ?? getElementById(allDayColumnsElementId), ); - if (!rect) { + if (!rect || visibleDates.length === 0) { return null; } return { - dayColumns: buildCalendarDayColumns(rect, visibleDateCount), + dayColumns: buildCalendarDayColumns(rect, visibleDates), edgeNavigation: { bottom: rect.bottom, edgeThresholdPx, @@ -143,20 +146,23 @@ export function buildCalendarDayColumns( ): CalendarDayColumnCache[]; export function buildCalendarDayColumns( input: Pick, - visibleDateCount: number, + visibleDates: string[], ): CalendarDayColumnCache[]; export function buildCalendarDayColumns( input: BuildCalendarDayColumnsInput | Pick, - visibleDateCount?: number, + visibleDates?: string[], ): CalendarDayColumnCache[] { - const safeVisibleDateCount = Math.max( - 1, - visibleDateCount ?? - (input as BuildCalendarDayColumnsInput).visibleDateCount, - ); - const columnWidth = input.width / safeVisibleDateCount; + const dates = + visibleDates ?? (input as BuildCalendarDayColumnsInput).visibleDates; + + if (dates.length === 0) { + return []; + } + + const columnWidth = input.width / dates.length; - return Array.from({ length: safeVisibleDateCount }, (_, index) => ({ + return dates.map((date, index) => ({ + date, index, left: input.left + columnWidth * index, width: columnWidth, diff --git a/packages/web/src/common/calendar-grid/interaction/math/allDayDrag.ts b/packages/web/src/common/calendar-grid/interaction/math/allDayDrag.ts index 4595b1c9b..225be0785 100644 --- a/packages/web/src/common/calendar-grid/interaction/math/allDayDrag.ts +++ b/packages/web/src/common/calendar-grid/interaction/math/allDayDrag.ts @@ -1,11 +1,10 @@ -import { - type CalendarLayoutCache, - getNearestDayColumn, -} from "@web/common/calendar-grid/interaction/calendarLayoutCache"; +import { type CalendarLayoutCache } from "@web/common/calendar-grid/interaction/calendarLayoutCache"; import { type AllDayDragVisual } from "../model/AllDayDragVisual"; import { type VisualPoint, type VisualRect } from "../model/TimedDragVisual"; +import { resolveDragColumn } from "./resolveDragColumn"; interface CreateAllDayDragVisualInput { + dayDate: string; dayIndex: number; eventId: string; pointerStart: VisualPoint; @@ -18,45 +17,40 @@ interface UpdateAllDayDragVisualInput { } export const createAllDayDragVisual = ({ + dayDate, dayIndex, eventId, pointerStart, sourceRect, }: CreateAllDayDragVisualInput): AllDayDragVisual => ({ + dayDate, dayIndex, eventId, + initialDayDate: dayDate, initialDayIndex: dayIndex, pointerStart, sourceRect, transform: { x: 0, y: 0 }, type: "allDayDrag", - weekOffsetDays: 0, }); export const updateAllDayDragVisual = ( visual: AllDayDragVisual, { layout, pointer }: UpdateAllDayDragVisualInput, ): AllDayDragVisual => { - const deltaX = pointer.x - visual.pointerStart.x; - const initialColumn = layout.dayColumns.find( - (column) => column.index === visual.initialDayIndex, - ); - const sourceCenterX = - (initialColumn?.left ?? visual.sourceRect.left) + - (initialColumn?.width ?? visual.sourceRect.width) / 2; - const nextColumn = getNearestDayColumn( - layout.dayColumns, - sourceCenterX + deltaX, - ); - const nextDayIndex = nextColumn?.index ?? visual.initialDayIndex; - const initialColumnLeft = initialColumn?.left ?? visual.sourceRect.left; - const nextColumnLeft = nextColumn?.left ?? initialColumnLeft; + const { nextColumn, transformX } = resolveDragColumn({ + deltaX: pointer.x - visual.pointerStart.x, + initialDayIndex: visual.initialDayIndex, + layout, + sourceRect: visual.sourceRect, + }); return { ...visual, - dayIndex: nextDayIndex, + dayDate: nextColumn?.date ?? visual.dayDate, + dayIndex: nextColumn?.index ?? visual.initialDayIndex, transform: { - x: nextColumnLeft - initialColumnLeft, + x: transformX, y: 0, }, }; diff --git a/packages/web/src/common/calendar-grid/interaction/math/resolveDragColumn.ts b/packages/web/src/common/calendar-grid/interaction/math/resolveDragColumn.ts new file mode 100644 index 000000000..93c66eb5a --- /dev/null +++ b/packages/web/src/common/calendar-grid/interaction/math/resolveDragColumn.ts @@ -0,0 +1,40 @@ +import { + type CalendarLayoutCache, + getNearestDayColumn, +} from "@web/common/calendar-grid/interaction/calendarLayoutCache"; +import { type VisualRect } from "../model/TimedDragVisual"; + +/** + * Resolves which day column a horizontal drag is over, relative to the + * dragged event's own column: the initial column is looked up by its + * window-relative index (stable across mid-drag layout rebuilds), and the + * next column is whichever is nearest to the source center shifted by the + * pointer's horizontal delta. + */ +export const resolveDragColumn = ({ + deltaX, + initialDayIndex, + layout, + sourceRect, +}: { + deltaX: number; + initialDayIndex: number; + layout: CalendarLayoutCache; + sourceRect: VisualRect; +}) => { + const initialColumn = layout.dayColumns.find( + (column) => column.index === initialDayIndex, + ); + const initialColumnLeft = initialColumn?.left ?? sourceRect.left; + const sourceCenterX = + initialColumnLeft + (initialColumn?.width ?? sourceRect.width) / 2; + const nextColumn = getNearestDayColumn( + layout.dayColumns, + sourceCenterX + deltaX, + ); + + return { + nextColumn, + transformX: (nextColumn?.left ?? initialColumnLeft) - initialColumnLeft, + }; +}; diff --git a/packages/web/src/common/calendar-grid/interaction/math/timedDrag.ts b/packages/web/src/common/calendar-grid/interaction/math/timedDrag.ts index 2702adefd..5ac1ae140 100644 --- a/packages/web/src/common/calendar-grid/interaction/math/timedDrag.ts +++ b/packages/web/src/common/calendar-grid/interaction/math/timedDrag.ts @@ -1,17 +1,16 @@ -import { - type CalendarLayoutCache, - getNearestDayColumn, -} from "@web/common/calendar-grid/interaction/calendarLayoutCache"; +import { type CalendarLayoutCache } from "@web/common/calendar-grid/interaction/calendarLayoutCache"; import { type TimedDragVisual, type VisualPoint, type VisualRect, } from "../model/TimedDragVisual"; +import { resolveDragColumn } from "./resolveDragColumn"; import { clamp, snapToStep } from "./snap"; const MINUTES_PER_DAY = 24 * 60; interface CreateTimedDragVisualInput { + dayDate: string; dayIndex: number; endMinutes: number; eventId: string; @@ -27,6 +26,7 @@ interface UpdateTimedDragVisualInput { } export const createTimedDragVisual = ({ + dayDate, dayIndex, endMinutes, eventId, @@ -34,10 +34,12 @@ export const createTimedDragVisual = ({ sourceRect, startMinutes, }: CreateTimedDragVisualInput): TimedDragVisual => ({ + dayDate, dayIndex, durationMinutes: endMinutes - startMinutes, endMinutes, eventId, + initialDayDate: dayDate, initialDayIndex: dayIndex, initialEndMinutes: endMinutes, initialStartMinutes: startMinutes, @@ -46,7 +48,6 @@ export const createTimedDragVisual = ({ startMinutes, transform: { x: 0, y: 0 }, type: "timedDrag", - weekOffsetDays: 0, }); export const updateTimedDragVisual = ( @@ -66,27 +67,21 @@ export const updateTimedDragVisual = ( scrollDeltaPx, visual, }); - const initialColumn = layout.dayColumns.find( - (column) => column.index === visual.initialDayIndex, - ); - const sourceCenterX = - (initialColumn?.left ?? visual.sourceRect.left) + - (initialColumn?.width ?? visual.sourceRect.width) / 2; - const nextColumn = getNearestDayColumn( - layout.dayColumns, - sourceCenterX + deltaX, - ); - const nextDayIndex = nextColumn?.index ?? visual.initialDayIndex; - const initialColumnLeft = initialColumn?.left ?? visual.sourceRect.left; - const nextColumnLeft = nextColumn?.left ?? initialColumnLeft; + const { nextColumn, transformX } = resolveDragColumn({ + deltaX, + initialDayIndex: visual.initialDayIndex, + layout, + sourceRect: visual.sourceRect, + }); return { ...visual, - dayIndex: nextDayIndex, + dayDate: nextColumn?.date ?? visual.dayDate, + dayIndex: nextColumn?.index ?? visual.initialDayIndex, endMinutes: verticalPlacement.startMinutes + visual.durationMinutes, startMinutes: verticalPlacement.startMinutes, transform: { - x: nextColumnLeft - initialColumnLeft, + x: transformX, y: verticalPlacement.transformY, }, }; diff --git a/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts b/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts index 8c67423d8..ab70ff72e 100644 --- a/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts +++ b/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts @@ -1,12 +1,21 @@ import { type VisualPoint, type VisualRect } from "./TimedDragVisual"; +/** + * Day indices are window-relative (0..N-1 over the rendered columns) and stay + * valid across mid-drag layout rebuilds because the visible day count is + * frozen while an interaction is in motion. Day *dates* come from the layout + * cache columns, so they track mid-drag week navigation automatically. + */ export interface AllDayDragVisual { + /** Local YYYY-MM-DD date of the column currently under the drag. */ + dayDate: string; dayIndex: number; eventId: string; + /** Local YYYY-MM-DD date of the (window-clamped) source column at drag start. */ + initialDayDate: string; initialDayIndex: number; pointerStart: VisualPoint; sourceRect: VisualRect; transform: VisualPoint; type: "allDayDrag"; - weekOffsetDays: number; } diff --git a/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts b/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts index e42830a6e..b6edc8da0 100644 --- a/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts +++ b/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts @@ -10,11 +10,21 @@ export interface VisualRect { width: number; } +/** + * Day indices are window-relative (0..N-1 over the rendered columns) and stay + * valid across mid-drag layout rebuilds because the visible day count is + * frozen while an interaction is in motion. Day *dates* come from the layout + * cache columns, so they track mid-drag week navigation automatically. + */ export interface TimedDragVisual { + /** Local YYYY-MM-DD date of the column currently under the drag. */ + dayDate: string; dayIndex: number; durationMinutes: number; endMinutes: number; eventId: string; + /** Local YYYY-MM-DD date of the source column at drag start. */ + initialDayDate: string; initialDayIndex: number; initialEndMinutes: number; initialStartMinutes: number; @@ -23,5 +33,4 @@ export interface TimedDragVisual { startMinutes: number; transform: VisualPoint; type: "timedDrag"; - weekOffsetDays: number; } diff --git a/packages/web/src/common/calendar-interaction/CalendarInteractionEngine.ts b/packages/web/src/common/calendar-interaction/CalendarInteractionEngine.ts index d3a4d9d0b..cbe81a095 100644 --- a/packages/web/src/common/calendar-interaction/CalendarInteractionEngine.ts +++ b/packages/web/src/common/calendar-interaction/CalendarInteractionEngine.ts @@ -312,7 +312,9 @@ export const createCalendarInteractionEngine = ( pendingSession.target, ), ); - attachScrollSync(getNearestScrollableAncestor(pendingSession.sourceElement)); + attachScrollSync( + getNearestScrollableAncestor(pendingSession.sourceElement), + ); activatedAt = resolvedOptions.now(); latestPointer = pendingSession.startPoint; metrics.active = true; diff --git a/packages/web/src/common/constants/responsive.constants.ts b/packages/web/src/common/constants/responsive.constants.ts new file mode 100644 index 000000000..032417c35 --- /dev/null +++ b/packages/web/src/common/constants/responsive.constants.ts @@ -0,0 +1,7 @@ +// Tailwind xl breakpoint; the sidebar auto-collapses below this width. +export const SIDEBAR_AUTO_COLLAPSE_BREAKPOINT = 1280; + +// Below this width the day view can no longer fit the task list at its +// minimum width next to a usable calendar (task list 240 + divider 32 + +// calendar 320 + main-section padding + hour labels), so it auto-collapses. +export const TASK_LIST_AUTO_COLLAPSE_BREAKPOINT = 720; diff --git a/packages/web/src/common/hooks/useCollapsiblePanel.test.ts b/packages/web/src/common/hooks/useCollapsiblePanel.test.ts new file mode 100644 index 000000000..08c1fd42e --- /dev/null +++ b/packages/web/src/common/hooks/useCollapsiblePanel.test.ts @@ -0,0 +1,135 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react"; +import { + type CollapsiblePanelState, + useCollapsiblePanel, +} from "./useCollapsiblePanel"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +const flushFrames = () => { + act(() => { + for (const callback of pendingFrames.splice(0)) { + callback(0); + } + }); +}; + +let pendingFrames: FrameRequestCallback[] = []; +const originalRequestAnimationFrame = globalThis.requestAnimationFrame; +const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + +beforeEach(() => { + pendingFrames = []; + globalThis.requestAnimationFrame = mock((callback: FrameRequestCallback) => { + pendingFrames.push(callback); + return pendingFrames.length; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = mock(); +}); + +afterEach(() => { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; +}); + +const fireTransitionEnd = ( + onTransitionEnd: CollapsiblePanelState["onTransitionEnd"], + overrides: Partial<{ propertyName: string; sameTarget: boolean }> = {}, +) => { + const target = {}; + const { propertyName = "width", sameTarget = true } = overrides; + + act(() => { + onTransitionEnd({ + currentTarget: target, + propertyName, + target: sameTarget ? target : {}, + } as never); + }); +}; + +describe("useCollapsiblePanel", () => { + it("starts expanded with no entrance animation when it opens on first mount", () => { + const { result } = renderHook(() => useCollapsiblePanel(true)); + + expect(result.current.isMounted).toBe(true); + expect(result.current.isExpanded).toBe(true); + }); + + it("starts unmounted when it opens closed on first mount", () => { + const { result } = renderHook(() => useCollapsiblePanel(false)); + + expect(result.current.isMounted).toBe(false); + expect(result.current.isExpanded).toBe(false); + }); + + it("mounts immediately and expands on the next frame when opening later", () => { + const { rerender, result } = renderHook( + ({ isOpen }) => useCollapsiblePanel(isOpen), + { initialProps: { isOpen: false } }, + ); + + rerender({ isOpen: true }); + + expect(result.current.isMounted).toBe(true); + expect(result.current.isExpanded).toBe(false); + + flushFrames(); + + expect(result.current.isExpanded).toBe(true); + }); + + it("collapses immediately but stays mounted until the width transition ends", () => { + const { rerender, result } = renderHook( + ({ isOpen }) => useCollapsiblePanel(isOpen), + { initialProps: { isOpen: true } }, + ); + + rerender({ isOpen: false }); + + expect(result.current.isExpanded).toBe(false); + expect(result.current.isMounted).toBe(true); + + fireTransitionEnd(result.current.onTransitionEnd); + + expect(result.current.isMounted).toBe(false); + }); + + it("ignores bubbled transitionend events from other properties or children", () => { + const { rerender, result } = renderHook( + ({ isOpen }) => useCollapsiblePanel(isOpen), + { initialProps: { isOpen: true } }, + ); + + rerender({ isOpen: false }); + + fireTransitionEnd(result.current.onTransitionEnd, { + propertyName: "opacity", + }); + expect(result.current.isMounted).toBe(true); + + fireTransitionEnd(result.current.onTransitionEnd, { sameTarget: false }); + expect(result.current.isMounted).toBe(true); + + fireTransitionEnd(result.current.onTransitionEnd); + expect(result.current.isMounted).toBe(false); + }); + + it("does not unmount from a stale transitionend after reopening", () => { + const { rerender, result } = renderHook( + ({ isOpen }) => useCollapsiblePanel(isOpen), + { initialProps: { isOpen: true } }, + ); + + rerender({ isOpen: false }); + rerender({ isOpen: true }); + flushFrames(); + + // A transitionend that fires late from the earlier close should not + // unmount a panel that has since reopened. + fireTransitionEnd(result.current.onTransitionEnd); + + expect(result.current.isMounted).toBe(true); + expect(result.current.isExpanded).toBe(true); + }); +}); diff --git a/packages/web/src/common/hooks/useCollapsiblePanel.ts b/packages/web/src/common/hooks/useCollapsiblePanel.ts new file mode 100644 index 000000000..32fab72e6 --- /dev/null +++ b/packages/web/src/common/hooks/useCollapsiblePanel.ts @@ -0,0 +1,60 @@ +import { + type TransitionEvent, + useCallback, + useLayoutEffect, + useState, +} from "react"; + +export interface CollapsiblePanelState { + /** Whether the panel should be in the DOM at all. */ + isMounted: boolean; + /** Whether the panel's "open" (expanded) styles should be applied. */ + isExpanded: boolean; + /** Wire directly onto the element whose `width` transition drives the animation. */ + onTransitionEnd: (event: TransitionEvent) => void; +} + +/** + * Keeps a panel mounted for the duration of its collapse transition instead + * of unmounting it instantly, mirroring the open/close pattern in + * Dedication.tsx: opening flips to the expanded state on the next frame (so + * the browser has a "before" style to transition from); closing waits for + * the transition to actually finish before unmounting. On first mount, + * `isMounted`/`isExpanded` already match `isOpen`, so a panel that starts + * open never plays an entrance animation on page load. + * + * Uses `useLayoutEffect`, not `useEffect`: callers like the sidebar derive + * `isOpen` from a breakpoint check that itself corrects in a parent's + * `useLayoutEffect` right after mount (see useResponsiveLayout). Reacting in + * a passive effect would lag one paint behind that correction, so a narrow + * first load would flash the panel open before animating it closed. + * Reacting in a layout effect settles in the same pre-paint phase instead. + */ +export function useCollapsiblePanel(isOpen: boolean): CollapsiblePanelState { + const [isMounted, setIsMounted] = useState(isOpen); + const [isExpanded, setIsExpanded] = useState(isOpen); + + useLayoutEffect(() => { + if (!isOpen) { + setIsExpanded(false); + return; + } + + setIsMounted(true); + const frame = requestAnimationFrame(() => setIsExpanded(true)); + return () => cancelAnimationFrame(frame); + }, [isOpen]); + + const onTransitionEnd = useCallback( + (event: TransitionEvent) => { + if (event.target !== event.currentTarget) return; + if (event.propertyName !== "width") return; + if (isOpen) return; + + setIsMounted(false); + }, + [isOpen], + ); + + return { isExpanded, isMounted, onTransitionEnd }; +} diff --git a/packages/web/src/common/hooks/useIsMobile.ts b/packages/web/src/common/hooks/useIsMobile.ts index d77c6ded9..00cf160a3 100644 --- a/packages/web/src/common/hooks/useIsMobile.ts +++ b/packages/web/src/common/hooks/useIsMobile.ts @@ -16,6 +16,10 @@ const getInitialMobileState = (): boolean => { /** * Hook to detect if the current viewport is mobile-sized * Uses window.matchMedia with a 768px breakpoint + * + * This is a viewport check, not a device check — use it for layout decisions + * (e.g. grid columns). To detect an actual mobile device (gating), use + * isMobileOS from device.util. * @returns boolean indicating if viewport is mobile-sized */ export const useIsMobile = (): boolean => { diff --git a/packages/web/src/common/hooks/useResponsiveLayout.test.ts b/packages/web/src/common/hooks/useResponsiveLayout.test.ts new file mode 100644 index 000000000..d8ab6e394 --- /dev/null +++ b/packages/web/src/common/hooks/useResponsiveLayout.test.ts @@ -0,0 +1,249 @@ +import { act, renderHook } from "@testing-library/react"; +import { + SIDEBAR_AUTO_COLLAPSE_BREAKPOINT, + TASK_LIST_AUTO_COLLAPSE_BREAKPOINT, +} from "@web/common/constants/responsive.constants"; +import { + selectIsSidebarOpen, + selectIsTaskListOpen, + useViewStore, + viewActions, +} from "@web/events/stores/view.store"; +import { useResponsiveLayout } from "./useResponsiveLayout"; +import { afterEach, describe, expect, it, mock } from "bun:test"; + +// Helper to create a mock matchMedia list for a single query +const createMediaQueryMock = (matches: boolean) => { + const listeners: Array<(e: MediaQueryListEvent) => void> = []; + return { + matches, + media: "", + onchange: null, + addListener: mock(), + removeListener: mock(), + addEventListener: mock( + (_event: string, listener: (e: MediaQueryListEvent) => void) => { + listeners.push(listener); + }, + ), + removeEventListener: mock( + (_event: string, listener: (e: MediaQueryListEvent) => void) => { + const index = listeners.indexOf(listener); + if (index !== -1) listeners.splice(index, 1); + }, + ), + dispatchEvent: mock(), + _triggerChange: (newMatches: boolean) => { + listeners.forEach((listener) => { + listener({ matches: newMatches } as MediaQueryListEvent); + }); + }, + }; +}; + +/** + * Installs a matchMedia mock that returns a distinct media query list per + * breakpoint, so sidebar and task list crossings can be driven independently. + */ +const setupMatchMedia = ({ + sidebarMatches, + taskListMatches, +}: { + sidebarMatches: boolean; + taskListMatches: boolean; +}) => { + const sidebarQuery = createMediaQueryMock(sidebarMatches); + const taskListQuery = createMediaQueryMock(taskListMatches); + + window.matchMedia = mock((query: string) => { + if (query.includes(`${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT}px`)) { + return sidebarQuery as unknown as MediaQueryList; + } + if (query.includes(`${TASK_LIST_AUTO_COLLAPSE_BREAKPOINT}px`)) { + return taskListQuery as unknown as MediaQueryList; + } + throw new Error(`Unexpected media query: ${query}`); + }); + + return { sidebarQuery, taskListQuery }; +}; + +const getIsSidebarOpen = () => selectIsSidebarOpen(useViewStore.getState()); +const getIsTaskListOpen = () => selectIsTaskListOpen(useViewStore.getState()); + +describe("useResponsiveLayout sidebar", () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + it("should open the sidebar on mount when screen is wide (>=1280px)", () => { + setupMatchMedia({ sidebarMatches: true, taskListMatches: true }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsSidebarOpen()).toBe(true); + }); + + it("should close the sidebar on mount when screen is narrow (<1280px)", () => { + setupMatchMedia({ sidebarMatches: false, taskListMatches: true }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsSidebarOpen()).toBe(false); + }); + + it("should close the sidebar when screen resizes from wide to narrow", () => { + const { sidebarQuery } = setupMatchMedia({ + sidebarMatches: true, + taskListMatches: true, + }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsSidebarOpen()).toBe(true); + + act(() => { + sidebarQuery._triggerChange(false); + }); + + expect(getIsSidebarOpen()).toBe(false); + }); + + it("should open the sidebar when screen resizes from narrow to wide", () => { + const { sidebarQuery } = setupMatchMedia({ + sidebarMatches: false, + taskListMatches: true, + }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsSidebarOpen()).toBe(false); + + act(() => { + sidebarQuery._triggerChange(true); + }); + + expect(getIsSidebarOpen()).toBe(true); + }); + + it("should keep a manual toggle until the next breakpoint crossing", () => { + const { sidebarQuery } = setupMatchMedia({ + sidebarMatches: false, + taskListMatches: true, + }); + + const { rerender } = renderHook(() => useResponsiveLayout()); + + expect(getIsSidebarOpen()).toBe(false); + + // User reopens the sidebar while narrow; no crossing happens, so it sticks + act(() => { + viewActions.toggleSidebar(); + }); + rerender(); + + expect(getIsSidebarOpen()).toBe(true); + + // The next crossing takes over again + act(() => { + sidebarQuery._triggerChange(true); + }); + act(() => { + sidebarQuery._triggerChange(false); + }); + + expect(getIsSidebarOpen()).toBe(false); + }); + + it("should remove the media query listeners on unmount", () => { + const { sidebarQuery, taskListQuery } = setupMatchMedia({ + sidebarMatches: true, + taskListMatches: true, + }); + + const { unmount } = renderHook(() => useResponsiveLayout()); + unmount(); + + expect(sidebarQuery.removeEventListener).toHaveBeenCalledTimes(1); + expect(taskListQuery.removeEventListener).toHaveBeenCalledTimes(1); + + act(() => { + sidebarQuery._triggerChange(false); + }); + + expect(getIsSidebarOpen()).toBe(true); + }); +}); + +describe("useResponsiveLayout task list", () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + it("should close the task list on mount when screen is narrower than its breakpoint", () => { + setupMatchMedia({ sidebarMatches: false, taskListMatches: false }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsTaskListOpen()).toBe(false); + }); + + it("should collapse the sidebar before the task list as the screen shrinks", () => { + const { sidebarQuery, taskListQuery } = setupMatchMedia({ + sidebarMatches: true, + taskListMatches: true, + }); + + renderHook(() => useResponsiveLayout()); + + // Crossing below 1280px collapses only the sidebar + act(() => { + sidebarQuery._triggerChange(false); + }); + + expect(getIsSidebarOpen()).toBe(false); + expect(getIsTaskListOpen()).toBe(true); + + // Crossing below the task list breakpoint collapses the task list too + act(() => { + taskListQuery._triggerChange(false); + }); + + expect(getIsTaskListOpen()).toBe(false); + }); + + it("should reopen the task list when crossing back above its breakpoint", () => { + const { taskListQuery } = setupMatchMedia({ + sidebarMatches: false, + taskListMatches: false, + }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsTaskListOpen()).toBe(false); + + act(() => { + taskListQuery._triggerChange(true); + }); + + expect(getIsTaskListOpen()).toBe(true); + }); + + it("should keep a manual task list toggle until the next crossing", () => { + setupMatchMedia({ sidebarMatches: false, taskListMatches: false }); + + renderHook(() => useResponsiveLayout()); + + expect(getIsTaskListOpen()).toBe(false); + + act(() => { + viewActions.toggleTaskList(); + }); + + expect(getIsTaskListOpen()).toBe(true); + }); +}); diff --git a/packages/web/src/common/hooks/useResponsiveLayout.ts b/packages/web/src/common/hooks/useResponsiveLayout.ts new file mode 100644 index 000000000..cdf170bf2 --- /dev/null +++ b/packages/web/src/common/hooks/useResponsiveLayout.ts @@ -0,0 +1,40 @@ +import { useLayoutEffect } from "react"; +import { + SIDEBAR_AUTO_COLLAPSE_BREAKPOINT, + TASK_LIST_AUTO_COLLAPSE_BREAKPOINT, +} from "@web/common/constants/responsive.constants"; +import { viewActions } from "@web/events/stores/view.store"; + +/** + * Syncs panel visibility in the view store with the window size. Panels + * auto-collapse/reopen only when a breakpoint is crossed, so a manual toggle + * sticks until the next crossing. Mount once per app (AuthenticatedLayout) so + * overrides survive view switches. + */ +export function useResponsiveLayout() { + useLayoutEffect(() => { + const panelQueries = [ + { + query: window.matchMedia( + `(min-width: ${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT}px)`, + ), + setOpen: viewActions.setSidebarOpen, + }, + { + query: window.matchMedia( + `(min-width: ${TASK_LIST_AUTO_COLLAPSE_BREAKPOINT}px)`, + ), + setOpen: viewActions.setTaskListOpen, + }, + ]; + + const cleanups = panelQueries.map(({ query, setOpen }) => { + setOpen(query.matches); + const onChange = (e: MediaQueryListEvent) => setOpen(e.matches); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }); + + return () => cleanups.forEach((cleanup) => cleanup()); + }, []); +} diff --git a/packages/web/src/common/hooks/useSidebarState.test.ts b/packages/web/src/common/hooks/useSidebarState.test.ts deleted file mode 100644 index 74d777370..000000000 --- a/packages/web/src/common/hooks/useSidebarState.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { useSidebarState } from "./useSidebarState"; -import { afterEach, describe, expect, it, mock } from "bun:test"; - -// Helper to create a mock matchMedia -const createMatchMedia = (matches: boolean) => { - const listeners: Array<(e: MediaQueryListEvent) => void> = []; - return { - matches, - media: "", - onchange: null, - addListener: mock(), - removeListener: mock(), - addEventListener: mock( - (_event: string, listener: (e: MediaQueryListEvent) => void) => { - listeners.push(listener); - }, - ), - removeEventListener: mock(), - dispatchEvent: mock(), - _triggerChange: (newMatches: boolean) => { - listeners.forEach((listener) => { - listener({ matches: newMatches } as MediaQueryListEvent); - }); - }, - }; -}; - -describe("useSidebarState", () => { - const originalMatchMedia = window.matchMedia; - const originalInnerWidth = window.innerWidth; - - afterEach(() => { - window.matchMedia = originalMatchMedia; - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: originalInnerWidth, - }); - }); - - it("should return isOpen true when screen is wide (>=1280px)", () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1400, - }); - const mockMediaQuery = createMatchMedia(true); - window.matchMedia = mock().mockReturnValue(mockMediaQuery); - - const { result } = renderHook(() => useSidebarState()); - - expect(result.current.isSidebarOpen).toBe(true); - }); - - it("should return isOpen false when screen is narrow (<1280px)", () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1000, - }); - const mockMediaQuery = createMatchMedia(false); - window.matchMedia = mock().mockReturnValue(mockMediaQuery); - - const { result } = renderHook(() => useSidebarState()); - - expect(result.current.isSidebarOpen).toBe(false); - }); - - it("should toggle sidebar state when toggleSidebar is called", () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1400, - }); - const mockMediaQuery = createMatchMedia(true); - window.matchMedia = mock().mockReturnValue(mockMediaQuery); - - const { result } = renderHook(() => useSidebarState()); - - expect(result.current.isSidebarOpen).toBe(true); - - act(() => { - result.current.toggleSidebar(); - }); - - expect(result.current.isSidebarOpen).toBe(false); - - act(() => { - result.current.toggleSidebar(); - }); - - expect(result.current.isSidebarOpen).toBe(true); - }); - - it("should close sidebar when screen resizes from wide to narrow", () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1400, - }); - const mockMediaQuery = createMatchMedia(true); - window.matchMedia = mock().mockReturnValue(mockMediaQuery); - - const { result } = renderHook(() => useSidebarState()); - - expect(result.current.isSidebarOpen).toBe(true); - - act(() => { - mockMediaQuery._triggerChange(false); - }); - - expect(result.current.isSidebarOpen).toBe(false); - }); - - it("should open sidebar when screen resizes from narrow to wide", () => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1000, - }); - const mockMediaQuery = createMatchMedia(false); - window.matchMedia = mock().mockReturnValue(mockMediaQuery); - - const { result } = renderHook(() => useSidebarState()); - - expect(result.current.isSidebarOpen).toBe(false); - - act(() => { - mockMediaQuery._triggerChange(true); - }); - - expect(result.current.isSidebarOpen).toBe(true); - }); -}); diff --git a/packages/web/src/common/hooks/useSidebarState.ts b/packages/web/src/common/hooks/useSidebarState.ts deleted file mode 100644 index 23a3a83d0..000000000 --- a/packages/web/src/common/hooks/useSidebarState.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -// Tailwind xl breakpoint -const XL_BREAKPOINT = 1280; - -/** - * Hook to manage collapsible sidebar state with responsive behavior. - * - Auto-collapses when screen is below xl breakpoint (1280px) - * - Auto-expands when screen is at or above xl breakpoint - * - Provides toggle function for manual control - */ -export function useSidebarState() { - const [isOpen, setIsOpen] = useState( - () => window.innerWidth >= XL_BREAKPOINT, - ); - - // Auto-collapse/expand sidebar based on screen width - useEffect(() => { - const mediaQuery = window.matchMedia(`(min-width: ${XL_BREAKPOINT}px)`); - - const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { - setIsOpen(e.matches); - }; - - // Check initial state - handleChange(mediaQuery); - - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, []); - - const toggle = useCallback(() => { - setIsOpen((prev) => !prev); - }, []); - - return { - isSidebarOpen: isOpen, - toggleSidebar: toggle, - }; -} diff --git a/packages/web/src/common/shortcuts/data/shortcuts.data.ts b/packages/web/src/common/shortcuts/data/shortcuts.data.ts index 460a4b669..682e362fe 100644 --- a/packages/web/src/common/shortcuts/data/shortcuts.data.ts +++ b/packages/web/src/common/shortcuts/data/shortcuts.data.ts @@ -74,7 +74,7 @@ const getEditShortcuts = (view: ShortcutMenuView): Shortcut[] => { keys: ["Arrow keys"], label: "Move draft event" }, ]; -const otherShortcuts: Shortcut[] = [ +const getOtherShortcuts = (): Shortcut[] => [ { keys: ["["], label: "Toggle sidebar" }, { keys: ["?"], label: "Toggle shortcuts" }, { keys: ["Mod", "k"], label: "Command Palette" }, @@ -99,6 +99,6 @@ export const getShortcutMenuSections = ( { id: "create", title: "Create", shortcuts: getCreateShortcuts(view) }, { id: "focus", title: "Focus", shortcuts: getFocusShortcuts(view) }, { id: "edit", title: "Edit", shortcuts: getEditShortcuts(view) }, - { id: "other", title: "Other", shortcuts: otherShortcuts }, + { id: "other", title: "Other", shortcuts: getOtherShortcuts() }, ]; }; diff --git a/packages/web/src/common/utils/device/device.util.test.ts b/packages/web/src/common/utils/device/device.util.test.ts new file mode 100644 index 000000000..65780c713 --- /dev/null +++ b/packages/web/src/common/utils/device/device.util.test.ts @@ -0,0 +1,62 @@ +import { isMobileOS } from "@web/common/utils/device/device.util"; +import { afterEach, describe, expect, it } from "bun:test"; + +const UA = { + android: + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Mobile Safari/537.36", + iphone: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + ipad: "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36", +} as const; + +const originalUserAgent = navigator.userAgent; + +const setUserAgent = (value: string) => { + Object.defineProperty(window.navigator, "userAgent", { + value, + configurable: true, + writable: true, + }); +}; + +const setUserAgentData = (value: { mobile: boolean } | undefined) => { + Object.defineProperty(window.navigator, "userAgentData", { + value, + configurable: true, + writable: true, + }); +}; + +afterEach(() => { + setUserAgent(originalUserAgent); + setUserAgentData(undefined); +}); + +describe("isMobileOS", () => { + it("prefers userAgentData.mobile when available", () => { + setUserAgent(UA.mac); + setUserAgentData({ mobile: true }); + expect(isMobileOS()).toBe(true); + + setUserAgentData({ mobile: false }); + expect(isMobileOS()).toBe(false); + }); + + it("detects Android and iPhone user agents", () => { + setUserAgent(UA.android); + expect(isMobileOS()).toBe(true); + + setUserAgent(UA.iphone); + expect(isMobileOS()).toBe(true); + }); + + it("treats desktop and iPad user agents as not mobile", () => { + setUserAgent(UA.mac); + expect(isMobileOS()).toBe(false); + + // iPads get the responsive layout, not the gate + setUserAgent(UA.ipad); + expect(isMobileOS()).toBe(false); + }); +}); diff --git a/packages/web/src/common/utils/device/device.util.ts b/packages/web/src/common/utils/device/device.util.ts new file mode 100644 index 000000000..205b3ad86 --- /dev/null +++ b/packages/web/src/common/utils/device/device.util.ts @@ -0,0 +1,27 @@ +interface NavigatorUAData { + mobile: boolean; +} + +type NavigatorWithUAData = Navigator & { + userAgentData?: NavigatorUAData; +}; + +/** + * Whether the app is running on a mobile OS (phone), based on the user agent + * rather than the window size — narrow desktop windows get the responsive + * layout instead of the mobile gate. iPads (including desktop-mode Safari, + * which reports a Mac UA with touch support) are treated as desktop since the + * responsive layout serves them better than the gate. + */ +export const isMobileOS = (): boolean => { + if (typeof navigator === "undefined") { + return false; + } + + const uaData = (navigator as NavigatorWithUAData).userAgentData; + if (typeof uaData?.mobile === "boolean") { + return uaData.mobile; + } + + return /Android|iPhone|iPod/i.test(navigator.userAgent); +}; diff --git a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx index 01c5c1c64..4927d8099 100644 --- a/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx +++ b/packages/web/src/components/AuthenticatedLayout/AuthenticatedLayout.tsx @@ -1,9 +1,11 @@ import { Outlet } from "react-router-dom"; +import { useResponsiveLayout } from "@web/common/hooks/useResponsiveLayout"; /** * Layout component for authenticated routes * Handles shared logic like data refetching that should run for all authenticated views */ export const AuthenticatedLayout = () => { + useResponsiveLayout(); return ; }; diff --git a/packages/web/src/components/CollapsiblePanel/CollapsiblePanel.tsx b/packages/web/src/components/CollapsiblePanel/CollapsiblePanel.tsx new file mode 100644 index 000000000..0fe484cd8 --- /dev/null +++ b/packages/web/src/components/CollapsiblePanel/CollapsiblePanel.tsx @@ -0,0 +1,32 @@ +import { type PropsWithChildren } from "react"; +import { useCollapsiblePanel } from "@web/common/hooks/useCollapsiblePanel"; + +interface Props extends PropsWithChildren { + isOpen: boolean; + /** Rendered width when expanded, in px. */ + width: number; +} + +/** + * Width-collapsing wrapper: mounts/unmounts its children through a subtle + * width transition (see useCollapsiblePanel). Children should render at + * their natural fixed width; the wrapper clips them during the slide. + */ +export function CollapsiblePanel({ children, isOpen, width }: Props) { + const { isExpanded, isMounted, onTransitionEnd } = + useCollapsiblePanel(isOpen); + + if (!isMounted) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/SomedayInteractionCoordinator.tsx b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/SomedayInteractionCoordinator.tsx index 57eebb383..6fe61582e 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/SomedayInteractionCoordinator.tsx +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/SomedayInteractionCoordinator.tsx @@ -1,14 +1,9 @@ -import { - type FC, - type PropsWithChildren, - useLayoutEffect, - useMemo, - useRef, -} from "react"; +import { type FC, type PropsWithChildren, useMemo, useRef } from "react"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; import { useSidebarContext } from "@web/components/PlannerSidebar/draft/context/useSidebarContext"; import { type WeekProps } from "@web/views/Week/hooks/useWeek"; import { type WeekLayoutCacheSources } from "@web/views/Week/interaction/adapter/geometry/weekLayoutCache"; +import { useWeekInteractionLayoutSync } from "@web/views/Week/interaction/useWeekInteractionLayoutSync"; import { createSomedayInteractionAdapter } from "./adapter/SomedayInteractionAdapter"; import { type SomedayInteractionRuntime } from "./adapter/SomedayInteractionAdapter.types"; import { markSomedayCommitAcknowledgement } from "./state/somedayCommitAcknowledgementState"; @@ -25,12 +20,12 @@ export const SomedayInteractionCoordinator: FC = ({ }) => { const { actions, state } = useSidebarContext(); const layoutSourcesRef = useRef(getLayoutSources); - const viewStartRef = useRef(weekProps.component.startOfView); const eventsById = useMemo(() => { return new Map(Object.entries(state.somedayEvents.events)); }, [state.somedayEvents.events]); const runtimeRef = useRef({ getSomedayEventById: () => null, + getVisibleDays: () => [], onClickSomedayEvent: () => undefined, onCommitSomedayInteraction: () => undefined, }); @@ -38,30 +33,17 @@ export const SomedayInteractionCoordinator: FC = ({ () => createSomedayInteractionAdapter({ getLayoutSources: () => layoutSourcesRef.current?.() ?? {}, - getViewStart: () => viewStartRef.current, runtime: () => runtimeRef.current, }), [], ); - const lastNavigationSource = weekProps.util.getLastNavigationSource(); - const renderedWeekStartMs = weekProps.component.startOfView.valueOf(); + const visibleDayKeys = useWeekInteractionLayoutSync(adapter, weekProps); layoutSourcesRef.current = getLayoutSources; - viewStartRef.current = weekProps.component.startOfView; - - useLayoutEffect(() => { - if ( - lastNavigationSource !== "drag-to-edge" || - !Number.isFinite(renderedWeekStartMs) - ) { - return; - } - - adapter.rebuildLayoutAfterNavigation(); - }, [adapter, lastNavigationSource, renderedWeekStartMs]); runtimeRef.current = { getSomedayEventById: (eventId) => eventsById.get(eventId) ?? null, + getVisibleDays: () => visibleDayKeys, isSidebarDropAllowed: actions.isSomedaySidebarDropAllowed, onCancelInteraction: actions.cancelSomedayInteraction, onClickSomedayEvent: actions.onDraft, diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.test.ts b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.test.ts index 1326882a1..b1f81ad46 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.test.ts +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.test.ts @@ -104,9 +104,11 @@ const setReducedMotionPreference = (matches: boolean) => { const createHarness = ({ mainGridScrollTop = 0, viewStart = dayjs("2026-05-17"), + visibleDays, }: { mainGridScrollTop?: number; viewStart?: ReturnType; + visibleDays?: string[]; } = {}) => { document.body.innerHTML = ""; somedayDropTargetRegistry.clear(); @@ -114,6 +116,11 @@ const createHarness = ({ let now = 100; let nextFrameId = 1; + let currentVisibleDays = + visibleDays ?? + [...Array(7)].map((_, index) => + viewStart.add(index, "day").format("YYYY-MM-DD"), + ); const frameCallbacks = new Map(); const timerCallbacks = new Map void>(); const event = createSomedayEvent(); @@ -218,9 +225,9 @@ const createHarness = ({ mainGridElement: mainGrid, timedColumnsElement: timedColumns, }), - getViewStart: () => viewStart, runtime: () => ({ getSomedayEventById: (eventId) => (eventId === event._id ? event : null), + getVisibleDays: () => currentVisibleDays, onCancelInteraction, onClickSomedayEvent, onCommitSomedayInteraction, @@ -230,6 +237,10 @@ const createHarness = ({ }), }); + const setVisibleDays = (nextVisibleDays: string[]) => { + currentVisibleDays = nextVisibleDays; + }; + const flushFrame = (timestamp = 16) => { const [[frameId, callback]] = frameCallbacks; @@ -252,6 +263,7 @@ const createHarness = ({ onMotionActivation, onPreviewSomedaySidebarDrop, onRequestWeekNavigation, + setVisibleDays, source, sourceButton, sourceChild, @@ -348,6 +360,45 @@ describe("SomedayInteractionAdapter", () => { }); }); + it("schedules a timed drop on the column's date in a windowed view with an offset", () => { + // 3-day window Wed..Fri (offset 3): the drop column's date is 2026-05-20, + // not viewStart + column index (the old math ignored the window offset). + const { + adapter, + flushFrame, + onCommitSomedayInteraction, + sourceChild, + timedColumns, + } = createHarness({ + visibleDays: ["2026-05-20", "2026-05-21", "2026-05-22"], + }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: sourceChild, x: 20, y: 12 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { + target: timedColumns, + x: 250, + y: 220, + }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: timedColumns, x: 250, y: 220 }), + ); + + expect(onCommitSomedayInteraction).toHaveBeenCalledWith({ + dates: { + endDate: expect.stringContaining("2026-05-20T03:00"), + startDate: expect.stringContaining("2026-05-20T02:00"), + }, + eventId: "someday-event", + isAllDay: false, + type: "schedule", + }); + }); + it("accounts for the main grid scroll position when scheduling a timed drop", () => { const { adapter, diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.ts b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.ts index c40199e23..7b3c51c29 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.ts +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.ts @@ -1,6 +1,7 @@ import { Priorities } from "@core/constants/core.constants"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import { Categories_Event, type Schema_Event } from "@core/types/event.types"; +import dayjs from "@core/util/date/dayjs"; import { type CalendarInteractionAdapter } from "@web/common/calendar-interaction/CalendarInteractionAdapter"; import { type CalendarInteractionCancellationTargets, @@ -23,12 +24,8 @@ import { getNearestDayColumn, type WeekDayColumnCache, type WeekLayoutCache, - type WeekLayoutCacheSources, } from "@web/views/Week/interaction/adapter/geometry/weekLayoutCache"; -import { - createWeekEdgeNavigationController, - type WeekEdgeNavigationSide, -} from "@web/views/Week/interaction/adapter/weekEdgeNavigation"; +import { createWeekEdgeNavigationController } from "@web/views/Week/interaction/adapter/weekEdgeNavigation"; import { resetWeekInteractionEdgeNavigationState, setWeekInteractionEdgeNavigationState, @@ -98,6 +95,7 @@ const SOMEDAY_TIME_LABEL_SELECTOR = const inertRuntime: SomedayInteractionRuntime = { getSomedayEventById: () => null, + getVisibleDays: () => [], onClickSomedayEvent: () => undefined, onCommitSomedayInteraction: () => undefined, }; @@ -112,7 +110,6 @@ const activeEdgeNavigationIndicatorState = { export const createSomedayInteractionAdapter = ({ engineOptions, getLayoutSources = () => ({}), - getViewStart, runtime = () => inertRuntime, }: SomedayInteractionAdapterOptions): SomedayInteractionAdapter => { const edgeNavigation = createWeekEdgeNavigationController(); @@ -256,12 +253,10 @@ export const createSomedayInteractionAdapter = ({ return { drop: null, eventId: target.event._id!, - initialViewStart: getViewStart(), pointerStart, sourceIndex: target.registered.index, sourceRect, transform: { x: 0, y: 0 }, - weekOffsetDays: 0, }; }, getOverlayMount: ({ sourceElement, target }) => ({ @@ -314,8 +309,7 @@ export const createSomedayInteractionAdapter = ({ return { overlay: { height: overlayRect?.height, - mutate: (node) => - mutateOverlay(node, nextDrop, target.event, nextVisual), + mutate: (node) => mutateOverlay(node, nextDrop, target.event), transform: nextVisual.transform, width: overlayRect?.width, }, @@ -417,6 +411,7 @@ export const createSomedayInteractionAdapter = ({ } return { + date: column.date, dayIndex: column.index, type: "allDay", }; @@ -450,6 +445,7 @@ export const createSomedayInteractionAdapter = ({ ); return { + date: column.date, dayIndex: column.index, endMinutes: startMinutes + ONE_HOUR_MINUTES, startMinutes, @@ -502,9 +498,8 @@ export const createSomedayInteractionAdapter = ({ } if (drop.type === "allDay") { - const start = visual.initialViewStart - .add(visual.weekOffsetDays + drop.dayIndex, "day") - .startOf("day"); + // The drop column carries its own date, so no view-start arithmetic + const start = dayjs(drop.date).startOf("day"); return { dates: { @@ -517,8 +512,7 @@ export const createSomedayInteractionAdapter = ({ }; } - const start = visual.initialViewStart - .add(visual.weekOffsetDays + drop.dayIndex, "day") + const start = dayjs(drop.date) .startOf("day") .add(drop.startMinutes, "minutes"); @@ -607,17 +601,14 @@ export const createSomedayInteractionAdapter = ({ setWeekInteractionEdgeNavigationState(update.state); if (update.requestedSide) { + // No day bookkeeping: the pending layout rebuild carries the new column + // dates, and the next drop resolution reads them directly. isLayoutRebuildPending = true; runtime().onRequestWeekNavigation?.(update.requestedSide); return { isDwellActive: false, - visual: { - ...visual, - weekOffsetDays: - visual.weekOffsetDays + - getWeekOffsetDaysDelta(update.requestedSide), - }, + visual, }; } @@ -639,11 +630,13 @@ export const createSomedayInteractionAdapter = ({ return null; } - function rebuildLayouts( - sources: WeekLayoutCacheSources = getLayoutSources(), - ) { - allDayLayout = buildAllDayWeekLayoutCache(sources); - timedLayout = buildTimedWeekLayoutCache(sources); + function rebuildLayouts() { + const input = { + ...getLayoutSources(), + visibleDays: runtime().getVisibleDays(), + }; + allDayLayout = buildAllDayWeekLayoutCache(input); + timedLayout = buildTimedWeekLayoutCache(input); } function clearInteractionState() { @@ -736,7 +729,6 @@ const mutateOverlay = ( node: HTMLElement, drop: SomedayInteractionDrop | null, event: Schema_Event, - visual: SomedayInteractionVisual, ) => { node.style.overflow = "hidden"; @@ -795,7 +787,7 @@ const mutateOverlay = ( } const titleRow = applyTimedOverlayLayout(node); - const { end, start } = getTimedDropDateRange(drop, visual); + const { end, start } = getTimedDropDateRange(drop); const timeLabel = getOrCreateOverlayTimeLabel(node, titleRow); @@ -808,12 +800,8 @@ const isReducedMotionPreferred = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; -const getTimedDropDateRange = ( - drop: SomedayTimedDrop, - visual: SomedayInteractionVisual, -) => { - const start = visual.initialViewStart - .add(visual.weekOffsetDays + drop.dayIndex, "day") +const getTimedDropDateRange = (drop: SomedayTimedDrop) => { + const start = dayjs(drop.date) .startOf("day") .add(drop.startMinutes, "minutes"); @@ -994,9 +982,6 @@ const getSidebarPreviewKey = ({ }: SomedaySidebarCommitResult) => `${eventId}:${source.droppableId}:${source.index}->${destination.droppableId}:${destination.index}`; -const getWeekOffsetDaysDelta = (side: WeekEdgeNavigationSide) => - side === "next" ? 7 : -7; - const isInteractiveChild = (target: EventTarget | null) => target instanceof Element && Boolean( diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.types.ts b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.types.ts index aa87ba78a..596e5e150 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.types.ts +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.types.ts @@ -1,5 +1,4 @@ import { type Schema_Event } from "@core/types/event.types"; -import { type Dayjs } from "@core/util/date/dayjs"; import { type CalendarInteractionCancellationTargets, type CalendarInteractionEngineSchedulerOptions, @@ -18,12 +17,17 @@ export interface SomedayInteractionPointerOwnership { export interface SomedayInteractionAdapterOptions { engineOptions?: CalendarInteractionEngineSchedulerOptions; getLayoutSources?: () => WeekLayoutCacheSources; - getViewStart: () => Dayjs; runtime?: () => SomedayInteractionRuntime; } export interface SomedayInteractionRuntime { getSomedayEventById(eventId: string): Schema_Event | null; + /** + * Local YYYY-MM-DD dates of the rendered day columns, in window order. + * Sourced from the same React render that painted the columns so drop + * dates always agree with what is on screen. + */ + getVisibleDays(): string[]; isSidebarDropAllowed?: (result: SomedaySidebarCommitResult) => boolean; onCancelInteraction?: () => void; onClickSomedayEvent( @@ -51,6 +55,9 @@ export interface SomedaySidebarDrop { } export interface SomedayTimedDrop { + /** Local YYYY-MM-DD date of the drop column. */ + date: string; + /** Window-relative column index; used for overlay geometry only. */ dayIndex: number; endMinutes: number; startMinutes: number; @@ -58,6 +65,9 @@ export interface SomedayTimedDrop { } export interface SomedayAllDayDrop { + /** Local YYYY-MM-DD date of the drop column. */ + date: string; + /** Window-relative column index; used for overlay geometry only. */ dayIndex: number; type: "allDay"; } @@ -70,7 +80,6 @@ export type SomedayInteractionDrop = export interface SomedayInteractionVisual { drop: SomedayInteractionDrop | null; eventId: string; - initialViewStart: Dayjs; pointerStart: { x: number; y: number; @@ -86,7 +95,6 @@ export interface SomedayInteractionVisual { x: number; y: number; }; - weekOffsetDays: number; } export interface SomedayScheduleCommitResult { diff --git a/packages/web/src/events/stores/view.store.ts b/packages/web/src/events/stores/view.store.ts index ab049671f..599be30c3 100644 --- a/packages/web/src/events/stores/view.store.ts +++ b/packages/web/src/events/stores/view.store.ts @@ -11,6 +11,9 @@ interface ViewState { sidebar: { isOpen: boolean; }; + taskList: { + isOpen: boolean; + }; } export const initialViewState: ViewState = { @@ -19,6 +22,7 @@ export const initialViewState: ViewState = { end: dayjs().endOf("week").format(), }, sidebar: { isOpen: true }, + taskList: { isOpen: true }, }; // Selectors passed to this hook must return primitives or stable references; @@ -31,15 +35,30 @@ export const useViewStore = create()( ); export const viewActions = { + setSidebarOpen: (isOpen: boolean) => + useViewStore.setState({ sidebar: { isOpen } }, false, { + type: "setSidebarOpen", + }), toggleSidebar: () => useViewStore.setState( (state) => ({ sidebar: { isOpen: !state.sidebar.isOpen } }), false, { type: "toggleSidebar" }, ), + setTaskListOpen: (isOpen: boolean) => + useViewStore.setState({ taskList: { isOpen } }, false, { + type: "setTaskListOpen", + }), + toggleTaskList: () => + useViewStore.setState( + (state) => ({ taskList: { isOpen: !state.taskList.isOpen } }), + false, + { type: "toggleTaskList" }, + ), updateDates: (dates: ViewState["dates"]) => useViewStore.setState({ dates }, false, { type: "updateDates" }), }; export const selectDatesInView = (state: ViewState) => state.dates; export const selectIsSidebarOpen = (state: ViewState) => state.sidebar.isOpen; +export const selectIsTaskListOpen = (state: ViewState) => state.taskList.isOpen; diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts b/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts index b62d3277b..afa5e81b4 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts @@ -31,9 +31,6 @@ interface KeyboardShortcutsConfig { // General onEscape?: () => void; - // Sidebar - onToggleSidebar?: () => void; - // Calendar navigation onFocusCalendar?: () => void; @@ -62,7 +59,6 @@ export function useDayViewShortcuts(config: KeyboardShortcutsConfig) { onGoToToday, onFocusCalendar, onEditEvent, - onToggleSidebar, hasFocusedTask, } = config; @@ -108,11 +104,6 @@ export function useDayViewShortcuts(config: KeyboardShortcutsConfig) { onGoToToday?.(); }); - // Sidebar shortcut - useAppHotkeyUp("[", () => { - onToggleSidebar?.(); - }); - // Tasks shortcuts useAppHotkeyUp("U", () => { onFocusTasks?.(); diff --git a/packages/web/src/views/Day/interaction/adapter/DayInteractionAdapter.ts b/packages/web/src/views/Day/interaction/adapter/DayInteractionAdapter.ts index 8253b1882..aeda12f5c 100644 --- a/packages/web/src/views/Day/interaction/adapter/DayInteractionAdapter.ts +++ b/packages/web/src/views/Day/interaction/adapter/DayInteractionAdapter.ts @@ -85,7 +85,6 @@ export type { DayTimedResizeCommitResult, } from "./DayInteractionAdapter.types"; -const DAY_VISIBLE_DATE_COUNT = 1; const DAY_SMART_SCROLL_EDGE_THRESHOLD_PX = 50; const SMART_SCROLL_BOTTOM_INSET_PX = 100; const SMART_SCROLL_SPEED_PX = 10; @@ -254,9 +253,11 @@ export const createDayInteractionAdapter = ({ return result; }, createVisual: ({ pointerStart, sourceElement, target }) => { + const visibleDateKey = getVisibleDate().format(YEAR_MONTH_DAY_FORMAT); const nextLayout = buildDayLayoutCacheForTarget( target, getLayoutSources(), + [visibleDateKey], ); if (!nextLayout) { @@ -271,6 +272,7 @@ export const createDayInteractionAdapter = ({ if (target.type === "allDayDrag") { return createAllDayDragVisual({ + dayDate: visibleDateKey, dayIndex: 0, eventId: target.event._id!, pointerStart, @@ -301,6 +303,7 @@ export const createDayInteractionAdapter = ({ } return createTimedDragVisual({ + dayDate: visibleDateKey, dayIndex: 0, endMinutes: getLocalMinutes(target.event.endDate), eventId: target.event._id!, @@ -605,7 +608,10 @@ export const createDayInteractionAdapter = ({ }; }; -const buildDayTimedLayoutCache = (sources: CalendarLayoutCacheSources = {}) => +const buildDayTimedLayoutCache = ( + sources: CalendarLayoutCacheSources, + visibleDates: string[], +) => buildTimedCalendarLayoutCache({ ...sources, edgeThresholdPx: DAY_SMART_SCROLL_EDGE_THRESHOLD_PX, @@ -617,26 +623,30 @@ const buildDayTimedLayoutCache = (sources: CalendarLayoutCacheSources = {}) => snapMinutes: CALENDAR_GRID_TIME_STEP, timedColumnsElementId: ID_GRID_COLUMNS_TIMED, timedVisibleHours: CALENDAR_TIMED_VISIBLE_HOURS, - visibleDateCount: DAY_VISIBLE_DATE_COUNT, + visibleDates, }); -const buildDayAllDayLayoutCache = (sources: CalendarLayoutCacheSources = {}) => +const buildDayAllDayLayoutCache = ( + sources: CalendarLayoutCacheSources, + visibleDates: string[], +) => buildAllDayCalendarLayoutCache({ ...sources, allDayColumnsElementId: ID_ALLDAY_COLUMNS, edgeThresholdPx: 0, snapMinutes: CALENDAR_GRID_TIME_STEP, timedVisibleHours: CALENDAR_TIMED_VISIBLE_HOURS, - visibleDateCount: DAY_VISIBLE_DATE_COUNT, + visibleDates, }); const buildDayLayoutCacheForTarget = ( target: DayInteractionTarget, sources: CalendarLayoutCacheSources, + visibleDates: string[], ) => isAllDayTarget(target) - ? buildDayAllDayLayoutCache(sources) - : buildDayTimedLayoutCache(sources); + ? buildDayAllDayLayoutCache(sources, visibleDates) + : buildDayTimedLayoutCache(sources, visibleDates); const commitTimedDragInteraction = ( target: DayTimedDragTarget, @@ -680,10 +690,7 @@ const commitAllDayDragInteraction = ( visibleDate: Dayjs, ): DayAllDayDragCommitResult => { const hasMoved = - "dayIndex" in visual - ? visual.dayIndex !== visual.initialDayIndex || - visual.weekOffsetDays !== 0 - : false; + "dayDate" in visual ? visual.dayDate !== visual.initialDayDate : false; return { event: hasMoved @@ -761,8 +768,7 @@ const allDayVisualToDayGridEvent = ( }); const hasTimedDragVisualMoved = (visual: TimedDragVisual) => - visual.dayIndex !== visual.initialDayIndex || - visual.weekOffsetDays !== 0 || + visual.dayDate !== visual.initialDayDate || visual.startMinutes !== visual.initialStartMinutes || visual.endMinutes !== visual.initialEndMinutes; diff --git a/packages/web/src/views/Day/storage/task-list-width.constants.ts b/packages/web/src/views/Day/storage/task-list-width.constants.ts index 81f8910fb..d37f60187 100644 --- a/packages/web/src/views/Day/storage/task-list-width.constants.ts +++ b/packages/web/src/views/Day/storage/task-list-width.constants.ts @@ -1,6 +1,8 @@ export const TASK_LIST_DEFAULT_WIDTH = 360; export const TASK_LIST_MIN_WIDTH = 240; export const TASK_LIST_MAX_WIDTH = 600; +// Matches the resize-handle column's w-8 (2rem) in DayViewContent. +export const TASK_LIST_DIVIDER_WIDTH = 32; export const clampTaskListWidth = (width: number) => Math.min(TASK_LIST_MAX_WIDTH, Math.max(TASK_LIST_MIN_WIDTH, width)); diff --git a/packages/web/src/views/Day/view/DayViewContent.tsx b/packages/web/src/views/Day/view/DayViewContent.tsx index 7f0aeeb8f..051d7a4e5 100644 --- a/packages/web/src/views/Day/view/DayViewContent.tsx +++ b/packages/web/src/views/Day/view/DayViewContent.tsx @@ -2,15 +2,18 @@ import classNames from "classnames"; import { memo, useCallback, useMemo } from "react"; import dayjs from "@core/util/date/dayjs"; import { ID_MAIN } from "@web/common/constants/web.constants"; +import { useCollapsiblePanel } from "@web/common/hooks/useCollapsiblePanel"; import { getShortcutMenuSections } from "@web/common/shortcuts/data/shortcuts.data"; import { CompassDOMEvents, compassEventEmitter, } from "@web/common/utils/dom/event-emitter.util"; +import { CollapsiblePanel } from "@web/components/CollapsiblePanel/CollapsiblePanel"; import { PlannerSidebar } from "@web/components/PlannerSidebar/PlannerSidebar"; import { usePlannerShortcuts } from "@web/components/PlannerSidebar/usePlannerShortcuts"; import { selectIsSidebarOpen, + selectIsTaskListOpen, useViewStore, viewActions, } from "@web/events/stores/view.store"; @@ -29,6 +32,7 @@ import { openEventFormEditEvent, } from "@web/views/Day/interaction/dayCalendarFocus.util"; import { + TASK_LIST_DIVIDER_WIDTH, TASK_LIST_MAX_WIDTH, TASK_LIST_MIN_WIDTH, } from "@web/views/Day/storage/task-list-width.constants"; @@ -37,14 +41,22 @@ import { focusOnFirstTask, } from "@web/views/Day/util/day.shortcut.util"; import { Dedication } from "@web/views/Week/components/Dedication/Dedication"; +import { SIDEBAR_OPEN_WIDTH } from "@web/views/Week/layout.constants"; export const DayViewContent = memo(() => { const isSidebarOpen = useViewStore(selectIsSidebarOpen); + const isTaskListOpen = useViewStore(selectIsTaskListOpen); + // The task list and its resize divider animate as one unit, so they share + // a single transition instance instead of using CollapsiblePanel. + const taskListTransition = useCollapsiblePanel(isTaskListOpen); const { width: taskListWidth, isResizing, dividerProps, } = useResizableTaskList(); + // Only animate open/close: a live divider drag must track the pointer + // 1:1, not lag behind an in-flight width transition. + const animatesWidth = !isResizing; const { tasks, @@ -73,6 +85,21 @@ export const DayViewContent = memo(() => { const toggleSidebar = useCallback(() => { viewActions.toggleSidebar(); }, []); + const toggleTaskList = useCallback(() => { + viewActions.toggleTaskList(); + }, []); + // Task-focused shortcuts imply the user wants the list visible; reopen it + // first and defer focus a frame so the list exists in the DOM. + const withTaskListOpen = useCallback((focusTask: () => void) => { + return () => { + if (selectIsTaskListOpen(useViewStore.getState())) { + focusTask(); + return; + } + viewActions.setTaskListOpen(true); + requestAnimationFrame(focusTask); + }; + }, []); const { closeShortcuts, isShortcutsOpen, toggleShortcuts } = usePlannerShortcuts({ isSidebarOpen, @@ -147,17 +174,16 @@ export const DayViewContent = memo(() => { useDayViewShortcuts({ onCreateAllDayEvent: handleCreateAllDayEvent, - onAddTask: focusOnAddTaskInput, + onAddTask: withTaskListOpen(focusOnAddTaskInput), onEditTask: handleEditTask, onDeleteTask: handleDeleteTask, onMigrateTask: migrateTask, - onFocusTasks: focusOnFirstTask, + onFocusTasks: withTaskListOpen(focusOnFirstTask), onFocusCalendar: focusFirstDayCalendarEvent, onEditEvent: openEventFormEditEvent, onNextDay: navigateToNextDay, onPrevDay: navigateToPreviousDay, onGoToToday: handleGoToToday, - onToggleSidebar: toggleSidebar, hasFocusedTask, }); @@ -166,7 +192,7 @@ export const DayViewContent = memo(() => { - {isSidebarOpen ? ( + { viewEnd={plannerViewEnd} viewStart={plannerViewStart} /> - ) : null} +
@@ -193,29 +219,58 @@ export const DayViewContent = memo(() => { "cursor-col-resize select-none": isResizing, })} > - - - {/* biome-ignore lint/a11y/useSemanticElements: An hr cannot host the focusable, draggable window-splitter interaction. */} -
-
-
+ {taskListTransition.isMounted ? ( + <> +
+ +
+ + {/* biome-ignore lint/a11y/useSemanticElements: An hr cannot host the focusable, draggable window-splitter interaction. */} +
+
+
+ + ) : null}
diff --git a/packages/web/src/views/Root.tsx b/packages/web/src/views/Root.tsx index d92b7be56..b3325d480 100644 --- a/packages/web/src/views/Root.tsx +++ b/packages/web/src/views/Root.tsx @@ -1,12 +1,15 @@ +import { useMemo } from "react"; import { UserProvider } from "@web/auth/compass/user/context/UserProvider"; -import { useIsMobile } from "@web/common/hooks/useIsMobile"; +import { isMobileOS } from "@web/common/utils/device/device.util"; import { AuthenticatedLayout } from "@web/components/AuthenticatedLayout/AuthenticatedLayout"; import { GlobalShortcutsHost } from "@web/components/CompassProvider/CompassProvider"; import { MobileGate } from "@web/components/MobileGate/MobileGate"; import SSEProvider from "@web/sse/provider/SSEProvider"; export const RootView = () => { - const isMobile = useIsMobile(); + // Gate on the device OS, not the window width: narrow desktop windows get + // the responsive layout. Static per session, so no listener is needed. + const isMobile = useMemo(() => isMobileOS(), []); if (isMobile) { return ; diff --git a/packages/web/src/views/Week/WeekView.tsx b/packages/web/src/views/Week/WeekView.tsx index 0df337e06..e33ea4bb5 100644 --- a/packages/web/src/views/Week/WeekView.tsx +++ b/packages/web/src/views/Week/WeekView.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { ID_MAIN } from "@web/common/constants/web.constants"; import { getShortcutMenuSections } from "@web/common/shortcuts/data/shortcuts.data"; +import { CollapsiblePanel } from "@web/components/CollapsiblePanel/CollapsiblePanel"; import { ContextMenuWrapper } from "@web/components/ContextMenu/GridContextMenuWrapper"; import { SidebarDraftProvider } from "@web/components/PlannerSidebar/draft/context/SidebarDraftProvider"; import { PlannerSidebar } from "@web/components/PlannerSidebar/PlannerSidebar"; @@ -24,10 +25,12 @@ import { Shortcuts } from "@web/views/Week/components/Shortcuts"; import { useDateCalcs } from "@web/views/Week/hooks/grid/useDateCalcs"; import { useGridLayout } from "@web/views/Week/hooks/grid/useGridLayout"; import { useScroll } from "@web/views/Week/hooks/grid/useScroll"; +import { useVisibleDayCount } from "@web/views/Week/hooks/grid/useVisibleDayCount"; import { usePlannerSidebarCalendarDate } from "@web/views/Week/hooks/usePlannerSidebarCalendarDate"; import { useToday } from "@web/views/Week/hooks/useToday"; import { useWeek } from "@web/views/Week/hooks/useWeek"; import { WeekInteractionCoordinator } from "@web/views/Week/interaction/WeekInteractionCoordinator"; +import { SIDEBAR_OPEN_WIDTH } from "@web/views/Week/layout.constants"; export const WeekView = () => { const isSidebarOpen = useViewStore(selectIsSidebarOpen); @@ -42,9 +45,11 @@ export const WeekView = () => { const { today } = useToday(); - const weekProps = useWeek(today); + const { trackRef, visibleDayCount } = useVisibleDayCount(); - const { gridRefs, measurements } = useGridLayout(); + const weekProps = useWeek(today, visibleDayCount); + + const { gridRefs, measurements } = useGridLayout(visibleDayCount); const scrollUtil = useScroll(gridRefs.mainGridRef); @@ -79,7 +84,7 @@ export const WeekView = () => { ); const { calendarDate, goToDateFromSidebar } = usePlannerSidebarCalendarDate({ - setStartOfView: weekProps.state.setStartOfView, + goToDate: weekProps.state.goToDate, today, viewEnd: weekProps.component.endOfView, viewStart: weekProps.component.startOfView, @@ -112,7 +117,10 @@ export const WeekView = () => { - {isSidebarOpen ? ( + { viewEnd={weekProps.component.endOfView} viewStart={weekProps.component.startOfView} /> - ) : null} +
-
+
component: { endOfView: dayjs("2026-05-30T23:59:59.999"), startOfView: dayjs("2026-05-24T00:00:00.000"), + weekDays: [...Array(7)].map((_, index) => + dayjs("2026-05-24T00:00:00.000").add(index, "day"), + ), }, }) as WeekProps; @@ -266,5 +269,4 @@ describe("GridDraft keyboard focus", () => { ); expect(document.activeElement).not.toBe(draftBlock); }); - }); diff --git a/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx b/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx index 62a073e64..d25f6dccc 100644 --- a/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx +++ b/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx @@ -85,7 +85,6 @@ export const GridDraft: FC = ({ <> {draft.isAllDay ? ( = ({ setIsResizing(true); }} ref={refs.setReference} - startOfView={weekProps.component.startOfView} + weekDays={weekProps.component.weekDays} {...getReferenceProps()} /> ) : ( diff --git a/packages/web/src/views/Week/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Week/components/Event/Grid/GridEvent/GridEvent.tsx index 33c0e5cf6..653d8b585 100644 --- a/packages/web/src/views/Week/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Week/components/Event/Grid/GridEvent/GridEvent.tsx @@ -173,6 +173,8 @@ export const GridEventMemo = memo(GridEvent, (prev, next) => { prev.event === next.event && prev.interactionAttributes === next.interactionAttributes && prev.measurements === next.measurements && - prev.motionMode === next.motionMode + prev.motionMode === next.motionMode && + // The visible window can move without the event or measurements changing + prev.weekProps.component.weekDays === next.weekProps.component.weekDays ); }); diff --git a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvent.tsx b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvent.tsx index f3a19d7d4..e4557ea8f 100644 --- a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvent.tsx +++ b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvent.tsx @@ -16,8 +16,7 @@ interface Props { interactionAttributes?: Record; isPlaceholder: boolean; measurements: Measurements_Grid; - startOfView: WeekProps["component"]["startOfView"]; - endOfView: WeekProps["component"]["endOfView"]; + weekDays: WeekProps["component"]["weekDays"]; onMouseDown?: (e: MouseEvent, event: Schema_GridEvent) => void; onKeyDown?: (event: Schema_GridEvent) => void; onScalerMouseDown?: ( @@ -33,28 +32,19 @@ const AllDayEventBase = ( interactionAttributes, isPlaceholder, measurements, - startOfView, - endOfView, + weekDays, onMouseDown, onKeyDown, onScalerMouseDown, }: Props, ref: ForwardedRef, ) => { - const visibleDates = Array.from( - { - length: - endOfView.startOf("day").diff(startOfView.startOf("day"), "day") + 1, - }, - (_, index) => { - const date = startOfView.startOf("day").add(index, "day"); - - return { - date, - key: date.format(YEAR_MONTH_DAY_FORMAT), - }; - }, - ); + // Positions map to the rendered day columns, which may be a window of the + // week rather than all 7 days. + const visibleDates = weekDays.map((date) => ({ + date, + key: date.format(YEAR_MONTH_DAY_FORMAT), + })); const position = getCalendarAllDayEventPosition(event, { isDraft: false, measurements, @@ -106,6 +96,8 @@ export const AllDayEventMemo = memo(AllDayEvent, (prev, next) => { prev.event === next.event && prev.interactionAttributes === next.interactionAttributes && prev.isPlaceholder === next.isPlaceholder && - prev.measurements === next.measurements + prev.measurements === next.measurements && + // The visible window can move without the event or measurements changing + prev.weekDays === next.weekDays ); }); diff --git a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvents.tsx b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvents.tsx index 8be085a08..6682121c9 100644 --- a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvents.tsx +++ b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayEvents.tsx @@ -16,16 +16,19 @@ import { getWeekInteractionTargetAttributes, useWeekEventRegistrationRef, } from "@web/views/Week/interaction/registry/weekEventRegistry"; +import { isAllDayEventInVisibleDays } from "@web/views/Week/util/week-window.util"; interface Props { measurements: Measurements_Grid; startOfView: WeekProps["component"]["startOfView"]; endOfView: WeekProps["component"]["endOfView"]; + weekDays: WeekProps["component"]["weekDays"]; } export const AllDayEvents = ({ measurements, startOfView, endOfView, + weekDays, }: Props) => { const draft = useDraftStore(selectDraft); const { allDayEvents, isPending: isLoadingWeekView } = useWeekEventViewModel({ @@ -34,6 +37,16 @@ export const AllDayEvents = ({ }); const draftId = useDraftStore(selectDraftId); + // The query covers the full week; only mount events overlapping the visible + // window so off-window events never land in the DOM or the interaction + // registry. + const visibleAllDayEvents = useMemo( + () => + allDayEvents.filter((event: Schema_GridEvent) => + isAllDayEventInVisibleDays(event, weekDays), + ), + [allDayEvents, weekDays], + ); const handleKeyDown = (event: Schema_GridEvent) => { draftActions.start({ @@ -49,7 +62,7 @@ export const AllDayEvents = ({ id={ID_GRID_EVENTS_ALLDAY} > {!isLoadingWeekView && - allDayEvents.map((event: Schema_GridEvent) => { + visibleAllDayEvents.map((event: Schema_GridEvent) => { const isPlaceholder = event._id === draftId; const eventForDisplay = isPlaceholder && draft && draft._id === event._id @@ -58,13 +71,12 @@ export const AllDayEvents = ({ return ( ); })} @@ -73,21 +85,19 @@ export const AllDayEvents = ({ }; interface AllDayEventItemProps { - endOfView: WeekProps["component"]["endOfView"]; event: Schema_GridEvent; isPlaceholder: boolean; measurements: Measurements_Grid; onKeyDown: (event: Schema_GridEvent) => void; - startOfView: WeekProps["component"]["startOfView"]; + weekDays: WeekProps["component"]["weekDays"]; } const AllDayEventItem = ({ - endOfView, event, isPlaceholder, measurements, onKeyDown, - startOfView, + weekDays, }: AllDayEventItemProps) => { const isRegisteredForWeekInteraction = Boolean(event._id) && !isPlaceholder; const registrationRef = useWeekEventRegistrationRef({ @@ -109,14 +119,13 @@ const AllDayEventItem = ({ return ( ); }; diff --git a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayRow.tsx b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayRow.tsx index 2f40013a9..67472db99 100644 --- a/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayRow.tsx +++ b/packages/web/src/views/Week/components/Grid/AllDayRow/AllDayRow.tsx @@ -154,11 +154,13 @@ const useAllDayEventsLayer = ( endOfView={weekProps.component.endOfView} measurements={measurements} startOfView={weekProps.component.startOfView} + weekDays={weekProps.component.weekDays} /> ), [ measurements, weekProps.component.endOfView, weekProps.component.startOfView, + weekProps.component.weekDays, ], ); diff --git a/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx b/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx index 9b2e597fb..7a3ba126a 100644 --- a/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx +++ b/packages/web/src/views/Week/components/Grid/MainGrid/MainGrid.test.tsx @@ -69,6 +69,9 @@ afterEach(() => { }); const startOfView = dayjs("2024-01-14T00:00:00.000"); +const weekDaysInView = Array.from({ length: 7 }, (_, index) => + startOfView.add(index, "day"), +); const measurements = { allDayRow: null, colWidths: [100, 100, 100, 100, 100, 100, 100], @@ -133,7 +136,7 @@ const createWeekProps = () => ({ startOfView.add(index, "day"), ), }, - state: { setStartOfView: mock() }, + state: { goToDate: mock() }, util: { decrementWeek: mock(), getLastNavigationSource: mock(() => "manual" as const), @@ -563,6 +566,7 @@ describe("Week calendar accessibility", () => { endOfView={startOfView.endOf("week")} measurements={measurements} startOfView={startOfView} + weekDays={weekDaysInView} /> , ); @@ -595,6 +599,7 @@ describe("Week calendar accessibility", () => { endOfView={startOfView.endOf("week")} measurements={measurements} startOfView={startOfView} + weekDays={weekDaysInView} /> , ); @@ -638,6 +643,7 @@ describe("Week calendar accessibility", () => { endOfView={startOfView.endOf("week")} measurements={measurements} startOfView={startOfView} + weekDays={weekDaysInView} /> , ); @@ -790,6 +796,7 @@ describe("saved Week event ownership", () => { endOfView={startOfView.endOf("week")} measurements={measurements} startOfView={startOfView} + weekDays={weekDaysInView} /> , ); diff --git a/packages/web/src/views/Week/components/Grid/MainGrid/MainGridEvents.tsx b/packages/web/src/views/Week/components/Grid/MainGrid/MainGridEvents.tsx index 24de8a77f..a64f806f5 100644 --- a/packages/web/src/views/Week/components/Grid/MainGrid/MainGridEvents.tsx +++ b/packages/web/src/views/Week/components/Grid/MainGrid/MainGridEvents.tsx @@ -19,6 +19,7 @@ import { getWeekInteractionTargetAttributes, useWeekEventRegistrationRef, } from "@web/views/Week/interaction/registry/weekEventRegistry"; +import { isTimedEventInVisibleDays } from "@web/views/Week/util/week-window.util"; import { GridEventMemo } from "../../Event/Grid/GridEvent/GridEvent"; interface Props { @@ -33,9 +34,17 @@ export const MainGridEvents = ({ measurements, weekProps }: Props) => { endOfView: weekProps.component.endOfView, }); const draftId = useDraftStore(selectDraftId); + const weekDays = weekProps.component.weekDays; + // The query covers the full week; only mount events for the visible window + // so off-window events never land in the DOM or the interaction registry. + const visibleTimedEvents = useMemo( + () => + timedEvents.filter((event) => isTimedEventInVisibleDays(event, weekDays)), + [timedEvents, weekDays], + ); const timedEventItems = useMemo( - () => createCalendarTimedEventLayout(timedEvents), - [timedEvents], + () => createCalendarTimedEventLayout(visibleTimedEvents), + [visibleTimedEvents], ); const category = Categories_Event.TIMED; diff --git a/packages/web/src/views/Week/components/Header/DayLabels.tsx b/packages/web/src/views/Week/components/Header/DayLabels.tsx index 167bee65d..fb762c262 100644 --- a/packages/web/src/views/Week/components/Header/DayLabels.tsx +++ b/packages/web/src/views/Week/components/Header/DayLabels.tsx @@ -1,5 +1,6 @@ import { type FC } from "react"; import { type Dayjs } from "@core/util/date/dayjs"; +import { CALENDAR_EVENT_WIDTH_MINIMUM } from "@web/common/calendar-grid/calendarGrid.constants"; import { getWeekDayLabel } from "@web/common/utils/event/event.util"; import { Text } from "@web/components/Text/Text"; @@ -41,7 +42,12 @@ export const DayLabels: FC = ({ return (
-
+
{weekDays.map((day) => { const dayNumber = getDayNumber(day); const { isToday, color } = getColor(day); diff --git a/packages/web/src/views/Week/hooks/grid/useGridLayout.ts b/packages/web/src/views/Week/hooks/grid/useGridLayout.ts index 218b5d48e..faf66304d 100644 --- a/packages/web/src/views/Week/hooks/grid/useGridLayout.ts +++ b/packages/web/src/views/Week/hooks/grid/useGridLayout.ts @@ -1,12 +1,11 @@ import { useCalendarGridLayout } from "@web/common/calendar-grid/hooks/useCalendarGridLayout"; import { isWeekInteractionMotionActive } from "@web/views/Week/interaction/state/weekInteractionMotionState"; +import { WEEK_DAY_COUNT } from "@web/views/Week/util/week-window.util"; -const DAYS_IN_VIEW = 7; - -export const useGridLayout = () => +export const useGridLayout = (visibleDateCount: number = WEEK_DAY_COUNT) => useCalendarGridLayout({ isInteractionMotionActive: isWeekInteractionMotionActive, - visibleDateCount: DAYS_IN_VIEW, + visibleDateCount, }); export type Layout_Grid = ReturnType; diff --git a/packages/web/src/views/Week/hooks/grid/useVisibleDayCount.ts b/packages/web/src/views/Week/hooks/grid/useVisibleDayCount.ts new file mode 100644 index 000000000..91aa1918c --- /dev/null +++ b/packages/web/src/views/Week/hooks/grid/useVisibleDayCount.ts @@ -0,0 +1,53 @@ +import { useCallback, useRef, useState } from "react"; +import { isWeekInteractionMotionActive } from "@web/views/Week/interaction/state/weekInteractionMotionState"; +import { + computeVisibleDayCount, + WEEK_DAY_COUNT, +} from "@web/views/Week/util/week-window.util"; + +/** + * Derives how many day columns the week grid can fit from the measured width + * of the grid track. Defaults to the full week until a real measurement + * arrives (the ref callback measures during commit, so the browser never + * paints the unmeasured fallback). Updates freeze during drag interactions so + * the window never re-derives mid-gesture. + */ +export const useVisibleDayCount = () => { + const [visibleDayCount, setVisibleDayCount] = useState(WEEK_DAY_COUNT); + const observerRef = useRef(null); + + const trackRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + + if (!node) { + return; + } + + const measure = () => { + if (isWeekInteractionMotionActive()) { + return; + } + + const width = node.getBoundingClientRect().width; + if (!width) { + // Unmeasurable (e.g. jsdom): keep showing the full week + return; + } + + setVisibleDayCount(computeVisibleDayCount(width)); + }; + + measure(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(measure); + observer.observe(node); + observerRef.current = observer; + }, []); + + return { trackRef, visibleDayCount }; +}; diff --git a/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.test.ts b/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.test.ts index c502e1286..55e5f3c96 100644 --- a/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.test.ts +++ b/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.test.ts @@ -11,12 +11,12 @@ const expectDate = (date: Dayjs, value: string) => { describe("usePlannerSidebarCalendarDate", () => { it("keeps the clicked weekday selected after the week view moves to that week", async () => { - const setStartOfView = mock(); + const goToDate = mock(); const today = dayjs("2026-05-11"); const { result, rerender } = renderHook( ({ viewEnd, viewStart }) => usePlannerSidebarCalendarDate({ - setStartOfView, + goToDate, today, viewEnd, viewStart, @@ -33,7 +33,7 @@ describe("usePlannerSidebarCalendarDate", () => { result.current.goToDateFromSidebar(dayjs("2026-05-18")); }); - expectDate(setStartOfView.mock.calls[0]?.[0], "2026-05-17"); + expectDate(goToDate.mock.calls[0]?.[0], "2026-05-18"); rerender({ viewEnd: dayjs("2026-05-23"), @@ -46,7 +46,7 @@ describe("usePlannerSidebarCalendarDate", () => { }); it("keeps a clicked date in the current week instead of snapping back to today", async () => { - const setStartOfView = mock(); + const goToDate = mock(); const today = dayjs("2026-05-11"); const viewStart = dayjs("2026-05-10"); const viewEnd = dayjs("2026-05-16"); @@ -54,7 +54,7 @@ describe("usePlannerSidebarCalendarDate", () => { (props) => usePlannerSidebarCalendarDate(props), { initialProps: { - setStartOfView, + goToDate, today, viewEnd, viewStart, @@ -66,7 +66,7 @@ describe("usePlannerSidebarCalendarDate", () => { result.current.goToDateFromSidebar(dayjs("2026-05-12")); }); - rerender({ setStartOfView, today, viewEnd, viewStart }); + rerender({ goToDate, today, viewEnd, viewStart }); await waitFor(() => { expectDate(result.current.calendarDate, "2026-05-12"); @@ -74,12 +74,12 @@ describe("usePlannerSidebarCalendarDate", () => { }); it("uses today when outside navigation returns to the current week", async () => { - const setStartOfView = mock(); + const goToDate = mock(); const today = dayjs("2026-05-11"); const { result, rerender } = renderHook( ({ viewEnd, viewStart }) => usePlannerSidebarCalendarDate({ - setStartOfView, + goToDate, today, viewEnd, viewStart, diff --git a/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.ts b/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.ts index e3739f61c..100c069ec 100644 --- a/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.ts +++ b/packages/web/src/views/Week/hooks/usePlannerSidebarCalendarDate.ts @@ -9,7 +9,7 @@ interface PlannerSidebarCalendarDateArgs { } interface UsePlannerSidebarCalendarDateArgs { - setStartOfView: (date: Dayjs) => void; + goToDate: (date: Dayjs) => void; today: Dayjs; viewEnd: Dayjs; viewStart: Dayjs; @@ -35,7 +35,7 @@ function resolvePlannerSidebarCalendarDate({ } export function usePlannerSidebarCalendarDate({ - setStartOfView, + goToDate, today, viewEnd, viewStart, @@ -71,9 +71,11 @@ export function usePlannerSidebarCalendarDate({ const goToDateFromSidebar = useCallback( (date: Dayjs) => { setCalendarDate(date); - setStartOfView(date.startOf("week")); + // Anchor the view on the picked date so it stays visible even when the + // window shows fewer than 7 days. + goToDate(date); }, - [setStartOfView], + [goToDate], ); return { calendarDate, goToDateFromSidebar }; diff --git a/packages/web/src/views/Week/hooks/useWeek.ts b/packages/web/src/views/Week/hooks/useWeek.ts index 50fbc5d4a..413d8784e 100644 --- a/packages/web/src/views/Week/hooks/useWeek.ts +++ b/packages/web/src/views/Week/hooks/useWeek.ts @@ -6,27 +6,49 @@ import { usePrefetchAdjacentEvents } from "@web/events/queries/usePrefetchAdjace import { useSomedayEventsQuery } from "@web/events/queries/useSomedayEventsQuery"; import { useWeekEventsQuery } from "@web/events/queries/useWeekEventsQuery"; import { viewActions } from "@web/events/stores/view.store"; +import { + anchorDateForWindowOffset, + computeVisibleWindowOffset, + WEEK_DAY_COUNT, +} from "@web/views/Week/util/week-window.util"; import { type Category_View } from "@web/views/Week/week-view.types"; export type WeekNavigationSource = "manual" | "drag-to-edge"; -export const useWeek = (today: Dayjs) => { - const origStart = useMemo(() => today.startOf("week"), [today]); - const [start, setStartOfView] = useState(origStart); +export const useWeek = ( + today: Dayjs, + visibleDayCount: number = WEEK_DAY_COUNT, +) => { + // The anchor is the day the visible window centers on. The week range and + // the window offset both derive from it, so a day-count change re-windows + // around the anchor without extra state. + const [anchor, setAnchor] = useState(today); const navigationSourceRef = useRef("manual"); + + const start = useMemo(() => anchor.startOf("week"), [anchor]); const end = useMemo(() => start.endOf("week"), [start]); const week = useMemo(() => start.week(), [start]); const isCurrentWeek = today.week() === start.week(); - const weekDays = [...(new Array(7) as number[])].map((_, index) => { - return start.add(index, "day"); + const windowOffset = computeVisibleWindowOffset({ + anchorIndex: anchor.startOf("day").diff(start.startOf("day"), "day"), + visibleDayCount, }); + const weekDays = useMemo( + () => + Array.from({ length: visibleDayCount }, (_, index) => + start.add(windowOffset + index, "day"), + ), + [start, visibleDayCount, windowOffset], + ); + // Week + someday reads are driven by TanStack Query: changing start/end // re-keys the queries (fetch on new ranges, instant render from cache on - // revisits). Redux stays the render source of truth via the hooks' sync. + // revisits). Queries stay week-granular even when fewer days render, so + // window paging within a week never refetches. useWeekEventsQuery({ startOfView: start, endOfView: end }); useSomedayEventsQuery(start); @@ -55,23 +77,55 @@ export const useWeek = (today: Dayjs) => { }); }, [end, start]); - const decrementWeek = (source: WeekNavigationSource = "manual") => { + const goToDate = (date: Dayjs) => { + navigationSourceRef.current = "manual"; + setAnchor(date); + }; + + // Shift the visible window by one page of visibleDayCount days; at the + // week's edge, cross into the adjacent week entering from its near side. + const pageWindow = (direction: 1 | -1, source: WeekNavigationSource) => { navigationSourceRef.current = source; - setStartOfView(start.subtract(7, "day")); + const maxOffset = WEEK_DAY_COUNT - visibleDayCount; + const isAtWeekEdge = + direction === 1 ? windowOffset >= maxOffset : windowOffset <= 0; + + if (isAtWeekEdge) { + setAnchor( + anchorDateForWindowOffset({ + weekStart: start.add(direction * WEEK_DAY_COUNT, "day"), + windowOffset: direction === 1 ? 0 : maxOffset, + visibleDayCount, + }), + ); + return; + } + + setAnchor( + anchorDateForWindowOffset({ + weekStart: start, + windowOffset: Math.min( + Math.max(windowOffset + direction * visibleDayCount, 0), + maxOffset, + ), + visibleDayCount, + }), + ); }; + const incrementWeek = (source: WeekNavigationSource = "manual") => + pageWindow(1, source); + + const decrementWeek = (source: WeekNavigationSource = "manual") => + pageWindow(-1, source); + const goToToday = () => { navigationSourceRef.current = "manual"; - if (today.week() !== start.week()) { - setStartOfView(today.startOf("week")); + if (!anchor.isSame(today, "day")) { + setAnchor(today); } }; - const incrementWeek = (source: WeekNavigationSource = "manual") => { - navigationSourceRef.current = source; - setStartOfView(start.add(7, "day")); - }; - const getLastNavigationSource = () => navigationSourceRef.current; const weekProps = { @@ -83,7 +137,7 @@ export const useWeek = (today: Dayjs) => { week, weekDays, }, - state: { setStartOfView }, + state: { goToDate }, util: { decrementWeek, goToToday, incrementWeek, getLastNavigationSource }, }; return weekProps; diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index fa5b97c19..0911c296f 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -1,10 +1,4 @@ -import { - type FC, - type PropsWithChildren, - useLayoutEffect, - useMemo, - useRef, -} from "react"; +import { type FC, type PropsWithChildren, useMemo, useRef } from "react"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { useWeekEventViewModel } from "@web/events/queries/useWeekEventsQuery"; @@ -20,6 +14,7 @@ import { type WeekTimedDragCommitResult, type WeekTimedResizeCommitResult, } from "./adapter/WeekInteractionAdapter"; +import { useWeekInteractionLayoutSync } from "./useWeekInteractionLayoutSync"; interface Props extends PropsWithChildren { getLayoutSources?: () => WeekLayoutCacheSources; @@ -45,6 +40,7 @@ export const WeekInteractionCoordinator: FC = ({ }, [allDayEvents]); const runtimeRef = useRef({ getTimedEventById: () => null, + getVisibleDays: () => [], onClickTimedEvent: () => undefined, onCommitTimedDrag: () => undefined, }); @@ -56,22 +52,10 @@ export const WeekInteractionCoordinator: FC = ({ }), [], ); - const lastNavigationSource = weekProps.util.getLastNavigationSource(); - const renderedWeekStartMs = weekProps.component.startOfView.valueOf(); + const visibleDayKeys = useWeekInteractionLayoutSync(adapter, weekProps); layoutSourcesRef.current = getLayoutSources; - useLayoutEffect(() => { - if ( - lastNavigationSource !== "drag-to-edge" || - !Number.isFinite(renderedWeekStartMs) - ) { - return; - } - - adapter.rebuildLayoutAfterNavigation(); - }, [adapter, lastNavigationSource, renderedWeekStartMs]); - const openTimedEvent = (event: Schema_GridEvent) => { draftActions.startGridClick(event); }; @@ -108,6 +92,7 @@ export const WeekInteractionCoordinator: FC = ({ runtimeRef.current = { getAllDayEventById: (eventId) => allDayEventsById.get(eventId) ?? null, getTimedEventById: (eventId) => timedEventsById.get(eventId) ?? null, + getVisibleDays: () => visibleDayKeys, isFormOpen: () => state.isFormOpen, onClickAllDayEvent: openAllDayEvent, onClickTimedEvent: openTimedEvent, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index 6c7ce0522..e97a708cb 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -183,6 +183,15 @@ const createHarness = ({ runtime: () => ({ getAllDayEventById: (eventId) => (eventId === event._id ? event : null), getTimedEventById: () => null, + getVisibleDays: () => [ + "2026-05-10", + "2026-05-11", + "2026-05-12", + "2026-05-13", + "2026-05-14", + "2026-05-15", + "2026-05-16", + ], onClickAllDayEvent, onClickTimedEvent: () => undefined, onCommitAllDayDrag, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayResize.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayResize.test.ts index 768bf26eb..bbbb904cd 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayResize.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayResize.test.ts @@ -156,6 +156,15 @@ const createHarness = ({ runtime: () => ({ getAllDayEventById: (eventId) => (eventId === event._id ? event : null), getTimedEventById: () => null, + getVisibleDays: () => [ + "2026-05-10", + "2026-05-11", + "2026-05-12", + "2026-05-13", + "2026-05-14", + "2026-05-15", + "2026-05-16", + ], onClickAllDayEvent, onClickTimedEvent: () => undefined, onCommitAllDayResize, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 33847f0e4..e0add17a5 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -105,7 +105,21 @@ const expectSourceRestored = (source: HTMLElement) => { expect(source).not.toHaveAttribute(SOURCE_ELEMENT_INTERACTION_ATTRIBUTE); }; +// The default harness renders the full week of Sun 2026-05-17 as 7 columns of +// 100px starting at x=100 (the event on Tue 2026-05-19 sits in column 2). +const FULL_WEEK_DAYS = [ + "2026-05-17", + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", +]; + const createHarness = ({ + columnsWidth = 700, + event: eventOverrides, mainGridScrollTop = 0, sourceRect = { height: 100, @@ -113,18 +127,23 @@ const createHarness = ({ top: 1000, width: 90, }, + visibleDays = FULL_WEEK_DAYS, }: { + columnsWidth?: number; + event?: Partial; mainGridScrollTop?: number; sourceRect?: Pick; + visibleDays?: string[]; } = {}) => { document.body.innerHTML = ""; weekEventRegistry.clear(); let now = 100; let nextFrameId = 1; + let currentVisibleDays = visibleDays; const frameCallbacks = new Map(); const timerCallbacks = new Map void>(); - const event = createTimedEvent(); + const event = createTimedEvent(eventOverrides); const source = document.createElement("div"); const child = document.createElement("span"); const mainGrid = document.createElement("div"); @@ -154,7 +173,7 @@ const createHarness = ({ height: 2400, left: 100, top: 100, - width: 700, + width: columnsWidth, }); setRect(source, sourceRect); @@ -187,6 +206,7 @@ const createHarness = ({ }, runtime: () => ({ getTimedEventById: (eventId) => (eventId === event._id ? event : null), + getVisibleDays: () => currentVisibleDays, onClickTimedEvent, onCommitTimedDrag, onMotionActivation, @@ -194,6 +214,10 @@ const createHarness = ({ }), }); + const setVisibleDays = (nextVisibleDays: string[]) => { + currentVisibleDays = nextVisibleDays; + }; + const flushFrame = (timestamp = 16) => { const [[frameId, callback]] = frameCallbacks; @@ -229,6 +253,7 @@ const createHarness = ({ onCommitTimedDrag, onMotionActivation, onRequestWeekNavigation, + setVisibleDays, source, timerCallbacks, unregister, @@ -558,6 +583,168 @@ describe("WeekInteractionAdapter timed drag", () => { ); }); + it("commits the hovered column's date in a windowed view with an offset", () => { + // 6-day window Mon..Sat (offset 1): Friday renders in column 4, so the + // old `.getDay()` seeding (5) resolved the wrong column and clamped the + // drag at the window edge. + const { adapter, child, flushFrame, onCommitTimedDrag } = createHarness({ + columnsWidth: 600, + event: { + endDate: "2026-05-22T10:00:00.000", + startDate: "2026-05-22T09:00:00.000", + }, + sourceRect: { height: 100, left: 500, top: 1000, width: 90 }, + visibleDays: [ + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + ], + }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 520, y: 1020 }), + ); + // One column (100px) to the right: Friday -> Saturday + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 620, y: 1020 }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 620, y: 1020 }), + ); + + expect(onCommitTimedDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + startDate: expect.stringContaining("2026-05-23"), + }), + hasMoved: true, + }), + ); + }); + + it("commits the rebuilt column's date after a within-week window page", () => { + // 6-day window Sun..Fri (offset 0); paging next shifts the window by ONE + // day (Mon..Sat), not seven. The drop must use the rebuilt column dates. + const { + adapter, + child, + flushFrame, + onCommitTimedDrag, + onRequestWeekNavigation, + setVisibleDays, + } = createHarness({ + columnsWidth: 600, + sourceRect: { height: 100, left: 300, top: 1000, width: 90 }, + visibleDays: [ + "2026-05-17", + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + ], + }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + // Dwell inside the right edge threshold until navigation is requested + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 690, y: 1020 }), + ); + flushFrame(16); + flushFrame(600); + + expect(onRequestWeekNavigation).toHaveBeenCalledWith("next"); + + // React re-renders the window paged by one day and rebuilds the layout + setVisibleDays([ + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + ]); + adapter.rebuildLayoutAfterNavigation(); + + // Release over column 1, which now renders 2026-05-19 + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 220, y: 1020 }), + ); + + expect(onCommitTimedDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + startDate: expect.stringContaining("2026-05-19"), + }), + }), + ); + }); + + it("commits the rebuilt column's date after a week-boundary navigation", () => { + // 6-day window Mon..Sat (offset 1); paging next crosses into the next + // week at offset 0 — a six-day shift, where the old ±7 bookkeeping was + // off by one. + const { + adapter, + child, + flushFrame, + onCommitTimedDrag, + onRequestWeekNavigation, + setVisibleDays, + } = createHarness({ + columnsWidth: 600, + sourceRect: { height: 100, left: 200, top: 1000, width: 90 }, + visibleDays: [ + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + ], + }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 220, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 690, y: 1020 }), + ); + flushFrame(16); + flushFrame(600); + + expect(onRequestWeekNavigation).toHaveBeenCalledWith("next"); + + setVisibleDays([ + "2026-05-24", + "2026-05-25", + "2026-05-26", + "2026-05-27", + "2026-05-28", + "2026-05-29", + ]); + adapter.rebuildLayoutAfterNavigation(); + + // Release over column 2, which now renders 2026-05-26 + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 320, y: 1020 }), + ); + + expect(onCommitTimedDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + startDate: expect.stringContaining("2026-05-26"), + }), + }), + ); + }); + it("requests one timed edge navigation after the edge dwell", () => { const { adapter, child, flushFrame, onRequestWeekNavigation } = createHarness(); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedResize.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedResize.test.ts index 8268ab3a1..e4e8d505e 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedResize.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedResize.test.ts @@ -160,6 +160,15 @@ const createHarness = ({ }, runtime: () => ({ getTimedEventById: (eventId) => (eventId === event._id ? event : null), + getVisibleDays: () => [ + "2026-05-17", + "2026-05-18", + "2026-05-19", + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + ], onClickTimedEvent, onCommitTimedDrag, onCommitTimedResize, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index 235704cd5..6b9642f04 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -28,7 +28,7 @@ import { buildAllDayWeekLayoutCache, buildTimedWeekLayoutCache, type WeekLayoutCache, - type WeekLayoutCacheSources, + type WeekLayoutCacheInput, } from "./geometry/weekLayoutCache"; import { commitAllDayDragInteraction, @@ -78,6 +78,7 @@ export type { const inertRuntime: WeekInteractionRuntime = { getTimedEventById: () => null, + getVisibleDays: () => [], onClickTimedEvent: () => undefined, onCommitTimedDrag: () => undefined, }; @@ -263,10 +264,7 @@ export const createWeekInteractionAdapter = ({ return result; }, createVisual: ({ pointerStart, sourceElement, target }) => { - const layout = buildWeekLayoutCacheForTarget( - target, - getLayoutSources(), - ); + const layout = buildWeekLayoutCacheForTarget(target, getLayoutInput()); if (!layout) { return null; @@ -310,6 +308,7 @@ export const createWeekInteractionAdapter = ({ } return createTimedDragInteractionVisual({ + layout, pointerStart, sourceRect, target, @@ -640,15 +639,14 @@ export const createWeekInteractionAdapter = ({ setWeekInteractionEdgeNavigationState(update.state); if (update.requestedSide) { + // No day bookkeeping: the pending layout rebuild carries the new column + // dates, and the visual re-resolves its dayDate against them. isLayoutRebuildPending = true; runtime().onRequestWeekNavigation?.(update.requestedSide); return { isDwellActive: false, - visual: { - ...visual, - weekOffsetDays: visual.weekOffsetDays + update.weekOffsetDaysDelta, - } as TVisual, + visual, }; } @@ -658,15 +656,19 @@ export const createWeekInteractionAdapter = ({ }; } + function getLayoutInput(): WeekLayoutCacheInput { + return { + ...getLayoutSources(), + visibleDays: runtime().getVisibleDays(), + }; + } + function rebuildLayoutIfNeeded(target: WeekInteractionTarget) { if (!isLayoutRebuildPending) { return; } - const nextLayout = buildWeekLayoutCacheForTarget( - target, - getLayoutSources(), - ); + const nextLayout = buildWeekLayoutCacheForTarget(target, getLayoutInput()); if (!nextLayout) { return; @@ -729,11 +731,11 @@ const getOwnershipReason = (target: WeekInteractionTarget) => { const buildWeekLayoutCacheForTarget = ( target: WeekInteractionTarget, - sources: WeekLayoutCacheSources, + input: WeekLayoutCacheInput, ) => isAllDayTarget(target) - ? buildAllDayWeekLayoutCache(sources) - : buildTimedWeekLayoutCache(sources); + ? buildAllDayWeekLayoutCache(input) + : buildTimedWeekLayoutCache(input); const isEligibleWeekPointerDown = isEligibleCalendarInteractionPointerDown; diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts index abbb8ed2d..bb68b5266 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts @@ -30,6 +30,12 @@ export interface WeekInteractionAdapterOptions { export interface WeekInteractionRuntime { getAllDayEventById?: (eventId: string) => Schema_GridEvent | null; getTimedEventById(eventId: string): Schema_GridEvent | null; + /** + * Local YYYY-MM-DD dates of the rendered day columns, in window order. + * Sourced from the same React render that painted the columns so drag + * geometry and drop dates always agree with what is on screen. + */ + getVisibleDays(): string[]; isFormOpen?: () => boolean; onClickAllDayEvent?: (event: Schema_GridEvent) => void; onClickTimedEvent: (event: Schema_GridEvent) => void; diff --git a/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts b/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts index a089cc8bc..bf448674e 100644 --- a/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts +++ b/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts @@ -5,14 +5,20 @@ import { type AllDayResizeVisual } from "@web/common/calendar-grid/interaction/m import { type Schema_GridEvent } from "@web/common/types/web.event.types"; export const hasAllDayDragVisualMoved = (visual: AllDayDragVisual) => - visual.dayIndex !== visual.initialDayIndex || visual.weekOffsetDays !== 0; + visual.dayDate !== visual.initialDayDate; export const allDayDragVisualToGridEvent = ( event: Schema_GridEvent, visual: AllDayDragVisual, ): Schema_GridEvent => { - const dayDelta = - visual.dayIndex - visual.initialDayIndex + visual.weekOffsetDays; + // Delta (not absolute) semantics: multi-day spans are clamped to the + // rendered window, so the initial column date is the clamped visible start, + // not necessarily the event's own start date. The date diff also absorbs + // mid-drag week navigation of any shift size. + const dayDelta = dayjs(visual.dayDate).diff( + dayjs(visual.initialDayDate), + "day", + ); return { ...event, diff --git a/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts b/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts index 796705067..706c1589f 100644 --- a/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts +++ b/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts @@ -4,8 +4,7 @@ import { type TimedResizeVisual } from "@web/common/calendar-grid/interaction/mo import { type Schema_GridEvent } from "@web/common/types/web.event.types"; export const hasTimedDragVisualMoved = (visual: TimedDragVisual) => - visual.dayIndex !== visual.initialDayIndex || - visual.weekOffsetDays !== 0 || + visual.dayDate !== visual.initialDayDate || visual.startMinutes !== visual.initialStartMinutes || visual.endMinutes !== visual.initialEndMinutes; @@ -13,9 +12,9 @@ export const timedDragVisualToGridEvent = ( event: Schema_GridEvent, visual: TimedDragVisual, ): Schema_GridEvent => { - const dayDelta = - visual.dayIndex - visual.initialDayIndex + visual.weekOffsetDays; - const movedDay = dayjs(event.startDate).add(dayDelta, "day").startOf("day"); + // The column under the drag knows its own date, so the target day is + // assigned absolutely; time-of-day rides on the visual's minutes. + const movedDay = dayjs(visual.dayDate).startOf("day"); return { ...event, diff --git a/packages/web/src/views/Week/interaction/adapter/geometry/weekLayoutCache.ts b/packages/web/src/views/Week/interaction/adapter/geometry/weekLayoutCache.ts index f2e66e87c..c4049a0fb 100644 --- a/packages/web/src/views/Week/interaction/adapter/geometry/weekLayoutCache.ts +++ b/packages/web/src/views/Week/interaction/adapter/geometry/weekLayoutCache.ts @@ -21,9 +21,20 @@ import { WEEK_EDGE_NAVIGATION_THRESHOLD_PX } from "../weekEdgeNavigation"; const SMART_SCROLL_BOTTOM_INSET_PX = 100; const SMART_SCROLL_SPEED_PX = 10; -const DAYS_IN_WEEK = 7; export type WeekLayoutCacheSources = CalendarLayoutCacheSources; + +/** + * The week renders a dynamic window of 1-7 day columns. The columns' dates + * come from the same React render that painted them (weekProps weekDays via + * the interaction runtime), so drag geometry and drop dates always agree + * with what is on screen. + */ +export interface WeekLayoutCacheInput extends CalendarLayoutCacheSources { + /** Local YYYY-MM-DD dates of the rendered day columns, in window order. */ + visibleDays: string[]; +} + export type WeekDayColumnCache = CalendarDayColumnCache; export type WeekEdgeNavigationCache = CalendarEdgeNavigationCache; export type WeekLayoutCache = CalendarLayoutCache; @@ -31,7 +42,7 @@ export type { SmartScrollCache }; export { getNearestDayColumn }; export const buildTimedWeekLayoutCache = ( - sources: WeekLayoutCacheSources = {}, + sources: WeekLayoutCacheInput, ): WeekLayoutCache | null => buildTimedCalendarLayoutCache({ ...sources, @@ -44,11 +55,11 @@ export const buildTimedWeekLayoutCache = ( snapMinutes: GRID_TIME_STEP, timedColumnsElementId: ID_GRID_COLUMNS_TIMED, timedVisibleHours: WEEK_TIMED_VISIBLE_HOURS, - visibleDateCount: DAYS_IN_WEEK, + visibleDates: sources.visibleDays, }); export const buildAllDayWeekLayoutCache = ( - sources: WeekLayoutCacheSources = {}, + sources: WeekLayoutCacheInput, ): WeekLayoutCache | null => buildAllDayCalendarLayoutCache({ ...sources, @@ -56,5 +67,5 @@ export const buildAllDayWeekLayoutCache = ( edgeThresholdPx: WEEK_EDGE_NAVIGATION_THRESHOLD_PX, snapMinutes: GRID_TIME_STEP, timedVisibleHours: WEEK_TIMED_VISIBLE_HOURS, - visibleDateCount: DAYS_IN_WEEK, + visibleDates: sources.visibleDays, }); diff --git a/packages/web/src/views/Week/interaction/adapter/interactions/allDayEventDragInteraction.ts b/packages/web/src/views/Week/interaction/adapter/interactions/allDayEventDragInteraction.ts index d82af314d..1ea60101f 100644 --- a/packages/web/src/views/Week/interaction/adapter/interactions/allDayEventDragInteraction.ts +++ b/packages/web/src/views/Week/interaction/adapter/interactions/allDayEventDragInteraction.ts @@ -31,9 +31,15 @@ export const createAllDayDragInteractionVisual = ({ target: WeekAllDayDragTarget; }) => { const visibleRange = getVisibleAllDayRange(layout, sourceRect); + const sourceColumn = layout.dayColumns[visibleRange.startDayIndex]; + + if (!sourceColumn) { + return null; + } return createAllDayDragVisual({ - dayIndex: visibleRange.startDayIndex, + dayDate: sourceColumn.date, + dayIndex: sourceColumn.index, eventId: target.event._id!, pointerStart, sourceRect, diff --git a/packages/web/src/views/Week/interaction/adapter/interactions/timedEventDragInteraction.ts b/packages/web/src/views/Week/interaction/adapter/interactions/timedEventDragInteraction.ts index 827ceeac2..825e31870 100644 --- a/packages/web/src/views/Week/interaction/adapter/interactions/timedEventDragInteraction.ts +++ b/packages/web/src/views/Week/interaction/adapter/interactions/timedEventDragInteraction.ts @@ -1,3 +1,5 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs from "@core/util/date/dayjs"; import { getLocalMinutes } from "@web/common/calendar-grid/interaction/calendarInteractionDate"; import { createTimedDragVisual, @@ -13,29 +15,49 @@ import { hasTimedDragVisualMoved, timedDragVisualToGridEvent, } from "../commit/timedDragVisualToGridEvent"; -import { type WeekLayoutCache } from "../geometry/weekLayoutCache"; +import { + getNearestDayColumn, + type WeekLayoutCache, +} from "../geometry/weekLayoutCache"; import { type WeekTimedDragCommitResult, type WeekTimedDragTarget, } from "../WeekInteractionAdapter.types"; export const createTimedDragInteractionVisual = ({ + layout, pointerStart, sourceRect, target, }: { + layout: WeekLayoutCache; pointerStart: CalendarInteractionPoint; sourceRect: VisualRect; target: WeekTimedDragTarget; -}) => - createTimedDragVisual({ - dayIndex: getLocalDayIndex(target.event.startDate), +}) => { + // Timed events render in the column of their start date, so the date lookup + // is exact; the geometric nearest-column fallback is belt-and-braces. + const startDateKey = dayjs(target.event.startDate).format( + YEAR_MONTH_DAY_FORMAT, + ); + const sourceColumn = + layout.dayColumns.find((column) => column.date === startDateKey) ?? + getNearestDayColumn(layout.dayColumns, sourceRect.left + 1); + + if (!sourceColumn) { + return null; + } + + return createTimedDragVisual({ + dayDate: sourceColumn.date, + dayIndex: sourceColumn.index, endMinutes: getLocalMinutes(target.event.endDate), eventId: target.event._id!, pointerStart, sourceRect, startMinutes: getLocalMinutes(target.event.startDate), }); +}; export const updateTimedDragInteractionVisual = ({ layout, @@ -76,21 +98,3 @@ export const commitTimedDragInteraction = ( type: "timedDragEnd", }; }; - -const getLocalDayIndex = (dateString: string | undefined) => { - if (!dateString) { - return new Date(0).getDay(); - } - - const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateString); - - if (dateOnly) { - return new Date( - Number(dateOnly[1]!), - Number(dateOnly[2]!) - 1, - Number(dateOnly[3]!), - ).getDay(); - } - - return new Date(dateString).getDay(); -}; diff --git a/packages/web/src/views/Week/interaction/adapter/weekEdgeNavigation.ts b/packages/web/src/views/Week/interaction/adapter/weekEdgeNavigation.ts index 4d7d499a1..dd57873e5 100644 --- a/packages/web/src/views/Week/interaction/adapter/weekEdgeNavigation.ts +++ b/packages/web/src/views/Week/interaction/adapter/weekEdgeNavigation.ts @@ -19,7 +19,6 @@ export interface WeekEdgeNavigationUpdate { isDwellActive: boolean; requestedSide: WeekEdgeNavigationSide | null; state: WeekInteractionEdgeNavigationState; - weekOffsetDaysDelta: number; } export interface WeekEdgeNavigationController { @@ -66,7 +65,6 @@ export const createWeekEdgeNavigationController = isDwellActive: false, requestedSide: null, state: idleDraggingState, - weekOffsetDaysDelta: 0, }; } @@ -79,7 +77,6 @@ export const createWeekEdgeNavigationController = isDwellActive: true, requestedSide: null, state: toIndicatorState(nextSide, true, 0), - weekOffsetDaysDelta: 0, }; } @@ -96,7 +93,6 @@ export const createWeekEdgeNavigationController = isDwellActive: false, requestedSide: nextSide, state: toIndicatorState(nextSide, false, 0), - weekOffsetDaysDelta: nextSide === "next" ? 7 : -7, }; } @@ -104,7 +100,6 @@ export const createWeekEdgeNavigationController = isDwellActive: !requested, requestedSide: null, state: toIndicatorState(nextSide, !requested, progress), - weekOffsetDaysDelta: 0, }; }; diff --git a/packages/web/src/views/Week/interaction/registry/weekEventRegistry.test.tsx b/packages/web/src/views/Week/interaction/registry/weekEventRegistry.test.tsx index 4d3890e88..96bcaa091 100644 --- a/packages/web/src/views/Week/interaction/registry/weekEventRegistry.test.tsx +++ b/packages/web/src/views/Week/interaction/registry/weekEventRegistry.test.tsx @@ -74,12 +74,14 @@ const weekProps = { component: { endOfView, startOfView, + weekDays: [...Array(7)].map((_, index) => startOfView.add(index, "day")), }, } as WeekProps; const pastWeekProps = { component: { endOfView: pastWeekStart.add(7, "day"), startOfView: pastWeekStart, + weekDays: [...Array(7)].map((_, index) => pastWeekStart.add(index, "day")), }, } as WeekProps; @@ -215,7 +217,6 @@ const RegisteredAllDayEventHarness = ({ return ( ); }; diff --git a/packages/web/src/views/Week/interaction/useWeekInteractionLayoutSync.ts b/packages/web/src/views/Week/interaction/useWeekInteractionLayoutSync.ts new file mode 100644 index 000000000..8ff125fe3 --- /dev/null +++ b/packages/web/src/views/Week/interaction/useWeekInteractionLayoutSync.ts @@ -0,0 +1,44 @@ +import { useLayoutEffect, useMemo } from "react"; +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import { type WeekProps } from "@web/views/Week/hooks/useWeek"; + +interface RebuildableAdapter { + rebuildLayoutAfterNavigation(): void; +} + +/** + * Shared coordinator wiring for interaction adapters that read the rendered + * week window: returns the visible days as local YYYY-MM-DD keys (for the + * adapter runtime's getVisibleDays) and rebuilds the adapter's drag layout + * after a mid-drag edge navigation re-renders the window. The rebuild is + * keyed on the first *visible* day, not startOfView — within-week window + * paging shifts the rendered columns without changing startOfView. + */ +export function useWeekInteractionLayoutSync( + adapter: RebuildableAdapter, + weekProps: WeekProps, +): string[] { + const visibleDayKeys = useMemo( + () => + weekProps.component.weekDays.map((day) => + day.format(YEAR_MONTH_DAY_FORMAT), + ), + [weekProps.component.weekDays], + ); + const lastNavigationSource = weekProps.util.getLastNavigationSource(); + const renderedFirstDayMs = + weekProps.component.weekDays[0]?.valueOf() ?? Number.NaN; + + useLayoutEffect(() => { + if ( + lastNavigationSource !== "drag-to-edge" || + !Number.isFinite(renderedFirstDayMs) + ) { + return; + } + + adapter.rebuildLayoutAfterNavigation(); + }, [adapter, lastNavigationSource, renderedFirstDayMs]); + + return visibleDayKeys; +} diff --git a/packages/web/src/views/Week/util/week-window.util.test.ts b/packages/web/src/views/Week/util/week-window.util.test.ts new file mode 100644 index 000000000..c3a98d9ed --- /dev/null +++ b/packages/web/src/views/Week/util/week-window.util.test.ts @@ -0,0 +1,121 @@ +import dayjs from "@core/util/date/dayjs"; +import { + anchorDateForWindowOffset, + computeVisibleDayCount, + computeVisibleWindowOffset, + isAllDayEventInVisibleDays, + isTimedEventInVisibleDays, + WEEK_DAY_COUNT, +} from "@web/views/Week/util/week-window.util"; +import { describe, expect, it } from "bun:test"; + +// Sunday-start week used across the tests +const weekStart = dayjs("2026-06-28T00:00:00.000"); +const weekDay = (index: number) => weekStart.add(index, "day"); +const windowDays = (offset: number, count: number) => + [...Array(count)].map((_, index) => weekDay(offset + index)); + +describe("computeVisibleDayCount", () => { + it("fits the full week on wide tracks", () => { + expect(computeVisibleDayCount(1728)).toBe(7); + }); + + it("drops days as the track narrows instead of squishing columns", () => { + // (850 - 50 margin) / 140 usable = 5.7 -> 5 days + expect(computeVisibleDayCount(850)).toBe(5); + expect(computeVisibleDayCount(520)).toBe(3); + }); + + it("never goes below one day or above seven", () => { + expect(computeVisibleDayCount(0)).toBe(1); + expect(computeVisibleDayCount(100000)).toBe(WEEK_DAY_COUNT); + }); +}); + +describe("computeVisibleWindowOffset", () => { + it("is always 0 for a full week", () => { + for (let anchorIndex = 0; anchorIndex < 7; anchorIndex++) { + expect( + computeVisibleWindowOffset({ anchorIndex, visibleDayCount: 7 }), + ).toBe(0); + } + }); + + it("centers the anchor day", () => { + expect( + computeVisibleWindowOffset({ anchorIndex: 3, visibleDayCount: 3 }), + ).toBe(2); // Tue..Thu around Wed + }); + + it("clamps at the week boundaries", () => { + expect( + computeVisibleWindowOffset({ anchorIndex: 0, visibleDayCount: 3 }), + ).toBe(0); + expect( + computeVisibleWindowOffset({ anchorIndex: 6, visibleDayCount: 3 }), + ).toBe(4); + }); + + it("round-trips with anchorDateForWindowOffset", () => { + for (let count = 1; count <= 7; count++) { + const maxOffset = WEEK_DAY_COUNT - count; + for (let offset = 0; offset <= maxOffset; offset++) { + const anchor = anchorDateForWindowOffset({ + weekStart, + windowOffset: offset, + visibleDayCount: count, + }); + expect( + computeVisibleWindowOffset({ + anchorIndex: anchor.diff(weekStart, "day"), + visibleDayCount: count, + }), + ).toBe(offset); + } + } + }); +}); + +describe("isTimedEventInVisibleDays", () => { + const event = { + startDate: weekDay(3).hour(10).format(), + endDate: weekDay(3).hour(11).format(), + }; + + it("shows an event starting on a visible day", () => { + expect(isTimedEventInVisibleDays(event, windowDays(2, 3))).toBe(true); + }); + + it("hides an event starting outside the window", () => { + expect(isTimedEventInVisibleDays(event, windowDays(0, 3))).toBe(false); + expect(isTimedEventInVisibleDays(event, windowDays(4, 3))).toBe(false); + }); +}); + +describe("isAllDayEventInVisibleDays", () => { + // All-day events use exclusive end dates: Mon..Tue spans Mon only + const singleDay = { + startDate: weekDay(1).format("YYYY-MM-DD"), + endDate: weekDay(2).format("YYYY-MM-DD"), + }; + const multiDay = { + startDate: weekDay(1).format("YYYY-MM-DD"), + endDate: weekDay(5).format("YYYY-MM-DD"), // Mon..Thu inclusive + }; + + it("shows a single-day event on its own day", () => { + expect(isAllDayEventInVisibleDays(singleDay, windowDays(0, 3))).toBe(true); + expect(isAllDayEventInVisibleDays(singleDay, windowDays(2, 3))).toBe(false); + }); + + it("shows a multi-day event overlapping the window from either side", () => { + expect(isAllDayEventInVisibleDays(multiDay, windowDays(0, 2))).toBe(true); + expect(isAllDayEventInVisibleDays(multiDay, windowDays(3, 2))).toBe(true); + expect(isAllDayEventInVisibleDays(multiDay, windowDays(5, 2))).toBe(false); + }); + + it("does not leak the exclusive end date into the next day", () => { + // Ends (exclusively) on Friday: hidden in a Fri..Sat window + expect(isAllDayEventInVisibleDays(multiDay, windowDays(5, 2))).toBe(false); + }); +}); diff --git a/packages/web/src/views/Week/util/week-window.util.ts b/packages/web/src/views/Week/util/week-window.util.ts new file mode 100644 index 000000000..cbd546feb --- /dev/null +++ b/packages/web/src/views/Week/util/week-window.util.ts @@ -0,0 +1,96 @@ +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; +import { + CALENDAR_DAY_COLUMN_MIN_USABLE_WIDTH, + CALENDAR_GRID_MARGIN_LEFT, +} from "@web/common/calendar-grid/calendarGrid.constants"; +import { type Schema_GridEvent } from "@web/common/types/web.event.types"; + +export const WEEK_DAY_COUNT = 7; + +/** + * How many day columns fit in the week grid track without squishing events + * below a readable width. The hour labels margin is part of the track, so it + * is subtracted before dividing. + */ +export const computeVisibleDayCount = (trackWidth: number) => + Math.min( + Math.max( + Math.floor( + (trackWidth - CALENDAR_GRID_MARGIN_LEFT) / + CALENDAR_DAY_COLUMN_MIN_USABLE_WIDTH, + ), + 1, + ), + WEEK_DAY_COUNT, + ); + +/** + * Index of the first visible day within the week. The window centers on the + * anchor day and clamps to the week boundaries, so a full-width window is + * always offset 0. + */ +export const computeVisibleWindowOffset = ({ + anchorIndex, + visibleDayCount, +}: { + anchorIndex: number; + visibleDayCount: number; +}) => { + const maxOffset = Math.max(WEEK_DAY_COUNT - visibleDayCount, 0); + const centered = anchorIndex - Math.floor((visibleDayCount - 1) / 2); + return Math.min(Math.max(centered, 0), maxOffset); +}; + +/** + * The anchor day that makes {@link computeVisibleWindowOffset} produce the + * given offset (the center day of that window). Used by paging navigation to + * express "move the window to this offset" in anchor terms. + */ +export const anchorDateForWindowOffset = ({ + weekStart, + windowOffset, + visibleDayCount, +}: { + weekStart: Dayjs; + windowOffset: number; + visibleDayCount: number; +}) => + weekStart.add(windowOffset + Math.floor((visibleDayCount - 1) / 2), "day"); + +type EventDates = Pick; + +/** + * Timed events render in the column of their start date (mirrors + * getCalendarTimedEventPosition), so visibility is start-date membership. + */ +export const isTimedEventInVisibleDays = ( + event: EventDates, + visibleDays: Dayjs[], +) => { + const start = dayjs(event.startDate); + return visibleDays.some((day) => day.isSame(start, "day")); +}; + +/** + * All-day events store an exclusive end date; an event is visible when its + * inclusive span overlaps the window (mirrors getCalendarAllDayEventPosition, + * which clamps the span to the visible dates). + */ +export const isAllDayEventInVisibleDays = ( + event: EventDates, + visibleDays: Dayjs[], +) => { + const visibleStart = visibleDays[0]?.startOf("day"); + const visibleEnd = visibleDays[visibleDays.length - 1]?.startOf("day"); + if (!visibleStart || !visibleEnd) { + return false; + } + + const eventStart = dayjs(event.startDate).startOf("day"); + const exclusiveEnd = dayjs(event.endDate).startOf("day"); + const eventEnd = exclusiveEnd.isAfter(eventStart) + ? exclusiveEnd.subtract(1, "day") + : eventStart; + + return !eventEnd.isBefore(visibleStart) && !eventStart.isAfter(visibleEnd); +};