# Collaborative Editor

> Build a tiny event-sourced notes editor: project document state from an append-only event log, apply local edits optimistically, autosave with a debounce, and merge simulated remote collaborator events.

- Difficulty: advanced
- Tags: event sourcing, CQRS, optimistic UI, autosave, debounce, useEffect
- Estimated time: 90 minutes
- Tests: 11
- Requires subscription: yes

## Overview

Build a collaborative notes editor as a tiny event-sourced + CQRS system: every change is an immutable event appended to a log, the visible document is a pure projection of that log, and autosave debounces those changes into a single "save to server" call.

### How it works

- **Write** — editing, adding, or deleting a block appends a new `DocumentEvent` to the log. It is optimistic: the UI updates before anything is saved.
- **Read** — `projectDocument` rebuilds the visible `Block[]` from the log, applying two merge rules: a duplicated event `id` is ignored and a stale `version` loses.
- **Persist** — `saveDocument` simulates saving the projected document, but only after 500 ms of inactivity (debounce). A failed save shows a retry action.
- **Collaborate** — `remotePeer` injects a scripted collaborator's events, which merge through the same rules without marking the document dirty.

![Architecture: everything goes into the log, the log is projected, the projection is rendered and saved](/diagrams/collaborative-editor.png)

### Files

You only edit **`App.tsx`**. The rest are read-only and already wired up:

- **`App.tsx`** — the component you implement: hold the event log, project it, autosave it, and merge remote events.
- **`types.ts`** — `Block` and the `DocumentEvent` union (`block.insert` / `block.update` / `block.delete`).
- **`eventLog.ts`** — `appendEvent`, `hasEvent`, `createLocalEventId` (immutable, idempotent appends).
- **`projection.ts`** — pure `projectDocument(events)` → `Block[]`, the read model.
- **`api.ts`** — fake `saveDocument(blocks)` (300 ms, rejects when a block contains `#fail`).
- **`peer.ts`** — simulated collaborator (`connect`, `emitNext`, `isConnected`).
- **`data.ts`** — `initialEvents` (seed document) and `remoteEvents` (scripted peer activity).
- **`App.test.tsx`** — the 11 tests that define the required behavior.


## Requirements

- Implement the editor in `App.tsx`. The provided `types.ts`, `data.ts`, `eventLog.ts`, `projection.ts`, `api.ts`, and `peer.ts` are read-only, and the first render shows the document built from `initialEvents`.
- The editor renders one editable block per textarea in insertion order. Each textarea has `data-testid="block-input-<blockId>"` and an `aria-label`; the seed blocks (`intro`, `goals`, `notes`) appear in that order.
- Each block has a delete control `data-testid="delete-block-<blockId>"` that removes the block immediately.
- The `data-testid="add-block"` control appends a new empty block to the end of the document.
- Typing in a block updates the visible text immediately and sets `data-testid="save-status"` to `Dirty`.
- Several edits in quick succession result in a single save, not one save per keystroke.
- While a save is pending, `data-testid="save-status"` reads `Saving…`; after a successful save it reads `Saved`.
- When a save fails, `data-testid="save-status"` reads `Error`, the edited text stays visible, and `data-testid="retry-save"` appears.
- Clicking `retry-save` saves the latest document immediately, without waiting, and moves from `Saving…` to `Saved` when it succeeds.
- A pending autosave is cancelled on unmount, so leaving the editor does not start a save.
- A remote update applied to a block replaces that block's text with the remote text.
- A remote event that was already applied is ignored: it changes nothing and triggers no extra save.
- A remote event that is older than the block's current version is ignored.
- Remote edits never mark the document `Dirty` and never trigger an autosave.
- `data-testid="presence"` reads `Ana is editing` while the simulated peer subscription is active; on unmount the subscription is removed and the peer reports no listeners.


## Notes

- Treat the log as append-only and the textareas as a projection of it. Always return a new array from an append — never mutate `events` in place, or React won't re-render.
- A local edit appends an event with `author: "local"` and `version` equal to the block's current version plus one. The projection only applies strictly greater versions.
- Idempotency is keyed on the event `id`, not on its payload: a re-delivered event can carry a higher version and must still be dropped before it reaches the projection.
- Debounce the save: keep the timeout handle in a ref, reset it on every new edit, and clear it in the effect cleanup so an unmount never fires a pending save.
- `saveDocument` takes 300 ms and rejects when any block contains `#fail`. Keep the edited text on screen so `retry-save` can send the latest version.
- Remote events are already persisted by the peer — applying one should not flip the status to `Dirty` or start an autosave.
- Generate ids deterministically (`createLocalEventId()` plus a counter for new blocks). `crypto.randomUUID()` makes the tests and logs non-reproducible.
- React StrictMode mounts effects twice in development, so the subscription cleanup must remove only its own listener.
- Keep the log in a ref so two rapid keystrokes don't build on the same version. Reading `Date.now()` is impure — wrap the clock in a module-level helper to keep the purity lint happy.


## Tests

01. renders the three initial blocks in order
02. updates the text immediately and marks the status dirty
03. debounces a burst of edits into a single save
04. moves from saving to saved and persists the latest text
05. keeps the edited text on failure and lets retry save the latest version
06. removes a block immediately when it is deleted
07. adds a new empty block
08. applies a remote update with a higher version
09. ignores a remote event whose id was already applied
10. ignores a stale remote event with a lower version
11. shows the peer presence and unsubscribes on unmount


