diff --git a/docs/explanations/2026-07-11-explanation-per-keystroke-backup.html b/docs/explanations/2026-07-11-explanation-per-keystroke-backup.html new file mode 100644 index 0000000..291e072 --- /dev/null +++ b/docs/explanations/2026-07-11-explanation-per-keystroke-backup.html @@ -0,0 +1,410 @@ + + + + + +Making capture smooth: cutting per-keystroke O(N) work + + + +
+ +
+
todo · desktop performance
+

Making capture buttery: cutting the O(notes) work hiding behind every keystroke

+

The capture screen quietly did work proportional to your whole backlog on every character you typed. This change makes typing cost the same whether you have 5 notes or 500 — while the on-disk backup stays exactly as fresh.

+
+ + + +
+

Background

+

todo is an offline-first notes app. Its landing screen — the capture screen — is a big always-focused text box. The whole point is speed of capture: your idea is persisted on every keystroke, so nothing is ever lost, even to a crash.

+ +

Two durability layers matter here:

+ + +

The subtle part is how those two were wired together. To keep the backup current, the capture screen subscribed to watchNotes() and handed each emission to the backup:

+
_notesSub = repository.watchNotes().listen(_localBackup.scheduleExport);
+

That one line is the origin of the whole problem — read on.

+
+ +
+

Intuition

+

Here's the trap. watchNotes() re-emits on any write to the notes table. But every keystroke is a write (the autosave upsert). So the live stream fired on every character — and each firing did the full pipeline:

+ +
    +
  1. re-run SELECT * FROM notes (the whole table),
  2. +
  3. re-parse every row into a Note (two DateTime.parse calls each), and
  4. +
  5. hand the list to the backup, which immediately rebuilt the entire Markdown export string for all notes — then threw it away until the 2-second debounce fired.
  6. +
+ +

All of that is O(notes) work, on the UI thread, per character. With 5 notes it's invisible. With 500, every keystroke re-parses 500 rows and re-serializes a big string. That is the part that would get worse as your backlog grew.

+ +

Toy example — type "hello" (5 keys) with 300 notes:

+
+
Before
5 keys × (300 row-parses + 1 full export) = 1,500 parses + 5 exports, all discarded but one
+
After
5 cheap ticks, then 1 fetch + 1 export once you pause — regardless of note count
+
+ +

The fix: signal that something changed, don't ship the data

+

The backup doesn't need the notes on every keystroke — it only needs them once, when it's actually about to write the file. So we split "something changed" from "give me the data":

+ +
+
+
Before — live list on every write O(notes) / keystroke
+
+
keystroke
upsert
+
→
+
watchNotes()
re-query + parse
all rows
+
→
+
build full
Markdown
every time
+
→
+
debounced
disk write
+
+
+
+ +
+
+
After — cheap tick, pull once on write O(1) / keystroke
+
+
keystroke
upsert
+
→
+
changes tick
void (no data)
+
→
+
reset 2s
debounce timer
+
⏱
+
on fire:
listNotes()
+ export once
+
+
+
+ +

The tick is a payload-free Stream<void>: it just says "a write happened." The expensive query-parse-serialize now runs at most once per idle window, not per character.

+ +
+Why the backup is still complete. The old wiring caught all writes because it watched the table — including edits made on the note-detail screen and notes arriving from a device sync merge. The new tick preserves that: every write path (upsert, delete, merge, and importNotes which calls upsert) funnels through the repository and fires the tick. Nothing that changes data escapes it. +
+ +

Two smaller wins

+

The save indicator. Typing also fired setState() to update the tiny "Saved locally at 12:04" line — which rebuilt the entire screen (app bar, priority/status dropdowns, the editor) on every keystroke. Now that timestamp lives in a ValueNotifier wrapped by a ValueListenableBuilder, so a keystroke repaints only that one line of text.

+

List keys. Notes-list rows now carry key: ValueKey(note.id), so when the list reorders (a note bubbles to the top after an edit) Flutter matches rows by identity instead of by position.

+
+ +
+

Playground — feel the per-keystroke cost

+

Set a backlog size, then type in the box. Each keystroke, watch the Before and After tallies of main-thread work diverge. Stop typing for two seconds and the After side does its single export.

