diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index d6ea4d9..3a70eef 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -110,7 +110,10 @@ Plans:
1. Adding or removing a single track from the queue uses incremental INSERT/DELETE via existing sqlc queries, not a full table rewrite
2. SetQueue Phase 2 (`resolveRemainingTracks`) skips file paths that were already resolved in Phase 1, eliminating redundant database lookups
3. Library store constructor no longer calls `eagerFetch()` — data loads lazily on first access via the existing `getTracks()`/`getAlbums()`/etc. getters, and the app starts without blocking on a full library load
-**Plans:** TBD
+**Plans:** 2 plans
+Plans:
+- [ ] 07-01-PLAN.md — Incremental queue persistence + SetQueue Phase 2 dedup
+- [ ] 07-02-PLAN.md — Library store deferred eager loading
### Phase 8: Frontend Performance & UX
**Goal:** The app feels smooth and visually consistent — large libraries render without jank, and the UI follows a coherent visual language
@@ -133,7 +136,7 @@ Plans:
| 4. Queue, Config & Player Tests | 2/2 | Complete | 2026-03-04 |
| 5. Database & Library Tests | 2/2 | Complete | 2026-03-04 |
| 6. SQL Consolidation & Code Quality | 2/3 | In Progress | — |
-| 7. Backend Performance | 0/? | Not started | — |
+| 7. Backend Performance | 0/2 | Not started | — |
| 8. Frontend Performance & UX | 0/? | Not started | — |
---
diff --git a/.planning/phases/07-backend-performance/07-01-PLAN.md b/.planning/phases/07-backend-performance/07-01-PLAN.md
new file mode 100644
index 0000000..b665c76
--- /dev/null
+++ b/.planning/phases/07-backend-performance/07-01-PLAN.md
@@ -0,0 +1,251 @@
+---
+phase: 07-backend-performance
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - backend/queue/persistence.go
+ - backend/queue/queue.go
+autonomous: true
+requirements:
+ - PERF-01
+ - PERF-02
+
+must_haves:
+ truths:
+ - "AddTrack persists a single INSERT + position shift instead of DELETE ALL + batch INSERT"
+ - "RemoveTrack persists a single DELETE + position shift instead of DELETE ALL + batch INSERT"
+ - "InsertNext/InsertNextTracks/InsertTracksAt persist incremental INSERTs + position shift instead of DELETE ALL + batch INSERT"
+ - "SetQueue Phase 2 skips file paths already resolved in Phase 1, avoiding redundant lookupTrackMetaBatch work"
+ - "Bulk operations (SetQueue, Clear, MoveQueueTracks) still use the full DELETE ALL + batch INSERT pattern"
+ - "All existing queue persistence roundtrip tests pass"
+ artifacts:
+ - path: "backend/queue/persistence.go"
+ provides: "Incremental persist helpers: persistAddTrack, persistAddTracks, persistRemoveTrack, persistRemoveTracks, persistInsertTracks"
+ contains: "func (q *Queue) persistAddTrack"
+ - path: "backend/queue/queue.go"
+ provides: "Updated AddTrack/RemoveTrack/InsertNext/InsertNextTracks/InsertTracksAt using incremental persistence; resolveRemainingTracks with exclusion set"
+ contains: "persistAddTrack"
+ key_links:
+ - from: "backend/queue/queue.go (AddTrack)"
+ to: "backend/queue/persistence.go (persistAddTrack)"
+ via: "direct method call replacing commitMutation"
+ pattern: "q\\.persistAddTrack"
+ - from: "backend/queue/queue.go (RemoveTrack)"
+ to: "backend/queue/persistence.go (persistRemoveTrack)"
+ via: "direct method call replacing commitMutation"
+ pattern: "q\\.persistRemoveTrack"
+ - from: "backend/queue/queue.go (resolveRemainingTracks)"
+ to: "backend/queue/queue.go (lookupTrackMetaBatch)"
+ via: "exclusion set filtering"
+ pattern: "exclude"
+---
+
+
+Optimize queue persistence for single-track and insert-at-position operations, and eliminate redundant database lookups in SetQueue Phase 2.
+
+Purpose: Single-track queue mutations (add, remove) currently rewrite the entire queue_tracks table (DELETE ALL + batch INSERT). This is O(n) where n is the queue length. For a 500-track queue, adding one track rewrites 501 rows. These operations should use incremental INSERT/DELETE with position shifts, making them O(1) for the actual mutation plus O(k) for position shifts (where k is the number of tracks after the mutation point). SetQueue Phase 2 currently re-resolves ALL file paths even though Phase 1 already resolved up to 50 of them — passing the Phase 1 results as an exclusion set eliminates redundant database work.
+
+Output: Modified persistence.go with incremental persist helpers, modified queue.go with updated mutation methods and Phase 2 dedup.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@backend/queue/persistence.go
+@backend/queue/queue.go
+@backend/queue/emit.go
+@backend/database/sql/queries/queue.sql
+@backend/database/sql/sqlcgen/queue.sql.go
+
+
+
+
+
+
+From backend/queue/queue.go:
+```go
+type Track struct {
+ ID int64 `json:"id"`
+ AudioFileID int64 `json:"audioFileId"`
+ FilePath string `json:"filePath"`
+ Position int64 `json:"position"`
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+}
+
+type trackMeta struct {
+ AudioFileID int64
+ FilePath string
+ Title string
+ Artist string
+}
+
+func (m trackMeta) toTrack(position int64) Track
+
+// commitMutation persists the current queue state after a mutation.
+// When reindex is true, track positions are renumbered first.
+// The caller must hold q.mu.
+func (q *Queue) commitMutation(reindex bool)
+
+// reindexPositions updates the Position field of all tracks to match slice index.
+func (q *Queue) reindexPositions()
+```
+
+From backend/database/sql/sqlcgen/queue.sql.go (existing sqlc queries available):
+```go
+func (q *Queries) InsertQueueTrack(ctx context.Context, arg InsertQueueTrackParams) (QueueTrack, error)
+func (q *Queries) RemoveQueueTrackByPosition(ctx context.Context, position int64) error
+func (q *Queries) ShiftQueuePositionsDown(ctx context.Context, position int64) error // position = position - 1 WHERE position > ?
+func (q *Queries) ShiftQueuePositionsUp(ctx context.Context, position int64) error // position = position + 1 WHERE position >= ?
+func (q *Queries) ClearQueueTracks(ctx context.Context) error
+```
+
+
+
+
+
+ Task 1: Add incremental persistence helpers and wire into mutation methods
+ backend/queue/persistence.go, backend/queue/queue.go
+
+**In `persistence.go`, add these incremental persistence methods (all assume caller holds q.mu):**
+
+1. `persistAddTrack(track Track)` — Inserts a single track at position `track.Position` using `InsertQueueTrack`. No position shifting needed because AddTrack always appends to the end.
+
+2. `persistAddTracks(tracks []Track)` — Inserts multiple tracks at consecutive positions at the end of the queue. Use the same `InsertQueueTrack` in a loop (these are appends, so no position shifting needed). Wrap in a transaction for atomicity (use `q.db.BeginTx()`, `q.db.Queries.WithTx(tx)`).
+
+3. `persistInsertTracks(tracks []Track, insertPos int)` — For insert-at-position operations. In a transaction: (a) Call `ShiftQueuePositionsUp` with `insertPos` to make room — but note `ShiftQueuePositionsUp` shifts by 1, so for N tracks, we need to shift by N. Since the sqlc query only shifts by 1, use a hand-crafted UPDATE: `UPDATE queue_tracks SET position = position + ? WHERE position >= ?` with args (len(tracks), insertPos). Add a `// SAFETY:` comment explaining why. (b) Insert each track using `InsertQueueTrack` with positions `insertPos`, `insertPos+1`, ..., `insertPos+N-1`.
+
+4. `persistRemoveTrack(position int)` — In a transaction: (a) Call `RemoveQueueTrackByPosition(position)`. (b) Call `ShiftQueuePositionsDown(position)` to close the gap.
+
+5. `persistRemoveTracks(positions []int)` — For multi-track removal. Since multiple position shifts interact, use the full `persistTracks()` rewrite for simplicity (the bulk path is acceptable for multi-remove — the user decision specified bulk operations keep the full rewrite). Just call `persistTracks()` directly.
+
+**In `queue.go`, update these methods to use incremental persistence instead of `commitMutation`:**
+
+1. `AddTrack` — Replace `q.commitMutation(false)` with: `q.persistAddTrack(track)` then `q.persistState()`. No reindex needed (appending at end, position is already correct).
+
+2. `AddTracks` — Replace `q.commitMutation(false)` with: `q.persistAddTracks(newTracks)` then `q.persistState()`. No reindex needed.
+
+3. `InsertNext` — Replace `q.commitMutation(true)` with: call `q.reindexPositions()` first, then `q.persistInsertTracks([]Track{track}, insertPos)` then `q.persistState()`. The reindex ensures in-memory positions are correct for subsequent operations.
+
+4. `InsertNextTracks` — Replace `q.commitMutation(true)` with: call `q.reindexPositions()` first, then `q.persistInsertTracks(newTracks, insertPos)` then `q.persistState()`.
+
+5. `InsertTracksAt` — Replace `q.commitMutation(true)` with: call `q.reindexPositions()` first, then `q.persistInsertTracks(newTracks, index)` then `q.persistState()`.
+
+6. `RemoveTrack` — Replace `q.commitMutation(true)` with: call `q.persistRemoveTrack(position)` then `q.reindexPositions()` then `q.persistState()`.
+
+7. `RemoveTracks` — Replace `q.commitMutation(true)` with: call `q.reindexPositions()` then `q.persistTracks()` (full rewrite, per user decision for bulk ops) then `q.persistState()`.
+
+**Keep `commitMutation` for**: `Clear`, `MoveQueueTracks`, `resolveRemainingTracks` — bulk operations that still do full rewrites per user decision.
+
+**For the hand-crafted SQL in `persistInsertTracks`:** Use `tx.ExecContext(q.db.Ctx, "UPDATE queue_tracks SET position = position + ? WHERE position >= ?", count, insertPos)` with a `// SAFETY: Multi-row position shift by variable N unsupported by sqlc (shift queries only shift by 1). Bind variables match args; no string interpolation.` comment.
+
+**Important:** Shuffle order regeneration was handled by `commitMutation`. For all the methods that previously called `commitMutation` with reindex=true, `generateShuffleOrder()` was also called if shuffleMode was active. Continue this behavior: after the incremental persist, check `q.shuffleMode` and call `q.generateShuffleOrder()` if true. For methods that called `commitMutation(false)` (AddTrack, AddTracks), shuffle order regeneration was also done if active — preserve this.
+
+**Verification approach:** Existing persistence roundtrip tests in `persistence_test.go` exercise `SaveState`/`RestoreState` which uses `persistTracks` (full rewrite). The incremental paths are verified by: (1) the existing queue_test.go tests that call AddTrack/RemoveTrack/InsertNext etc. with a real DB, and (2) adding a focused test.
+
+
+ cd backend && go build ./... && go test ./queue/... -race -count=1
+
+
+ - AddTrack/AddTracks use persistAddTrack/persistAddTracks (no full table rewrite)
+ - RemoveTrack uses persistRemoveTrack (single DELETE + position shift, no full table rewrite)
+ - InsertNext/InsertNextTracks/InsertTracksAt use persistInsertTracks (position shift + INSERT, no full table rewrite)
+ - RemoveTracks uses full persistTracks rewrite (acceptable for bulk operations)
+ - MoveQueueTracks, Clear, SetQueue still use commitMutation/persistTracks (unchanged bulk behavior)
+ - All existing tests pass with -race
+
+
+
+
+ Task 2: Eliminate redundant lookups in SetQueue Phase 2
+ backend/queue/queue.go
+
+**Modify `resolveRemainingTracks` to accept and use Phase 1's already-resolved metadata:**
+
+1. Change `resolveRemainingTracks` signature to accept the Phase 1 result map:
+ ```go
+ func (q *Queue) resolveRemainingTracks(
+ gen int64,
+ filePaths []string,
+ playingPath string,
+ phase1Meta map[string]trackMeta, // NEW: already-resolved from Phase 1
+ )
+ ```
+
+2. Inside `resolveRemainingTracks`, build the exclusion set from `phase1Meta` keys. Filter `filePaths` to get only the paths NOT in `phase1Meta` before calling `lookupTrackMetaBatch`:
+ ```go
+ // Exclude paths already resolved in Phase 1.
+ var unresolvedPaths []string
+ for _, fp := range filePaths {
+ if _, alreadyResolved := phase1Meta[fp]; !alreadyResolved {
+ unresolvedPaths = append(unresolvedPaths, fp)
+ }
+ }
+
+ // Only look up paths that Phase 1 didn't cover.
+ remainingMeta := q.lookupTrackMetaBatch(unresolvedPaths)
+
+ // Merge Phase 1 results into the lookup.
+ for k, v := range phase1Meta {
+ remainingMeta[k] = v
+ }
+ ```
+
+3. The rest of the method (building tracks from `allMeta`, finding `playingPath`, calling `commitMutation`) uses `remainingMeta` instead of `allMeta`. Rename the variable for clarity.
+
+4. **Update the call site in `SetQueue`:** Pass `batchMeta` (the Phase 1 result) to `resolveRemainingTracks`:
+ ```go
+ go q.resolveRemainingTracks(gen, filePaths, playingPath, batchMeta)
+ ```
+
+**Keep `initialBatchSize` at 50** — no changes to the Phase 1 window size (per user decision).
+
+**Result:** For a 1000-track SetQueue where Phase 1 resolves 50, Phase 2 now queries only 950 paths instead of all 1000. The 50 already-resolved paths are merged from the Phase 1 map.
+
+
+ cd backend && go build ./... && go test ./queue/... -race -count=1
+
+
+ - resolveRemainingTracks accepts phase1Meta parameter
+ - Phase 2 filters out already-resolved paths before calling lookupTrackMetaBatch
+ - Phase 1 results are merged into Phase 2 results
+ - SetQueue call site passes batchMeta to resolveRemainingTracks
+ - initialBatchSize remains at 50
+ - All existing tests pass with -race
+
+
+
+
+
+
+```bash
+# All queue tests pass with race detector
+cd backend && go test ./queue/... -race -count=1 -v
+
+# Build succeeds
+cd backend && go build ./...
+
+# Lint passes
+make lint
+```
+
+
+
+- Single-track add/remove uses incremental INSERT/DELETE (not full table rewrite)
+- Insert-at-position uses position shift + INSERT (not full table rewrite)
+- SetQueue Phase 2 only queries unreolved paths (not all paths)
+- All existing queue tests pass with -race
+- No linting errors
+
+
+
diff --git a/.planning/phases/07-backend-performance/07-02-PLAN.md b/.planning/phases/07-backend-performance/07-02-PLAN.md
new file mode 100644
index 0000000..eafad9a
--- /dev/null
+++ b/.planning/phases/07-backend-performance/07-02-PLAN.md
@@ -0,0 +1,181 @@
+---
+phase: 07-backend-performance
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - frontend/src/store/library-store.ts
+autonomous: true
+requirements:
+ - PERF-03
+
+must_haves:
+ truths:
+ - "LibraryStore constructor does NOT call eagerFetch() — app shell renders instantly"
+ - "After DOM is ready, eagerFetch() is called — all 4 data types (tracks, albums, artists, genres) are still loaded eagerly"
+ - "Views display loading state while data arrives (existing isTracksLoading/isAlbumsLoading/etc. flags)"
+ - "Post-scan invalidation still calls eagerFetch() to re-fetch everything"
+ - "First view switch after startup has data available (no empty views)"
+ artifacts:
+ - path: "frontend/src/store/library-store.ts"
+ provides: "Deferred eagerFetch — constructor omits data fetch, Wails DomReady event or document ready triggers it"
+ contains: "EventsOn"
+ key_links:
+ - from: "frontend/src/store/library-store.ts (constructor)"
+ to: "frontend/src/store/library-store.ts (eagerFetch)"
+ via: "Wails EventsOnce for dom-ready event OR document.readyState listener"
+ pattern: "eagerFetch"
+---
+
+
+Defer library data loading from constructor time to after DOM is ready, so the app shell renders instantly without blocking on backend data fetches.
+
+Purpose: Currently, `LibraryStore`'s constructor calls `eagerFetch()` which immediately fires 4 async Wails binding calls (`GetAllTracks`, `GetAllAlbums`, `GetAllArtists`, `GetAllGenresWithCounts`). Since the store singleton is instantiated during ES module evaluation (at import time), these 4 backend roundtrips begin before the DOM has even finished rendering, competing with the app shell paint. Moving `eagerFetch()` to after DOM ready means the app shell renders first, then data loads begin. The user still gets all 4 data types eagerly loaded — the change is WHEN, not WHETHER.
+
+Output: Modified library-store.ts with deferred eagerFetch trigger.
+
+
+
+@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
+@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@frontend/src/store/library-store.ts
+@frontend/index.ts
+
+
+
+
+
+From frontend/src/store/library-store.ts:
+```typescript
+class LibraryStore {
+ constructor() {
+ EventsOn(Events.LibraryScanComplete, () => {
+ this.invalidate();
+ });
+ this.loadCoverSize();
+ this.eagerFetch(); // <-- THIS LINE MUST BE REMOVED FROM CONSTRUCTOR
+ }
+
+ private eagerFetch(): void {
+ void this.getTracks();
+ void this.getAlbums();
+ void this.getArtists();
+ void this.getGenres();
+ }
+
+ private invalidate(): void {
+ this.tracks = null;
+ this.albums = null;
+ this.artists = null;
+ this.genres = null;
+ this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
+ this.notify();
+ this.eagerFetch(); // <-- THIS CALL IN invalidate() MUST REMAIN
+ }
+}
+```
+
+From frontend/index.ts:
+```typescript
+// At the bottom of index.ts, after all imports and setup:
+void Player.EmitCurrentState();
+void Queue.EmitCurrentState();
+// Library data fetching should happen around this point (after DOM is ready)
+```
+
+
+
+
+
+ Task 1: Defer eagerFetch from constructor to post-DOM-ready
+ frontend/src/store/library-store.ts
+
+**Modify the `LibraryStore` constructor to NOT call `eagerFetch()`:**
+
+1. Remove the `this.eagerFetch()` line from the constructor. The constructor should only do:
+ - Register the `LibraryScanComplete` event listener
+ - Call `this.loadCoverSize()`
+
+2. **Add a deferred fetch trigger.** The best mechanism for this Wails app is to check `document.readyState` and either call immediately or listen for the load event. Since the LibraryStore singleton is instantiated during module evaluation (import time), the DOM may or may not be ready:
+
+ ```typescript
+ constructor() {
+ EventsOn(Events.LibraryScanComplete, () => {
+ this.invalidate();
+ });
+
+ this.loadCoverSize();
+ this.deferEagerFetch();
+ }
+
+ private deferEagerFetch(): void {
+ if (document.readyState === 'complete') {
+ // DOM already ready (shouldn't happen during module eval, but safe)
+ this.eagerFetch();
+ } else {
+ // Wait for DOM to be ready, then fetch
+ window.addEventListener('load', () => {
+ this.eagerFetch();
+ }, { once: true });
+ }
+ }
+ ```
+
+ **Why `load` event and not `DOMContentLoaded`:** The `DOMContentLoaded` event fires when the HTML is parsed but before stylesheets, images, and subframes finish loading. The `load` event fires after everything is ready. Using `load` ensures the app shell has fully rendered (CSS applied, layout complete) before data fetches compete for resources. This is the mechanism that ensures the fastest visual shell render.
+
+ **Alternative (Claude's discretion):** If `load` causes a noticeable delay in data availability (because it waits for ALL resources), `DOMContentLoaded` is acceptable — it fires earlier and still defers past the initial module evaluation. Use judgment based on what feels right, but do NOT use `requestAnimationFrame` or `setTimeout` hacks.
+
+3. **Keep `eagerFetch()` call in `invalidate()` unchanged** — post-scan invalidation should still eagerly re-fetch everything immediately (the app is already running and rendered at that point).
+
+4. **Keep `eagerFetch()` method itself unchanged** — it should still call all 4 getters (`getTracks`, `getAlbums`, `getArtists`, `getGenres`).
+
+5. **Keep all `isTracksLoading()` / `isAlbumsLoading()` / etc. accessors unchanged** — views already use these for loading states. When the deferred fetch runs, these flags will be set to true and views will show loading state naturally.
+
+**What NOT to change:**
+- Do NOT make loading per-view or lazy-per-access — user explicitly wants ALL views pre-loaded
+- Do NOT change `invalidate()` behavior
+- Do NOT change the data access methods (`getTracks`, `getAlbums`, etc.)
+- Do NOT remove `eagerFetch` method — just defer WHEN it's first called
+
+
+ cd frontend && npx tsc --noEmit
+
+
+ - LibraryStore constructor no longer calls eagerFetch() directly
+ - eagerFetch() is deferred to after DOM ready (via load or DOMContentLoaded event)
+ - invalidate() still calls eagerFetch() immediately (for post-scan refresh)
+ - All 4 data types still loaded eagerly once triggered
+ - TypeScript compiles without errors
+
+
+
+
+
+
+```bash
+# TypeScript compiles
+cd frontend && npx tsc --noEmit
+
+# Frontend builds
+cd frontend && npx vite build
+```
+
+
+
+- LibraryStore constructor does NOT call eagerFetch()
+- eagerFetch() is triggered after DOM is ready
+- All 4 data types (tracks, albums, artists, genres) are still eagerly loaded once DOM is ready
+- Post-scan invalidation behavior is unchanged
+- TypeScript compiles and frontend builds
+
+
+