The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving.
What it does
In the day and week grids an event moves by dragging it, and changes length by dragging its bottom edge — both snapping to fifteen minutes. In the month grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer.
It goes through the same path a menu edit takes: a recurring event is asked which dates it means, and the answer runs through runScoped, so a date the server will only change as part of a whole series offers that rather than failing.
Three things offer no drag, checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a birthday — derived from a contact, with nothing on the server to move. The classes #199 reserved when it added swipe-to-step (.ev-chip, .ev-block) are exactly the ones draggable here, which is what that reservation was for.
Invitations are not sent. A drag is a scheduling gesture; mailing every guest on each nudge of a block is not what the hand was asking for. A change that should go out with notice goes through the editor.
The bug worth reading about
My first version computed a new instant from the reader's local hours and then re-expressed it in the event's time zone. That converts twice, and the two conversions do not cancel.
I caught it in the browser: dragging "Lunch with Grace" from 4 Sept to 10 Sept moved it from 12:00 to 10:00, and then it sat still on every subsequent drag — because after the first shift its stored time and the reader's happened to agree. Both the week and month views agreed on the wrong time, so nothing looked broken.
The fix is to stop going through instants at all. A drag is "move by N minutes" or "move to day D", and both can be expressed in the event's own frame: parse the stored wall clock into its parts, add minutes to those, format it back. No zone is touched, and timeZone is left exactly as it was — dragging an event is not a claim about where it happens. A resize sends only a duration, so the question does not arise there either.
I have not definitively root-caused the original two-hour figure (the mock sets timeZone to the browser's own zone, which weakens the obvious explanation). What I can say is that the conversion path produced it reproducibly and the frame-based path does not.
Testing
27 unit tests over the arithmetic: snapping in both directions, moves across midnight and across a month, the resize clamp at one slot, ISO duration formatting, the three refusals, pixel-to-minute conversion, and the patch shapes — including that a move never sends a duration and a resize never sends a start.
Suite green: web 832 across 86 files, server 116, typecheck clean.
Verified in the running app
Time-grid move 12:00–1:00 → 12:30–1:30 (24px = 30 min, duration held). Resize → 12:30–2:00 (start held). Month drag 4 Sept → 11 Sept with 12:00 preserved. Recurring event raised the scope dialog, and "This occurrence" moved one Standup by an hour while the others stayed put.
Not tested on a real touchscreen — the handlers are pointer-based so touch should work, but the feel of the resize handle under a thumb wants checking.
The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving.
## What it does
In the **day and week** grids an event moves by dragging it, and changes length by dragging its bottom edge — both snapping to fifteen minutes. In the **month** grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer.
It goes through the same path a menu edit takes: a recurring event is asked which dates it means, and the answer runs through `runScoped`, so a date the server will only change as part of a whole series offers that rather than failing.
**Three things offer no drag**, checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a **birthday** — derived from a contact, with nothing on the server to move. The classes #199 reserved when it added swipe-to-step (`.ev-chip`, `.ev-block`) are exactly the ones draggable here, which is what that reservation was for.
**Invitations are not sent.** A drag is a scheduling gesture; mailing every guest on each nudge of a block is not what the hand was asking for. A change that should go out with notice goes through the editor.
## The bug worth reading about
My first version computed a new **instant** from the reader's local hours and then re-expressed it in the event's time zone. That converts twice, and the two conversions do not cancel.
I caught it in the browser: dragging "Lunch with Grace" from 4 Sept to 10 Sept moved it from **12:00 to 10:00**, and then it sat still on every subsequent drag — because after the first shift its stored time and the reader's happened to agree. Both the week and month views agreed on the wrong time, so nothing looked broken.
The fix is to stop going through instants at all. A drag is "move by N minutes" or "move to day D", and both can be expressed in the event's **own frame**: parse the stored wall clock into its parts, add minutes to those, format it back. No zone is touched, and `timeZone` is left exactly as it was — dragging an event is not a claim about where it happens. A resize sends only a `duration`, so the question does not arise there either.
I have **not** definitively root-caused the original two-hour figure (the mock sets `timeZone` to the browser's own zone, which weakens the obvious explanation). What I can say is that the conversion path produced it reproducibly and the frame-based path does not.
## Testing
27 unit tests over the arithmetic: snapping in both directions, moves across midnight and across a month, the resize clamp at one slot, ISO duration formatting, the three refusals, pixel-to-minute conversion, and the patch shapes — including that a move never sends a duration and a resize never sends a start.
Suite green: web 832 across 86 files, server 116, typecheck clean.
## Verified in the running app
Time-grid move 12:00–1:00 → 12:30–1:30 (24px = 30 min, duration held). Resize → 12:30–2:00 (start held). Month drag 4 Sept → 11 Sept with 12:00 preserved. Recurring event raised the scope dialog, and "This occurrence" moved one Standup by an hour while the others stayed put.
Not tested on a real touchscreen — the handlers are pointer-based so touch should work, but the feel of the resize handle under a thumb wants checking.
**Merged** 2026-09-02 as coffey-labs/ihasmail@b4ee545856e5
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving.
What it does
In the day and week grids an event moves by dragging it, and changes length by dragging its bottom edge — both snapping to fifteen minutes. In the month grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer.
It goes through the same path a menu edit takes: a recurring event is asked which dates it means, and the answer runs through
runScoped, so a date the server will only change as part of a whole series offers that rather than failing.Three things offer no drag, checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a birthday — derived from a contact, with nothing on the server to move. The classes #199 reserved when it added swipe-to-step (
.ev-chip,.ev-block) are exactly the ones draggable here, which is what that reservation was for.Invitations are not sent. A drag is a scheduling gesture; mailing every guest on each nudge of a block is not what the hand was asking for. A change that should go out with notice goes through the editor.
The bug worth reading about
My first version computed a new instant from the reader's local hours and then re-expressed it in the event's time zone. That converts twice, and the two conversions do not cancel.
I caught it in the browser: dragging "Lunch with Grace" from 4 Sept to 10 Sept moved it from 12:00 to 10:00, and then it sat still on every subsequent drag — because after the first shift its stored time and the reader's happened to agree. Both the week and month views agreed on the wrong time, so nothing looked broken.
The fix is to stop going through instants at all. A drag is "move by N minutes" or "move to day D", and both can be expressed in the event's own frame: parse the stored wall clock into its parts, add minutes to those, format it back. No zone is touched, and
timeZoneis left exactly as it was — dragging an event is not a claim about where it happens. A resize sends only aduration, so the question does not arise there either.I have not definitively root-caused the original two-hour figure (the mock sets
timeZoneto the browser's own zone, which weakens the obvious explanation). What I can say is that the conversion path produced it reproducibly and the frame-based path does not.Testing
27 unit tests over the arithmetic: snapping in both directions, moves across midnight and across a month, the resize clamp at one slot, ISO duration formatting, the three refusals, pixel-to-minute conversion, and the patch shapes — including that a move never sends a duration and a resize never sends a start.
Suite green: web 832 across 86 files, server 116, typecheck clean.
Verified in the running app
Time-grid move 12:00–1:00 → 12:30–1:30 (24px = 30 min, duration held). Resize → 12:30–2:00 (start held). Month drag 4 Sept → 11 Sept with 12:00 preserved. Recurring event raised the scope dialog, and "This occurrence" moved one Standup by an hour while the others stayed put.
Not tested on a real touchscreen — the handlers are pointer-based so touch should work, but the feel of the resize handle under a thumb wants checking.
Merged 2026-09-02 as coffey-labs/ihasmail@b4ee545856
Rebuilt from: git history, session transcript.