Calendar drag snapping: snap to neighbouring item edges and Now (not just 15-min steps); configurable in Settings → Calendar #536

Closed
opened 2026-09-30 17:23:24 +00:00 by kayg · 15 comments
Owner

Owner request (2026-09-30, two screenshots of drag-to-create in Calendar Week)

"when i am pulling from under the last event, the end of last event should be a sticking point as well (as well as the current cursor) right now it only sticks to 15 minute intervals! this should also be in Settings → Calendar configurable in a very user friendly wording"
Behaviour (drag-to-create, drag-to-move and resize, for Logs, Events and Tasks in Day/Week/3-day):

  • Snap targets, in priority order within a small magnetic radius (for example ±6 px, or ±5 min at the current zoom):
    1. the end or start of neighbouring items in the same day column (dragging from under "Logging events" starts exactly at its end; resizing up to meet an item meets it exactly);
    2. Now (the current-time line; for Logs this is the most common target);
    3. the grid interval (default 15 min).
  • When a snap engages: a subtle haptic-like tick (a brief highlight of the target edge or the now line, and the time label pops with the spring). The live "21:45 – 22:45" labels show the snapped times.
  • Holding Alt/Option while dragging disables snapping (free minutes). Keyboard nudges (arrows) move by the grid interval with no animation (#527).
    Settings → Calendar (plain words, Title Case section names), in a group named for example "Dragging Items":
  • "Snap to other items": on/off (default on). Helper: "Line up with the start or end of items next to it."
  • "Snap to now": on/off (default on). Helper: "Line up with the current time."
  • "Time steps": 5 / 10 / 15 / 30 minutes (default 15). Helper: "How far items move when you drag them."
    Stored per User; the API, CLI and MCP parity comes via the #484 registry.
    Tests: unit tests for the snap resolver (priority, radius, Alt override, zoom scaling); an e2e dragging from under an item that starts exactly at its end; snapping to now; changing the setting takes effect. Frame strips of the tick. Web gates.
## Owner request (2026-09-30, two screenshots of drag-to-create in Calendar Week) "when i am pulling from under the last event, the end of last event should be a sticking point as well (as well as the current cursor) right now it only sticks to 15 minute intervals! this should also be in Settings → Calendar configurable in a very user friendly wording" **Behaviour (drag-to-create, drag-to-move and resize, for Logs, Events and Tasks in Day/Week/3-day):** - Snap targets, in priority order within a small magnetic radius (for example ±6 px, or ±5 min at the current zoom): 1. the **end or start of neighbouring items** in the same day column (dragging from under "Logging events" starts exactly at its end; resizing up to meet an item meets it exactly); 2. **Now** (the current-time line; for Logs this is the most common target); 3. the grid interval (default 15 min). - When a snap engages: a subtle haptic-like tick (a brief highlight of the target edge or the now line, and the time label pops with the spring). The live "21:45 – 22:45" labels show the snapped times. - Holding Alt/Option while dragging disables snapping (free minutes). Keyboard nudges (arrows) move by the grid interval with no animation (#527). **Settings → Calendar** (plain words, Title Case section names), in a group named for example "Dragging Items": - "Snap to other items": on/off (default on). Helper: "Line up with the start or end of items next to it." - "Snap to now": on/off (default on). Helper: "Line up with the current time." - "Time steps": 5 / 10 / 15 / 30 minutes (default 15). Helper: "How far items move when you drag them." Stored per User; the API, CLI and MCP parity comes via the #484 registry. **Tests:** unit tests for the snap resolver (priority, radius, Alt override, zoom scaling); an e2e dragging from under an item that starts exactly at its end; snapping to now; changing the setting takes effect. Frame strips of the tick. Web gates.
Author
Owner

Starting #536 on branch job/drag-snap-536. Base SHA: aa372eef6c (origin/dev). I have read CLAUDE.md, docs/DESIGN.md, CONTEXT.md, and the issue. I am tracing the existing calendar drag and preference paths before adding the snap behavior, settings, and requested coverage.

Starting #536 on branch job/drag-snap-536. Base SHA: aa372eef6c9312403f59a65d5b90cb3648ff52ed (origin/dev). I have read CLAUDE.md, docs/DESIGN.md, CONTEXT.md, and the issue. I am tracing the existing calendar drag and preference paths before adding the snap behavior, settings, and requested coverage.
Author
Owner

Finding during implementation: packages/ui/src/components/calendar/TimeGrid.svelte only exposes onmove for CalendarLog, and GridColumn.svelte wires pointer drag handlers only for Log blocks. Event and Task blocks are currently selection-only. The Calendar view model also defines only Today, Day, Week, Month and Year (packages/ui/src/components/calendar/model.ts), with no 3-day route. I am extending the existing timed-item drag path where the current data/API supports it; the snap resolver will work for any visible day-column count.

Finding during implementation: `packages/ui/src/components/calendar/TimeGrid.svelte` only exposes `onmove` for `CalendarLog`, and `GridColumn.svelte` wires pointer drag handlers only for Log blocks. Event and Task blocks are currently selection-only. The Calendar view model also defines only Today, Day, Week, Month and Year (`packages/ui/src/components/calendar/model.ts`), with no 3-day route. I am extending the existing timed-item drag path where the current data/API supports it; the snap resolver will work for any visible day-column count.
Author
Owner

Correction to my earlier capability note: Calendar Events already have PUT /api/v1/calendar/events/{id} and POST /api/v1/calendar/events/{id}/move routes in crates/plugins/calendar/src/routes.rs. The Calendar view does not call them for drag edits today. The Task PATCH /api/v1/notes/tasks/properties route in crates/plugins/notes/src/tasks_api.rs accepts date properties (start, due, scheduled) but not their clock fields, so moving a timed Task needs a Notes API behavior change. The checked-in Calendar design also does not define a 3-day view.

This job will keep snap candidates for timed Logs, Events and Tasks and complete the existing Log create/move/resize path and User settings. I will report the Event drag write path, Task clock-edit path, and 3-day view as remaining scope where their existing contracts do not provide the requested behavior.

Correction to my earlier capability note: Calendar Events already have `PUT /api/v1/calendar/events/{id}` and `POST /api/v1/calendar/events/{id}/move` routes in `crates/plugins/calendar/src/routes.rs`. The Calendar view does not call them for drag edits today. The Task `PATCH /api/v1/notes/tasks/properties` route in `crates/plugins/notes/src/tasks_api.rs` accepts date properties (`start`, `due`, `scheduled`) but not their clock fields, so moving a timed Task needs a Notes API behavior change. The checked-in Calendar design also does not define a 3-day view. This job will keep snap candidates for timed Logs, Events and Tasks and complete the existing Log create/move/resize path and User settings. I will report the Event drag write path, Task clock-edit path, and 3-day view as remaining scope where their existing contracts do not provide the requested behavior.
Author
Owner

The first web check found four 180ms animation durations in the new snap feedback. The repo motion-token check requires shared duration tokens. I changed those transitions to var(--dur-open) and will rerun the check.

The first web check found four `180ms` animation durations in the new snap feedback. The repo motion-token check requires shared duration tokens. I changed those transitions to `var(--dur-open)` and will rerun the check.
Author
Owner

The first real-server drag run reached the Settings control and exposed a test selector issue: getByRole('radio', { name: '5 min' }) also matched 15 min by substring, so Playwright stopped before the step setting and screenshot sweep. The selector now uses exact: true, and the state wait compares the radio's exact aria-label.

The first real-server drag run reached the Settings control and exposed a test selector issue: `getByRole('radio', { name: '5 min' })` also matched `15 min` by substring, so Playwright stopped before the step setting and screenshot sweep. The selector now uses `exact: true`, and the state wait compares the radio's exact `aria-label`.
Author
Owner

E2E finding: the time-step setting case attempted to create from 15:06 to 15:36 while its own fixture Log occupied 15:00–16:00. The real TimeGrid deliberately skips create drags that start on .block targets, so the timeout was in the test setup. I moved that assertion to an empty 17:06–17:36 range and added an explicit empty-grid target check so future fixture overlaps fail with the hit identity instead of timing out.

E2E finding: the time-step setting case attempted to create from 15:06 to 15:36 while its own fixture Log occupied 15:00–16:00. The real TimeGrid deliberately skips create drags that start on `.block` targets, so the timeout was in the test setup. I moved that assertion to an empty 17:06–17:36 range and added an explicit empty-grid target check so future fixture overlaps fail with the hit identity instead of timing out.
Author
Owner

E2E finding: the Now-snap scenario disabled “Snap to other items”; the following Log move then correctly used its 5-minute grid and stopped at 13:35. The test now restores both snap switches through Settings before checking that the moved Log meets the 13:37 neighbour edge.

E2E finding: the Now-snap scenario disabled “Snap to other items”; the following Log move then correctly used its 5-minute grid and stopped at 13:35. The test now restores both snap switches through Settings before checking that the moved Log meets the 13:37 neighbour edge.
Author
Owner

E2E finding: after the 5-minute step assertion, the focused flow continued into the existing touch proof, whose expected 08:30–08:45 preview assumes the default 15-minute step. The proof now restores the default through Settings before the touch and responsive screenshot matrix.

E2E finding: after the 5-minute step assertion, the focused flow continued into the existing touch proof, whose expected 08:30–08:45 preview assumes the default 15-minute step. The proof now restores the default through Settings before the touch and responsive screenshot matrix.
Author
Owner

Screenshot E2E finding: at 1440 px after a route navigation, browser scroll restoration left the Calendar column at top −434.8 px with a 51.36 px hour. The 08:30 press then landed at y≈2 px beneath the fixed header, so the real grid received no create gesture. The responsive Day/Week screenshot drags now use 17:30, which stays visible with the restored morning scroll and does not overlap the test Logs.

Screenshot E2E finding: at 1440 px after a route navigation, browser scroll restoration left the Calendar column at top −434.8 px with a 51.36 px hour. The 08:30 press then landed at y≈2 px beneath the fixed header, so the real grid received no create gesture. The responsive Day/Week screenshot drags now use 17:30, which stays visible with the restored morning scroll and does not overlap the test Logs.
Author
Owner

Interaction coverage finding before final gates: GridColumn originally exposed pointer move/resize only for Logs. The Calendar plugin already has PUT /calendar/events/{id} for updating an Event iCalendar body, and Notes TaskView already returns start_time, scheduled_time and due_time; /notes/tasks/properties currently accepts only date fields. I am wiring pointer edits for writable, non-recurring timed Events and file Tasks through those existing write paths, adding the three Task clock fields to the properties patch. Recurring Event instances and inline Task property edits need separate write semantics; the current routes do not provide those semantics. DESIGN.md has no 3-day view, so this work uses the existing Day and Week TimeGrid only.

Interaction coverage finding before final gates: `GridColumn` originally exposed pointer move/resize only for Logs. The Calendar plugin already has `PUT /calendar/events/{id}` for updating an Event iCalendar body, and Notes `TaskView` already returns `start_time`, `scheduled_time` and `due_time`; `/notes/tasks/properties` currently accepts only date fields. I am wiring pointer edits for writable, non-recurring timed Events and file Tasks through those existing write paths, adding the three Task clock fields to the properties patch. Recurring Event instances and inline Task property edits need separate write semantics; the current routes do not provide those semantics. `DESIGN.md` has no 3-day view, so this work uses the existing Day and Week TimeGrid only.
Author
Owner

E2E finding from the real Calendar API: a writable one-off Event is projected with id = cached-id@UTC-start (example test record: c2ea4b59-fa2a-4c8d-b246-c1e7d229cd1d@2026-09-28T08:00:00Z). GridColumn treated every @ ID as recurring, so the Event never received its drag handler; the pointer landed on the adjacent Log instead. The API's GET /calendar/events/{id} resolves this occurrence ID to the base cached Event ID, while PUT only accepts the base ID. I’m adding an explicit recurrence flag to the existing Event view and using the resolved base ID for safe one-off updates; recurring Event instances will stay read-only until the API has occurrence-specific write semantics. This is the small Calendar API addition needed to implement the requested Event drag behavior safely.

E2E finding from the real Calendar API: a writable one-off Event is projected with `id = cached-id@UTC-start` (example test record: `c2ea4b59-fa2a-4c8d-b246-c1e7d229cd1d@2026-09-28T08:00:00Z`). `GridColumn` treated every `@` ID as recurring, so the Event never received its drag handler; the pointer landed on the adjacent Log instead. The API's `GET /calendar/events/{id}` resolves this occurrence ID to the base cached Event ID, while `PUT` only accepts the base ID. I’m adding an explicit recurrence flag to the existing Event view and using the resolved base ID for safe one-off updates; recurring Event instances will stay read-only until the API has occurrence-specific write semantics. This is the small Calendar API addition needed to implement the requested Event drag behavior safely.
Author
Owner

E2E fixture finding: the earlier Log move consumed the only 15:00 edge, so the Event resize did not have an item target there. I moved the real Task fixture to 14:00–15:00 to keep a neighboring edge for the Event and Task resize cases. The resize pointer also needs to account for the visual lane gap at the block bottom; the test now approaches the exact boundary from inside the magnetic radius. The browser fixture waits for the TimeGrid scroll position to settle before mapping pixels to time.

Verification: calendar drag e2e: live labels and touch drag passed.

Commit: 757490aa1 (test: exercise planned-item edge snapping).

E2E fixture finding: the earlier Log move consumed the only 15:00 edge, so the Event resize did not have an item target there. I moved the real Task fixture to 14:00–15:00 to keep a neighboring edge for the Event and Task resize cases. The resize pointer also needs to account for the visual lane gap at the block bottom; the test now approaches the exact boundary from inside the magnetic radius. The browser fixture waits for the TimeGrid scroll position to settle before mapping pixels to time. Verification: `calendar drag e2e: live labels and touch drag passed`. Commit: `757490aa1` (`test: exercise planned-item edge snapping`).
Author
Owner

Finding while tightening the #536 OpenAPI enum: bun run --cwd apps/web check reported that the settings handler's Number(value) is typed as number, while the generated request schema now correctly permits only 5, 10, 15, or 30. The control options already come from CALENDAR_TIME_STEPS; I am mapping the selected string through that tuple so the handler sends only supported values.

Finding while tightening the #536 OpenAPI enum: `bun run --cwd apps/web check` reported that the settings handler's `Number(value)` is typed as `number`, while the generated request schema now correctly permits only 5, 10, 15, or 30. The control options already come from `CALENDAR_TIME_STEPS`; I am mapping the selected string through that tuple so the handler sends only supported values.
Author
Owner

Completed

Implemented #536 on branch job/drag-snap-536. Head: 8121b880f8f9d287fa4a93089eb15c98e2db3768 (api: constrain Calendar time-step choices). No push, deploy, merge, or issue close was done.

Calendar drag create, move, and resize now magnetically prefer neighbouring item edges, then Now, then the selected grid step. Alt/Option disables snapping. The resolver uses a zoom-scaled radius capped at five minutes (about six screen pixels at the current zoom). Supported steps are 5, 10, 15 and 30 minutes, with 15 as the default. Calendar preferences are stored per User and exposed through the shared preference API. Settings shows the requested “Dragging Items” group and wording. A target tick highlights item edges or Now, and snapped time labels update live. Keyboard nudges use the selected step without animation.

One-off writable Events and timed file Tasks support drag move/resize and keyboard nudges. Recurrence instances, URL-subscription Events and date-only/inline Tasks are not written because the existing APIs do not provide safe occurrence or schedule edits for those items. The design defines Day and Week grids but no 3-day view, so 3-day behavior remains unimplemented.

Files

  • apps/web/e2e/calendar.mjs, apps/web/e2e/harness.mjs
  • apps/web/src/lib/calendar/data.ts, data.test.ts, model.test.ts, prefs.ts, prefs.test.ts
  • apps/web/src/routes/calendar/[view]/[date]/+page.svelte
  • apps/web/src/routes/settings/calendars/CalendarsSection.svelte, apps/web/src/routes/settings/sections.ts, sections.test.ts
  • packages/ui/src/components/calendar/snap.ts, model.ts, TimeGrid.svelte, GridColumn.svelte
  • crates/plugins/calendar/src/items.rs, view.rs, recurrence.rs, feeds/subscriptions.rs
  • crates/plugins/notes/src/tasks_api.rs
  • contracts/openapi.json, packages/api-client/src/generated.ts
  • tests/adversarial/calendar_event_tags.mjs
  • bench/calendar-snap-536.mjs, bench/run.sh, docs/perf/baseline.json

Gates

cargo fmt --check exited 0 with no output.

Calendar crate clippy output:

Finished dev profile [unoptimized + debuginfo] target(s) in 36.36s

Calendar crate test summaries:

test result: ok. 81 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.61s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Notes crate clippy and tests also passed after its source change (132 tests and one Apple replay test passed).

Server clippy output:

Finished dev profile [unoptimized + debuginfo] target(s) in 58.98s

Server test output:

test result: ok. 93 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 36.11s

Web check output:

svelte-check found 0 errors and 0 warnings

Web test output:

 Test Files  140 passed (140)
      Tests  926 passed (926)
   Start at  00:37:33
   Duration  157.15s (transform 54%, environment 17%, import 15%, tests 10%, setup 3%)

Focused drag e2e output:

calendar drag e2e: live labels and touch drag passed

Adversarial probe output:

Calendar probe: Event tag Unicode/bidi and 65536-byte input; #536 default, hostile, auth and concurrent drag preference cases; timed Task invalid/oversized/auth and 16-write storm cases; 24 parallel tag/range/search reads passed

The production screenshot matrix is attached to this issue: Day, Week and Calendar Settings at 390, 820 and 1440 px in light and dark themes, plus three snap tick frames (21 screenshots).

The snap benchmark ran locally because the perf VM lock was busy. Average profile (400 edges, 2,000 resolver calls): p50 4.36 µs, p95 13.27 µs, CPU 0.0176 s, RSS 21,917,696 → 31,207,424 bytes. Worst profile (10,000 edges, 1,000 calls): p50 41.44 µs, p95 70.17 µs, CPU 0.0494 s, RSS 33,878,016 → 34,201,600 bytes. Burst (2,000 calls): CPU 0.1011 s, RSS 35,512,320 bytes. Local host load average was 19.01, 19.76, 25.07.

Decisions and known gaps

  • Chose a snap radius of about six screen pixels, capped at five minutes, to keep attraction consistent as the grid zoom changes.
  • Applied this to the existing Day and Week grids because DESIGN has no 3-day view.
  • Event writes use the stable cached base Event identity after resolving the occurrence. Occurrence-specific recurrence writes remain unsupported by the current API.
  • The benchmark is local and its high host load is recorded above; it is not a perf-VM measurement.

cargo clean completed and the generated apps/web/build and apps/web/.svelte-kit directories were removed. The worktree is clean.

## Completed Implemented #536 on branch `job/drag-snap-536`. Head: `8121b880f8f9d287fa4a93089eb15c98e2db3768` (`api: constrain Calendar time-step choices`). No push, deploy, merge, or issue close was done. Calendar drag create, move, and resize now magnetically prefer neighbouring item edges, then Now, then the selected grid step. Alt/Option disables snapping. The resolver uses a zoom-scaled radius capped at five minutes (about six screen pixels at the current zoom). Supported steps are 5, 10, 15 and 30 minutes, with 15 as the default. Calendar preferences are stored per User and exposed through the shared preference API. Settings shows the requested “Dragging Items” group and wording. A target tick highlights item edges or Now, and snapped time labels update live. Keyboard nudges use the selected step without animation. One-off writable Events and timed file Tasks support drag move/resize and keyboard nudges. Recurrence instances, URL-subscription Events and date-only/inline Tasks are not written because the existing APIs do not provide safe occurrence or schedule edits for those items. The design defines Day and Week grids but no 3-day view, so 3-day behavior remains unimplemented. ## Files - `apps/web/e2e/calendar.mjs`, `apps/web/e2e/harness.mjs` - `apps/web/src/lib/calendar/data.ts`, `data.test.ts`, `model.test.ts`, `prefs.ts`, `prefs.test.ts` - `apps/web/src/routes/calendar/[view]/[date]/+page.svelte` - `apps/web/src/routes/settings/calendars/CalendarsSection.svelte`, `apps/web/src/routes/settings/sections.ts`, `sections.test.ts` - `packages/ui/src/components/calendar/snap.ts`, `model.ts`, `TimeGrid.svelte`, `GridColumn.svelte` - `crates/plugins/calendar/src/items.rs`, `view.rs`, `recurrence.rs`, `feeds/subscriptions.rs` - `crates/plugins/notes/src/tasks_api.rs` - `contracts/openapi.json`, `packages/api-client/src/generated.ts` - `tests/adversarial/calendar_event_tags.mjs` - `bench/calendar-snap-536.mjs`, `bench/run.sh`, `docs/perf/baseline.json` ## Gates `cargo fmt --check` exited 0 with no output. Calendar crate clippy output: ```text Finished dev profile [unoptimized + debuginfo] target(s) in 36.36s ``` Calendar crate test summaries: ```text test result: ok. 81 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.61s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s ``` Notes crate clippy and tests also passed after its source change (132 tests and one Apple replay test passed). Server clippy output: ```text Finished dev profile [unoptimized + debuginfo] target(s) in 58.98s ``` Server test output: ```text test result: ok. 93 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 36.11s ``` Web check output: ```text svelte-check found 0 errors and 0 warnings ``` Web test output: ```text Test Files 140 passed (140) Tests 926 passed (926) Start at 00:37:33 Duration 157.15s (transform 54%, environment 17%, import 15%, tests 10%, setup 3%) ``` Focused drag e2e output: ```text calendar drag e2e: live labels and touch drag passed ``` Adversarial probe output: ```text Calendar probe: Event tag Unicode/bidi and 65536-byte input; #536 default, hostile, auth and concurrent drag preference cases; timed Task invalid/oversized/auth and 16-write storm cases; 24 parallel tag/range/search reads passed ``` The production screenshot matrix is attached to this issue: Day, Week and Calendar Settings at 390, 820 and 1440 px in light and dark themes, plus three snap tick frames (21 screenshots). The snap benchmark ran locally because the perf VM lock was busy. Average profile (400 edges, 2,000 resolver calls): p50 4.36 µs, p95 13.27 µs, CPU 0.0176 s, RSS 21,917,696 → 31,207,424 bytes. Worst profile (10,000 edges, 1,000 calls): p50 41.44 µs, p95 70.17 µs, CPU 0.0494 s, RSS 33,878,016 → 34,201,600 bytes. Burst (2,000 calls): CPU 0.1011 s, RSS 35,512,320 bytes. Local host load average was 19.01, 19.76, 25.07. ## Decisions and known gaps - Chose a snap radius of about six screen pixels, capped at five minutes, to keep attraction consistent as the grid zoom changes. - Applied this to the existing Day and Week grids because DESIGN has no 3-day view. - Event writes use the stable cached base Event identity after resolving the occurrence. Occurrence-specific recurrence writes remain unsupported by the current API. - The benchmark is local and its high host load is recorded above; it is not a perf-VM measurement. `cargo clean` completed and the generated `apps/web/build` and `apps/web/.svelte-kit` directories were removed. The worktree is clean.
Author
Owner

Shipped in merge round 4, deployed to calternal.cloud in 1af8ead26 (healthy).

Shipped in merge round 4, deployed to calternal.cloud in 1af8ead26 (healthy).
kayg closed this issue 2026-10-01 09:17:48 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#536
No description provided.