Summary
Replace the localStorage draft flow for notes with Google Docs/Notion-style autosave: whatever the user types is persisted to the server, debounced. No more client-side drafts.
This spec was settled through a grilling session; the decisions below are final.
Behavior
Lazy create
/notes/new touches no server until the first keystroke.
- On first edit:
POST /rest/notes, capture the returned id, and switch the view to edit semantics. All further edits go out as PATCH /rest/notes/{id}.
- A blank title is normalized server-side to
"Untitled"; a blank body is stored as ''.
Autosave
- Scope: title + body only. URL and tags remain Save-button-only (tags create entities, URL has format validation).
- Debounce: 2s trailing with a 10s max-wait cap while typing continuously.
- Flush immediately on editor blur, component unmount, and Save click.
- Saves are serialized client-side: one in-flight save max, monotonic sequence numbers, stale responses discarded, pending edits coalesced.
Concurrency safety (future-proofing for multiple tabs/users)
NoteRequest gains an optional lastKnownUpdate field (null = skip check, preserving contract compatibility).
- PATCH compares it to the row's
last_update; mismatch → 409 CONFLICT. No new column or migration: last_update is the concurrency token.
- On 409 the client silently GETs the latest note; if the user has pending edits it reapplies local title/body on top and retries with the fresh token, otherwise it adopts the server state.
- Known limitation (accepted, out of scope): two tabs both on
/notes/new typing simultaneously create two separate "Untitled" notes; lazy create has no shared id. Solving this needs cross-tab coordination (BroadcastChannel/localStorage lock) and is explicitly deferred.
Backend validation changes
- Relax the shared validator (
NoteService.isNoteRequestValid) for both POST and PATCH: blank title and blank description are allowed; size caps stay.
- Title null/blank → stored as
"Untitled". Description blank → stored as ''.
- No Flyway migration:
notes.description stays NOT NULL (empty string satisfies it). Title is already nullable.
- The hardcoded
archived = false in patchNote stays as-is (archived notes cannot be edited anyway).
Status label
- Tiny muted text directly under the Markdown editor (above the Save/Cancel row).
- States: hidden when pristine →
Saving… → persistent Saved HH:MM until the next edit. No spinner.
- Errors are never shown in the label; the failed save is retried on the next attempt.
Buttons
- Save (both modes, kept): flush pending autosave (await in-flight save first; never double-write), then:
- note exists under the hood →
PATCH
- user clicked New note and hit Save without typing →
POST of a blank Untitled note
- never both requests. Then navigate Home with
refreshHome as today.
- Cancel: shown only in create mode, hidden in edit mode.
- Create mode, pristine (no keystroke): navigate Home, touch nothing, no cache invalidation.
- Create mode, dirty: await any in-flight save / pending create, then
DELETE /rest/notes/{id}?force=true, navigate Home with refetch.
- Edit mode: no Cancel; autosaved edits simply persist (Notion-style). No snapshot/revert machinery.
Force delete
DELETE /rest/notes/{id} currently throws NoteNotArchivedException for non-archived notes (NoteService.deleteNote).
- Add optional query param
?force=true that skips the archived check (archived check remains the default; existing trash flow untouched). One endpoint, backward compatible.
Draft machinery removal
- Remove the localStorage draft system for notes in
NoteAdd (saveDraft, applyDraft, clearDraft, draft keys draft:note:new / draft:note:edit:{id}, the "Draft restored" banner, hasUserEdited). The server is now the draft.
Home cache
Test plan
- Backend unit: relaxed validation (blank title/description accepted on POST and PATCH), Untitled normalization, 409 on stale
lastKnownUpdate, ?force=true delete bypassing the archive check (and default still enforced).
- Backend integration (
*IntTest): patch conflict flow (stale token → 409 → refetch → retry succeeds); force-delete of a fresh note.
- Frontend: debounce/coalescing (2s trailing, 10s cap), lazy-create-on-first-keystroke and id capture, label state transitions, Save flush semantics (PATCH vs POST, never both), dirty-Cancel awaits save then force-deletes, pristine-Cancel leaves cache untouched, draft removal (no localStorage writes).
Notes
addNote() currently discards the POST response; it must return the created note so the id can be captured.
- Manual validation lives in
NoteService.isNoteRequestValid (no Bean Validation annotations on NoteRequest).
- Effective DB body limit is 2000 chars (old
chk_max_length check) while app validation allows 50000; out of scope here, but worth a separate look.
Summary
Replace the localStorage draft flow for notes with Google Docs/Notion-style autosave: whatever the user types is persisted to the server, debounced. No more client-side drafts.
This spec was settled through a grilling session; the decisions below are final.
Behavior
Lazy create
/notes/newtouches no server until the first keystroke.POST /rest/notes, capture the returned id, and switch the view to edit semantics. All further edits go out asPATCH /rest/notes/{id}."Untitled"; a blank body is stored as''.Autosave
Concurrency safety (future-proofing for multiple tabs/users)
NoteRequestgains an optionallastKnownUpdatefield (null = skip check, preserving contract compatibility).last_update; mismatch →409 CONFLICT. No new column or migration:last_updateis the concurrency token./notes/newtyping simultaneously create two separate "Untitled" notes; lazy create has no shared id. Solving this needs cross-tab coordination (BroadcastChannel/localStorage lock) and is explicitly deferred.Backend validation changes
NoteService.isNoteRequestValid) for both POST and PATCH: blank title and blank description are allowed; size caps stay."Untitled". Description blank → stored as''.notes.descriptionstaysNOT NULL(empty string satisfies it). Title is already nullable.archived = falseinpatchNotestays as-is (archived notes cannot be edited anyway).Status label
Saving…→ persistentSaved HH:MMuntil the next edit. No spinner.Buttons
PATCHPOSTof a blank Untitled noterefreshHomeas today.DELETE /rest/notes/{id}?force=true, navigate Home with refetch.Force delete
DELETE /rest/notes/{id}currently throwsNoteNotArchivedExceptionfor non-archived notes (NoteService.deleteNote).?force=truethat skips the archived check (archived check remains the default; existing trash flow untouched). One endpoint, backward compatible.Draft machinery removal
NoteAdd(saveDraft,applyDraft,clearDraft, draft keysdraft:note:new/draft:note:edit:{id}, the "Draft restored" banner,hasUserEdited). The server is now the draft.Home cache
Test plan
lastKnownUpdate,?force=truedelete bypassing the archive check (and default still enforced).*IntTest): patch conflict flow (stale token → 409 → refetch → retry succeeds); force-delete of a fresh note.Notes
addNote()currently discards the POST response; it must return the created note so the id can be captured.NoteService.isNoteRequestValid(no Bean Validation annotations onNoteRequest).chk_max_lengthcheck) while app validation allows 50000; out of scope here, but worth a separate look.