Skip to content

Smart save (autosave) for notes #91

Description

@thermcampos

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions