Composer: keep drafts on dismiss, commit-freeze snapshot, and the rest of calternal.js composer parity #93

Open
opened 2026-09-25 14:02:08 +00:00 by kayg · 2 comments
Owner

Problem

calternal's log composer is the Calendar creation popover: packages/ui/src/components/calendar/CreateForm.svelte, opened by a grid click or drag and by the Calendar FAB (calternal:open-log-entry in apps/web/src/routes/+layout.svelte, handled by createAtNow / saveDraft in apps/web/src/routes/calendar/[view]/[date]/+page.svelte). It already does: the range decides log entry (past or now) or event (future, DESIGN §30 C14); inline #tags as pills; text beyond one line saves the first line as the entry and the rest as a linked note (onnote, #42). The calternal.js composer had more, and DESIGN §18 says to import it and expand it.

Gaps, in priority order:

  1. Draft lost on dismiss (HIGH, data-loss aversion). Type a long entry, then press Esc, tap outside, or navigate: the text is gone. Reproduce: open Calendar Day, press the FAB, type two lines, press Esc, press the FAB again: the field is empty.
  2. Commit-freeze snapshot (HIGH, with 1). At send time, capture { text, date, start, end, kind, calendar } as one value and commit from it; never re-read live form state after an await. This removes the "user edited during save" race class.
  3. Tag insert at the caret on touch (LOW). A # action that inserts at the caret, not at the end. Only actions that work; no placeholder buttons.
  4. Long-press token editing (LOW). Long-press a parsed time or tag to change or clear it; touch and screen-reader action sets must be the same, and a picker must write text that the parser reads back to the same value.
  5. Draft stack and batch submit (MEDIUM, needs confirmation). Return on an empty line freezes the draft into a compact card and gives a clean field; "Send all" commits the active draft plus the queued ones. Partial success: successes leave the queue, failures stay for retry, each queued draft has a typed id.

Decisions already made

  • calternal.js owner decisions (sub-project C, 2026-07-03): the action row ships with # insert plus a fixed Send only; attachments and voice-note recording join later with note attachments (voice is an audio attachment, not dictation); Web Speech dictation is deferred; the tappable date bubble is approved; mode auto-switch at high confidence with hysteresis. Items 1-6 and 10 of calternal.js#69 shipped there; 7-9 were still open.
  • calternal changes (DESIGN §30, §31): log entry vs event is decided by the time range, not by a mode pill, so calternal.js's mode auto-switch (item 6) does not apply. Tasks from the composer wait for the task model port (#55). Daily notes are gatekept (K5): the composer writes log entries; longer text becomes a linked note (already done).
  • Drafts: one store, the single source of truth (calternal.js iOS had two mirrored stores and that was its worst bug source). Keep drafts per user and per day in the browser (IndexedDB), debounce on type, flush on close; restore on the next open for the same day, even after reload; prune empty drafts. A draft never leaves the device until it is sent.
  • DESIGN §35 essentials only: items 3, 4 and 5 add visible controls. Confirm them with the owner before building; items 1 and 2 add no chrome and can go now.

Relevant files

  • packages/ui/src/components/calendar/CreateForm.svelte, CalendarPopover.svelte
  • apps/web/src/routes/calendar/[view]/[date]/+page.svelte (startCreate, createAtNow, saveDraft)
  • apps/web/src/lib/calendar/journal.ts (createLogEntry and friends)
  • apps/web/src/lib/selection.ts and apps/web/src/lib/gestures.ts (long-press without native selection)

Acceptance criteria

  • Item 1: Esc, outside tap, route change and reload keep the draft; reopening on the same day restores text and range; sending clears it; a unit test covers the store and a Chromium test covers dismiss-and-restore at 390x844 and 1280x900.
  • Item 2: a test edits the field while createLogEntry is pending and asserts the saved entry is the snapshot and the field keeps the new text.
  • Items 3-5 (after confirmation): keyboard and screen-reader paths for every action; hit areas from the size tokens; reduced motion.
  • No draft text in logs or server requests before send.
  • Gates: bun run check, bun run test in apps/web and packages/ui, quoted verbatim.

Carried over from kayg/calternal.js#69

## Problem calternal's log composer is the Calendar creation popover: `packages/ui/src/components/calendar/CreateForm.svelte`, opened by a grid click or drag and by the Calendar FAB (`calternal:open-log-entry` in `apps/web/src/routes/+layout.svelte`, handled by `createAtNow` / `saveDraft` in `apps/web/src/routes/calendar/[view]/[date]/+page.svelte`). It already does: the range decides log entry (past or now) or event (future, DESIGN §30 C14); inline `#tags` as pills; text beyond one line saves the first line as the entry and the rest as a linked note (`onnote`, #42). The calternal.js composer had more, and DESIGN §18 says to import it and expand it. Gaps, in priority order: 1. **Draft lost on dismiss (HIGH, data-loss aversion).** Type a long entry, then press Esc, tap outside, or navigate: the text is gone. Reproduce: open Calendar Day, press the FAB, type two lines, press Esc, press the FAB again: the field is empty. 2. **Commit-freeze snapshot (HIGH, with 1).** At send time, capture `{ text, date, start, end, kind, calendar }` as one value and commit from it; never re-read live form state after an await. This removes the "user edited during save" race class. 3. **Tag insert at the caret on touch (LOW).** A `#` action that inserts at the caret, not at the end. Only actions that work; no placeholder buttons. 4. **Long-press token editing (LOW).** Long-press a parsed time or tag to change or clear it; touch and screen-reader action sets must be the same, and a picker must write text that the parser reads back to the same value. 5. **Draft stack and batch submit (MEDIUM, needs confirmation).** Return on an empty line freezes the draft into a compact card and gives a clean field; "Send all" commits the active draft plus the queued ones. Partial success: successes leave the queue, failures stay for retry, each queued draft has a typed id. ## Decisions already made - calternal.js owner decisions (sub-project C, 2026-07-03): the action row ships with `#` insert plus a fixed Send only; attachments and voice-note recording join later with note attachments (voice is an audio attachment, not dictation); Web Speech dictation is deferred; the tappable date bubble is approved; mode auto-switch at high confidence with hysteresis. Items 1-6 and 10 of calternal.js#69 shipped there; 7-9 were still open. - calternal changes (DESIGN §30, §31): log entry vs event is decided by the time range, not by a mode pill, so calternal.js's mode auto-switch (item 6) does not apply. Tasks from the composer wait for the task model port (#55). Daily notes are gatekept (K5): the composer writes log entries; longer text becomes a linked note (already done). - Drafts: one store, the single source of truth (calternal.js iOS had two mirrored stores and that was its worst bug source). Keep drafts per user and per day in the browser (IndexedDB), debounce on type, flush on close; restore on the next open for the same day, even after reload; prune empty drafts. A draft never leaves the device until it is sent. - DESIGN §35 essentials only: items 3, 4 and 5 add visible controls. Confirm them with the owner before building; items 1 and 2 add no chrome and can go now. ## Relevant files - `packages/ui/src/components/calendar/CreateForm.svelte`, `CalendarPopover.svelte` - `apps/web/src/routes/calendar/[view]/[date]/+page.svelte` (`startCreate`, `createAtNow`, `saveDraft`) - `apps/web/src/lib/calendar/journal.ts` (`createLogEntry` and friends) - `apps/web/src/lib/selection.ts` and `apps/web/src/lib/gestures.ts` (long-press without native selection) ## Acceptance criteria - Item 1: Esc, outside tap, route change and reload keep the draft; reopening on the same day restores text and range; sending clears it; a unit test covers the store and a Chromium test covers dismiss-and-restore at 390x844 and 1280x900. - Item 2: a test edits the field while `createLogEntry` is pending and asserts the saved entry is the snapshot and the field keeps the new text. - Items 3-5 (after confirmation): keyboard and screen-reader paths for every action; hit areas from the size tokens; reduced motion. - No draft text in logs or server requests before send. - Gates: `bun run check`, `bun run test` in `apps/web` and `packages/ui`, quoted verbatim. Carried over from kayg/calternal.js#69
Author
Owner

Items 1 and 2 are done on branch job/composer-drafts (not merged). Items 3-5 are not built: they need owner decisions (DESIGN §35).

Commits:

  • ed9a292 feat(web): composer draft store per user and day
  • a2efb88 feat(web): keep composer drafts on dismiss and commit from a frozen snapshot
  • 24c6308 test(web): e2e for composer drafts and commit-freeze

Behaviour:

Action Result
Esc, outside tap, close button, paging, opening a preview Composer closes. Draft kept. "Draft kept" is announced (polite live region).
Route change, reload, tab hidden Pending draft is written first (beforeNavigate, pagehide, visibilitychange).
Reopen with FAB or N on the same day Text and range come back.
Reopen with a drag on the grid, same day Text comes back; the drag's range is kept.
Successful send Draft cleared, composer closes.
Text typed while the send runs Saved entry is the snapshot. Field and draft keep the newer text; composer stays open.
Send fails Error in the composer; draft kept (toast when that composer has closed).
Discard (the footer button reads "Discard" when there is text, "Cancel" when empty) Draft cleared at once; toast "Draft discarded" with Undo (UNDO_MS), Undo reopens with the text.
Storage blocked Page-memory fallback: drafts survive close and reopen, not a reload.
Hover over a block while composing Hover preview no longer closes the composer.

Notes:

  • The store uses localStorage, not IndexedDB: a synchronous write finishes inside pagehide; an IndexedDB transaction there can be dropped. Key: calternal.composer-draft.v1:<user-id>:<date>. Text never goes to the server before send.
  • Explicit discard uses an Undo toast, not ConfirmSheet: one tap, and the same undo pattern as the rest of Calendar.

Gates (apps/web):

  • bun run check: COMPLETED 1383 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
  • bun run test: Test Files 36 passed (36), Tests 252 passed (252)
  • bun run build: ✓ built in 26.69s, ✔ done
  • bun run test:e2e:calendar: the new steps pass (6a at 1280x900, step 8 at 390x844, run with steps 6b-7 cut out). The full flow fails at step 6b "drag did not move the entry to 10:00". This also fails on dev 9ae55cf with no changes. It is a server data-corruption bug: see #94.
Items 1 and 2 are done on branch `job/composer-drafts` (not merged). Items 3-5 are not built: they need owner decisions (DESIGN §35). Commits: - `ed9a292` feat(web): composer draft store per user and day - `a2efb88` feat(web): keep composer drafts on dismiss and commit from a frozen snapshot - `24c6308` test(web): e2e for composer drafts and commit-freeze Behaviour: | Action | Result | |---|---| | Esc, outside tap, close button, paging, opening a preview | Composer closes. Draft kept. "Draft kept" is announced (polite live region). | | Route change, reload, tab hidden | Pending draft is written first (beforeNavigate, pagehide, visibilitychange). | | Reopen with FAB or N on the same day | Text and range come back. | | Reopen with a drag on the grid, same day | Text comes back; the drag's range is kept. | | Successful send | Draft cleared, composer closes. | | Text typed while the send runs | Saved entry is the snapshot. Field and draft keep the newer text; composer stays open. | | Send fails | Error in the composer; draft kept (toast when that composer has closed). | | Discard (the footer button reads "Discard" when there is text, "Cancel" when empty) | Draft cleared at once; toast "Draft discarded" with Undo (UNDO_MS), Undo reopens with the text. | | Storage blocked | Page-memory fallback: drafts survive close and reopen, not a reload. | | Hover over a block while composing | Hover preview no longer closes the composer. | Notes: - The store uses localStorage, not IndexedDB: a synchronous write finishes inside `pagehide`; an IndexedDB transaction there can be dropped. Key: `calternal.composer-draft.v1:<user-id>:<date>`. Text never goes to the server before send. - Explicit discard uses an Undo toast, not ConfirmSheet: one tap, and the same undo pattern as the rest of Calendar. Gates (apps/web): - `bun run check`: `COMPLETED 1383 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS` - `bun run test`: `Test Files 36 passed (36)`, `Tests 252 passed (252)` - `bun run build`: `✓ built in 26.69s`, `✔ done` - `bun run test:e2e:calendar`: the new steps pass (6a at 1280x900, step 8 at 390x844, run with steps 6b-7 cut out). The full flow fails at step 6b "drag did not move the entry to 10:00". This also fails on `dev` 9ae55cf with no changes. It is a server data-corruption bug: see #94.
Author
Owner

Items 1 and 2 landed in 2339502784 on origin/dev: per-User/day draft persistence and commit-freeze snapshots. Items 3–5 remain unbuilt or need owner decisions under DESIGN §35. Leaving this issue open for those remaining items.

Items 1 and 2 landed in `2339502784` on origin/dev: per-User/day draft persistence and commit-freeze snapshots. Items 3–5 remain unbuilt or need owner decisions under DESIGN §35. Leaving this issue open for those remaining items.
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#93
No description provided.