+ +
+
+ + +
+ +
+
+

Before watch on every write

+
Keystrokes0
+
Row-parses (O·n)0
+
Full exports built0
+
Whole-screen rebuilds0
+
+
+
+

After tick + pull on write

+
Keystrokes0
+
Row-parses (O·n)0
+
Full exports built0
+
Whole-screen rebuilds0
+
+
+
+

The bars show cumulative main-thread units of O(notes) work. Pause typing to trigger the After-side export.

+
+
+ +
+

Code

+

Grouped by concern, not by file. Four moves.

+ +

1 · A cheap change-tick in the repository

+

A broadcast Stream<void> that fires once after each write. O(1): no query, no parsing.

+
+ final StreamController<void> _changes = StreamController<void>.broadcast();
++ Stream<void> get changes => _changes.stream;
++ void _notifyChanged() { if (!_changes.isClosed) _changes.add(null); }
+
+  Future<void> upsert(Note note) async {
+    await _crdt.execute( /* INSERT ... ON CONFLICT ... */ );
++   _notifyChanged();
+  }
+  // delete() and merge() gain the same one-line tick;
+  // importNotes() is covered because it calls upsert().
+

close() now also closes the controller so a late write during teardown can't add to a dead stream.

+ +

2 · LocalBackup pulls notes on write, not on keystroke

+

The API changes from "here are the notes, schedule them" to "schedule a write; I'll fetch the notes myself when the timer fires."

+
  LocalBackup({
++   required this.fetch,          // Future<List<Note>> Function()
+    required this.reader, required this.writer,
+    this.debounce = const Duration(seconds: 2),
+  });
+
+- void scheduleExport(List<Note> notes) {
+-   final markdown = NotesMarkdown.export(notes);   // built EVERY call
+-   ... writer(markdown) ...
+- }
++ void schedule() {                    // just (re)arm the timer — O(1)
++   _timer?.cancel();
++   _timer = Timer(debounce, () => unawaited(_export()));
++ }
++ Future<void> _export() async {       // runs at most once per idle window
++   final notes = await fetch();       // one query, only now
++   await writer(NotesMarkdown.export(notes));
++ }
+ +

3 · Rewire capture: listen to the tick, not the list

+
- _notesSub = repository.watchNotes().listen(_localBackup.scheduleExport);
++ _changesSub = repository.changes.listen((_) => _localBackup.schedule());
++ _localBackup.schedule();   // one refresh on launch (the tick only fires on writes)
+

The production backup's fetch is wired to repository.listNotes. The launch schedule() replaces a side-effect the old watchNotes() gave for free: it emitted once on subscribe, keeping BACKLOG.md fresh even in a session with no edits.

+ +

4 · Scope the save indicator + key the tiles

+
- DateTime? _lastSavedAt;                       // a plain field...
+- if (mounted) setState(() => _lastSavedAt = now);   // ...rebuilt the whole screen
++ final ValueNotifier<DateTime?> _lastSavedAt = ValueNotifier(null);
++ if (mounted) _lastSavedAt.value = now;             // repaints one Text only
+
+  // in build():
++ ValueListenableBuilder<DateTime?>(valueListenable: _lastSavedAt, builder: ...)
+
+  // notes_list_screen.dart
++ _NoteTile(key: ValueKey(notes[i].id), ...)
+ +
+What deliberately did not change: the per-keystroke upsert itself. Persist-on-every-keystroke is a core promise of the app — we removed the amplification around each write, not the write. Measured on a 4K/144 Hz desktop, the UI-thread build time stays ≤ 3.3 ms; the app's 100% test coverage held across all changed files. +
+
+ +
+

Quiz

+

Five questions on the reasoning. Score: 0 / 0

+
+
+ + + +
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html new file mode 100644 index 0000000..3386b2e --- /dev/null +++ b/docs/explanations/index.html @@ -0,0 +1,43 @@ + + + + + +todo — change explanations + + + +
+
todo
+

Change explanations

+

1 explanation · interactive walkthroughs of notable changes, newest first.

+
+ + 2026-07-11 + Making capture smooth: cutting per-keystroke O(N) work + +
+ +
+ +