14 KiB
Undo and Redo
Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo
button) plus POST /api/lines/restore shipped as an interim so delete
undo keeps the same id and number. Redo and the command journal are still
unbuilt.
Goal
Add reliable Undo and Redo for user-authored drawings without silently overwriting newer changes, changing drawing identity, or allowing REST and WebSocket ordering to corrupt the result.
The durable design is a server-authoritative drawing command journal with a frontend command stack. This is deliberately smaller than full event sourcing: current drawing state remains directly persisted, while a bounded journal keeps the before/after snapshots needed to reverse accepted user commands.
Scope
Undoable persistent operations:
- create and duplicate a trendline;
- move a whole trendline or either anchor;
- end a trendline at a cutoff;
- rename, recolor, resize, arm, or re-arm a drawing;
- create or edit a typed price level;
- create, edit, recolor, pin, float, move, or delete a comment;
- delete one drawing, a selection, or all drawings matching a filter.
Transient behavior:
- If a trendline has a pending first anchor, Undo cancels that anchor before it touches persistent history. This is not written to the command journal.
- Escape should cancel a pending anchor/tool/context menu without creating an undo entry.
Excluded:
- MA, VWAP, prior-day and other derived levels;
- chart viewport, timeframe and layer preferences;
- selection and expanded/collapsed sidebar sections;
- automatic alert disarming performed by the runtime;
- notifications that have already been sent.
Comment collapse/expand should initially remain outside command history. It is persisted display state, but including every toggle would make useful history hard to reach.
Why Frontend-Only Undo Is Insufficient
The frontend knows which gesture occurred, but the current CRUD endpoints do not provide enough guarantees to invert it safely:
- Recreating a deleted drawing through a create endpoint produces a new ID, number and creation time. IDs also affect cluster identity.
ManualLineStorehas no revisions, command IDs, transactions across multiple drawings, or history.- Drawing numbers are derived from the current maximum and can be reused after deleting the highest-numbered drawing.
- REST responses and WebSocket level deltas can arrive in either order.
- Comments are fetched separately and are not synchronized through WebSocket drawing deltas.
- Optimistic drag/delete failures currently have no general rollback path.
- A stale inverse from one tab could overwrite a newer edit from another tab.
- Filtered bulk deletion is a series of requests and can partially succeed.
Undo therefore needs one atomic backend command boundary. The frontend remains responsible for interaction, labels and optimistic rendering, but the server decides whether a command or inverse is still valid.
Semantics
- Undo reverses the most recent accepted command made by the current browser actor, not blindly the most recent global mutation.
- Redo reapplies the command that Undo reversed.
- Any new forward command clears that actor's redo stack.
- Undo and Redo preserve every persisted drawing field, including ID, drawing
number, creation time, geometry, cutoff, style, comment state and
armed. - A completed drag is one command, regardless of how many pointer-move frames were rendered.
- A multi-selection or filtered deletion is one atomic command and one history entry.
- Undo conflicts rather than overwriting a drawing changed by another command after the target command completed.
- Undo restores drawing state only. It cannot retract a browser/ntfy alert or reverse historical market evaluation.
- History is initially bounded and may be cleared by a server restart. Durable cross-deploy history belongs with the eventual SQLite persistence stage.
Suggested initial limits are 100 commands or 24 hours, whichever is reached first. Limits should be configuration, not part of command correctness.
Command Model
Each accepted mutation produces a command record:
DrawingCommand
command_id client-generated UUID used for idempotency
actor_id stable random ID for one browser profile
action create, patch, delete, bulk_delete, undo, redo
target_ids affected drawing IDs
expected target revisions supplied by the client
before complete ManualLine snapshots before mutation
after complete ManualLine snapshots after mutation
store_revision monotonically increasing drawing-store revision
created_at
undone_by inverse command ID or null
redone_by redo command ID or null
The complete snapshot is the persisted ManualLine state:
id, tf, side, anchor_t, anchor_p, slope, last_t, created_at,
note, hidden, color, line_width, number, cutoff_t, armed,
kind, pinned, x, y, collapsed, revision
The server must assign a monotonically increasing drawing number from persisted store metadata. It must not recalculate the next number from only the currently existing drawings.
Revisions and Conflicts
Each drawing receives a revision. A command that changes existing drawings
includes their expected revisions. The backend rejects the complete command
with 409 Conflict if any expected revision is stale.
Undo carries the revisions produced by the original command. If a later command changed one of those drawings, Undo is rejected rather than restoring a stale full snapshot. The UI should retain the failed command in history and explain that a newer change prevents undoing it.
Idempotency
Create and retry behavior must use command_id. Repeating an accepted command
returns its original receipt instead of creating a second drawing. This matters
when persistence succeeds but a response, rebuild, or network connection fails.
Backend Architecture
Introduce a DrawingService above ManualLineStore. All drawing create, patch,
delete, restore and batch operations go through it.
Responsibilities:
- Acquire the drawing-store lock.
- Reject a duplicate
command_idor return its existing receipt. - Validate expected revisions.
- Capture complete
beforesnapshots. - Build and validate the complete proposed
afterstate. - Apply all affected records atomically and save once.
- Append the command record and increment the store revision.
- Rebuild levels once when level-bearing drawings changed.
- Broadcast one drawing delta and one resulting cluster update.
- Return the same command receipt used by WebSocket reconciliation.
The JSON implementation can write drawing state, store metadata and the bounded journal together through the existing temporary-file-and-replace pattern. A later SQLite implementation should preserve the service and API contracts while moving state and journal writes into one database transaction.
Required Persistence Corrections
- Add explicit restore/upsert that preserves ID, number and creation time.
- Persist
next_number, drawing revisions and store revision. - Support atomic multi-record mutation and one save.
- Change PATCH processing from
exclude_none=Truetoexclude_unset=Trueso an inverse can explicitly restorecutoff_ttonull. - Validate complete resulting geometry, not only supplied PATCH fields.
- Keep comments excluded from levels, clustering and alert evaluation.
API Shape
Exact route names can follow the existing API style, but mutation responses need a common command receipt.
{
"command_id": "uuid",
"action": "move",
"store_revision": 42,
"changed": [{ "id": "ml_...", "revision": 8 }],
"removed": [],
"undo_label": "Move up 2"
}
Recommended operations:
POST /api/drawing-commands execute create/patch/delete/batch
POST /api/drawing-commands/{id}/undo atomically apply the inverse
POST /api/drawing-commands/{id}/redo atomically reapply the result
GET /api/drawing-commands?actor_id= recover bounded own history
Existing drawing routes can initially become adapters that call
DrawingService, but new frontend work should use the command endpoint so every
mutation has idempotency, revisions and a receipt.
WebSocket Synchronization
Replace line-only assumptions with a drawing delta that covers trendlines, price levels and comments:
{
"type": "drawings",
"seq": 42,
"command_id": "uuid",
"actor_id": "browser-id",
"changed": ["complete drawing DTOs"],
"removed": ["ml_..."]
}
The initial snapshot should include all drawings or the current drawing revision plus a required refetch. Every drawing delta has a monotonically increasing sequence. If a client detects a gap, it refetches a complete drawing snapshot instead of applying uncertain incremental state.
The frontend reconciles REST acknowledgement and WebSocket delivery by
command_id; receiving both must not apply a command twice. Drawing removal
also clears nonexistent IDs from single and multi-selection state.
Frontend Command Manager
Add one command manager in static/app.js rather than separate undo logic in
each control:
execute(command, optimisticProjection)
undo()
redo()
canUndo
canRedo
undoLabel
redoLabel
Execution flow:
- Capture the relevant current revisions and before state.
- Apply an optimistic projection when useful for interaction.
- Send a command with a UUID and actor ID.
- Commit it to the local Undo stack only after server acknowledgement.
- Roll back the optimistic projection on failure.
- Reconcile the matching WebSocket command receipt.
Commands should serialize per drawing. Completed drag operations should coalesce into one PATCH/command on pointer release; pointer movement remains local rendering only. Repeated style changes may be coalesced later, but are not required for the first release.
Controls and Shortcuts
Ctrl+ZandCmd+Z: Undo.Ctrl+Shift+ZandCmd+Shift+Z: Redo.Ctrl+Y: Redo on platforms where it is conventional.- Keep the existing input/editing guard so text fields retain native Undo.
- If a first trendline anchor is pending, Undo cancels it first.
- Call
preventDefault()only when the chart actually handled the shortcut. - Add visible Undo and Redo buttons with labels/tooltips such as
Undo move up 2; keyboard shortcuts cannot be the only mobile access. - Disable controls while an Undo/Redo request is pending.
Selection itself is not undoable. Undoing deletion may select the single restored drawing, but batch restoration should leave selection empty unless a clear product rule is chosen.
Failure Handling
- Network/server failure: restore the optimistic before state and show a visible error; do not add a history entry.
- Revision conflict: refetch drawings, preserve the failed history entry, and explain that a newer change prevents Undo.
- Missed WebSocket sequence: refetch the complete drawing snapshot.
- Partial bulk failure: impossible by contract; the whole command commits or none of it does.
- Rebuild failure after persistence: return the stored idempotent receipt on retry and recover derived levels from authoritative drawing state.
- Session expiration: authentication may retry, but the same
command_idmust be reused so a create cannot duplicate.
Rollout
Stage 1: Reliable Command Boundary
- Add
DrawingService, revisions, stable restore, monotonic numbering, idempotent command IDs and atomic batches. - Route all drawing mutations through it.
- Return common command receipts.
- Add in-memory/bounded server journal and frontend Undo/Redo stacks.
- Implement create, patch, move, delete and bulk-delete inverses.
- Add shortcuts and visible controls.
Stage 1 history may clear on restart, but every command during the process lifetime must be safe and conflict-aware.
Stage 2: Complete Multi-Client Synchronization
- Broadcast full drawing deltas, including comments.
- Add sequence-gap recovery and complete drawing snapshots.
- Reconcile REST and WebSocket acknowledgements by command ID.
- Clean stale selection state after remote mutations.
Stage 3: Durable History
- Move drawing state and the bounded command journal into SQLite.
- Preserve command/API contracts.
- Retain history by count/time policy across restart and deployment.
- Add audit-only, non-undoable records for automatic runtime changes if useful.
Tests That Earn Their Place
Backend:
- Undo delete restores every field with the same ID and drawing number.
- Undo end-here restores
cutoff_ttonull. - Bulk delete is all-or-nothing and saves/rebuilds/broadcasts once.
- Retrying one
command_idcannot create a second drawing. - Undo rejects a stale revision after another actor edits the drawing.
- Redo restores the exact accepted result; a new command clears redo.
- Comment commands synchronize but never enter levels or confluence.
- Automatic alert disarm does not enter user Undo history.
Browser:
- Create, Undo and Redo preserve the drawing ID.
- Whole-line and anchor drags Undo to exact prior geometry.
- Single and filtered batch deletion restore the complete set.
- Pending first anchor consumes Undo before persistent history.
- Undo inside a text field remains native text editing.
- A conflict displays an error and does not overwrite newer state.
- A second tab observes drawing and Undo deltas, including comments.
- Touch-accessible controls expose the same command labels and states.
Decisions to Confirm Before Implementation
- Whether bounded history should survive a normal server restart in Stage 1 or wait for SQLite in Stage 3.
- Whether persisted comment collapse/expand should remain excluded.
- Whether undoing one deleted drawing should select the restored drawing.
- The initial history count/time limits.
- Whether actor identity remains per browser profile or later becomes the authenticated user ID. The command model supports either.
The first implementation task is the atomic, revisioned DrawingService, not
the Undo button. Building the controls first would make deletion restoration,
bulk actions and cross-tab edits appear to work while retaining silent data-loss
races.