Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ 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)

## Runtime Flows

- [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)

Expand Down
2 changes: 1 addition & 1 deletion docs/development/feature-file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 4 additions & 4 deletions docs/development/testing-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 9 additions & 1 deletion docs/frontend/frontend-runtime-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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:
Expand Down
90 changes: 90 additions & 0 deletions docs/frontend/week-drag-interaction.md
Original file line number Diff line number Diff line change
@@ -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<br/>weekProps.weekDays<br/>['06-29', …, '07-04']"] -->|"runtime().getVisibleDays()<br/>(fresh every render)"| B["Drag start<br/>layout cache columns<br/>{index:0, date:'06-29'}<br/>{index:1, date:'06-30'}, …"]
B -->|"pointer moves to column N"| C["visual.dayDate = column N's date"]
C -->|"pointerup"| D["Commit<br/>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.
66 changes: 66 additions & 0 deletions e2e/navigation/responsive-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading