From 7cd0c4aae1faa3131936320939fda972d879e6ec Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 8 Apr 2026 03:28:36 +0200 Subject: [PATCH] docs: add search/nav/management/errors design spec --- ...2026-04-08-search-nav-management-design.md | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-search-nav-management-design.md diff --git a/docs/superpowers/specs/2026-04-08-search-nav-management-design.md b/docs/superpowers/specs/2026-04-08-search-nav-management-design.md new file mode 100644 index 0000000..89bbe66 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-search-nav-management-design.md @@ -0,0 +1,353 @@ +# Search, Nav, Management & Error States — Design Spec + +**Date:** 2026-04-08 +**Scope:** Backend search service, frontend nav shell, song management (edit + delete), error states. + +--- + +## Context + +PocketChords has working persistence and a functional chord viewer. This iteration makes the app feel polished and complete: server-side search, consistent navigation, song management, and proper error handling. + +--- + +## 1. Backend: Search Service + +### Architecture + +A dedicated `SongSearchService` with its own port — fully decoupled from `SongService`. Search and CRUD are independently swappable. + +``` +GET /songs?q=… → SongSearchService → Box → SqliteSongRepository +GET /songs → SongService → Box → SqliteSongRepository +``` + +`SqliteSongRepository` implements both ports — one struct, two trait impls. + +### New domain port (`crates/domain/src/ports.rs`) + +```rust +#[async_trait] +pub trait SongSearchPort: Send + Sync { + async fn search(&self, query: &str) -> Result, RepositoryError>; +} +``` + +### `SqliteSongRepository` search impl (`crates/infrastructure/persistence/src/lib.rs`) + +SQLite `LIKE` query on title and artist columns: +```sql +SELECT id, title, artist, original_key, preview_chords +FROM songs +WHERE title LIKE ? OR artist LIKE ? +ORDER BY created_at DESC +``` +Bind parameter: `format!("%{}%", query)` for both. + +### New `SongSearchService` (`crates/common/src/lib.rs`) + +```rust +pub struct SongSearchService { + search: Box, +} +impl SongSearchService { + pub fn new(search: Box) -> Self + pub async fn search(&self, query: &str) -> Result, RepositoryError> +} +``` + +### `AppState` update (`crates/api/src/routes/tabs.rs`) + +```rust +pub struct AppState { + pub fetcher: Box, + pub parser: Box, + pub songs: SongService, + pub search: SongSearchService, +} +``` + +### API endpoint update (`crates/api/src/routes/songs.rs`) + +`GET /songs` — branches on presence of `q` query param: +```rust +#[derive(Deserialize)] +pub struct ListQuery { pub q: Option } + +pub async fn list_songs( + State(state): State>, + Query(params): Query, +) -> Result>, ...> { + if let Some(q) = params.q.filter(|s| !s.is_empty()) { + state.search.search(&q).await ... + } else { + state.songs.list().await ... + } +} +``` + +### `main.rs` wiring + +Use a single `Arc` shared between both services — avoids two connection pools: + +```rust +use std::sync::Arc; +let repo = Arc::new(SqliteRepositoryFactory::create(&database_url).await?); +let songs = SongService::new(Box::new(Arc::clone(&repo))); +let search = SongSearchService::new(Box::new(Arc::clone(&repo))); +``` + +Requires `SqliteSongRepository` to implement both ports, and `Arc` to implement them via blanket delegation. In practice: implement the traits on `Arc` directly, or on `SqliteSongRepository` and add `#[async_trait] impl SongRepositoryPort for Arc { ... }` forwarding impls. + +--- + +## 2. Backend: Edit endpoint + +### New endpoint + +`PATCH /songs/:id` — updates mutable metadata fields only. + +**Request body:** +```json +{ "title": "New Title", "artist": "New Artist", "original_key": "Am" } +``` +All fields optional. Only provided fields are updated. + +**Response:** `200 OK` with updated `SongSummary`. + +### Domain port update + +```rust +pub trait SongRepositoryPort: Send + Sync { + async fn save(&self, song: &Song) -> Result; + async fn list(&self) -> Result, RepositoryError>; + async fn get(&self, id: Uuid) -> Result, RepositoryError>; + async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; + async fn update_meta(&self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>) -> Result; +} +``` + +### `SongService` gains `update_meta` + +Delegates to repo. Also updates `body` JSON so the full Song stays in sync: +```sql +UPDATE songs SET title = COALESCE(?, title), artist = COALESCE(?, artist), + original_key = COALESCE(?, original_key), body = ? +WHERE id = ? +``` +Deserializes `body`, patches `meta`, re-serializes, writes back. + +### New handler `update_song` in `songs.rs` + +```rust +pub async fn update_song( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> +``` + +--- + +## 3. Frontend: Layout & Nav + +### New files + +- `app/app/routes/layout.tsx` — parent route shell with bottom tab bar +- `app/app/components/bottom-nav.tsx` — single Library tab + +### Route config update (`app/app/routes.ts`) + +```ts +import { type RouteConfig, index, layout, route } from "@react-router/dev/routes"; + +export default [ + layout("routes/layout.tsx", [ + index("routes/home.tsx"), + route("songs/:id", "routes/songs.$id.tsx"), + ]), +] satisfies RouteConfig; +``` + +### `layout.tsx` + +```tsx +export default function Layout() { + return ( +
+
+ +
+ +
+ ); +} +``` + +### `bottom-nav.tsx` + +Single tab: Library icon + label, links to `/`, highlights when active (`useLocation`). + +```tsx + +``` + +Uses `NavLink` from react-router for active state styling. + +--- + +## 4. Frontend: Live Search + +### `home.tsx` changes + +- Remove `useState(query)` client-side filter +- Add `useSearchParams` hook — search term lives in URL (`?q=…`) +- Debounce input changes (300ms) before updating URL param +- Loader reads `q` from `request.url` and calls `listSongs(q)` or `listSongs()` + +```ts +// loader +export async function loader({ request }: Route.LoaderArgs) { + const q = new URL(request.url).searchParams.get("q") ?? ""; + const songs = await listSongs(q); + return { songs, q }; +} +``` + +```ts +// api.ts +export async function listSongs(q = ""): Promise { + const url = q ? `${API_BASE}/songs?q=${encodeURIComponent(q)}` : `${API_BASE}/songs`; + ... +} +``` + +Component uses `useNavigate` + `useSearchParams` + debounced `setSearchParams`. + +--- + +## 5. Frontend: Song Management + +### `TransposeBar` update + +Add `onEdit` and `onDelete` prop callbacks. Add `DropdownMenu` (shadcn) triggered by a `MoreHorizontal` icon button in the header row. + +Menu items: +- **Edit** → calls `onEdit()` +- **Delete** → calls `onDelete()` + +### New `EditSongSheet` component (`app/app/components/edit-song-sheet.tsx`) + +Bottom `Sheet` with three inputs: Title, Artist, Key. Pre-filled from current `SongMeta`. Submit calls `updateSong(id, { title, artist, original_key })` → updates `song.meta` in component state → closes sheet. + +### New `DeleteSongDialog` component (`app/app/components/delete-song-dialog.tsx`) + +`AlertDialog` (shadcn): "Are you sure? This cannot be undone." Confirm → `deleteSong(id)` → navigate to `/`. + +### `songs.$id.tsx` changes + +- Import `EditSongSheet`, `DeleteSongDialog` +- Track `editOpen`, `deleteOpen` state +- Pass `onEdit`/`onDelete` to `TransposeBar` + +### New `api.ts` helpers + +```ts +export async function updateSong(id: string, patch: { + title?: string; artist?: string; original_key?: string; +}): Promise + +export async function deleteSong(id: string): Promise // already exists +``` + +--- + +## 6. Frontend: Error States + +### Library (`home.tsx`) + +Loader catches API errors and returns `{ songs: [], error: true }`. Component shows inline error when `error` is true: + +```tsx +{loaderData.error && ( +
+

+ Couldn't load your songs. Is the API running? +

+ +
+)} +``` + +Uses `useRevalidator` from react-router for the retry. + +### Song detail (`songs.$id.tsx`) + +`getSong` returns `null` on 404 or throws on network error. Loader returns `{ song: null }` on any failure. Component shows: + +```tsx +{!song && ( +
+

Song not found or unavailable.

+ ← Back to library +
+)} +``` + +### Transient errors (toasts) + +`sonner` is already in the project. Import `toast` from `sonner`. Fire on: +- Add song failure: `toast.error("Failed to import song", { description: err.message })` +- Delete failure: `toast.error("Failed to delete song")` +- Edit failure: `toast.error("Failed to save changes")` + +Add `` to `layout.tsx` (one location, covers all pages). + +--- + +## New/Modified Files Summary + +**Rust:** +- `crates/domain/src/ports.rs` — add `SongSearchPort`, add `update_meta` to `SongRepositoryPort` +- `crates/domain/src/lib.rs` — re-export `SongSearchPort` +- `crates/infrastructure/persistence/src/lib.rs` — impl `SongSearchPort`, impl `update_meta` +- `crates/common/src/lib.rs` — add `SongSearchService`, add `SongService::update_meta` +- `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState` +- `crates/api/src/routes/songs.rs` — update `list_songs` for `?q=`, add `update_song` +- `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}` + +**Frontend:** +- `app/app/routes.ts` — add layout route +- `app/app/routes/layout.tsx` — new shell with `` + `` +- `app/app/components/bottom-nav.tsx` — new single-tab nav +- `app/app/routes/home.tsx` — URL-based search params, loader uses `q` +- `app/app/routes/songs.$id.tsx` — edit/delete integration, null error state +- `app/app/components/transpose-bar.tsx` — add DropdownMenu with Edit/Delete +- `app/app/components/edit-song-sheet.tsx` — new edit sheet +- `app/app/components/delete-song-dialog.tsx` — new confirm dialog +- `app/app/lib/api.ts` — update `listSongs(q?)`, add `updateSong` +- `app/app/lib/types.ts` — add `UpdateSongRequest` + +--- + +## Verification + +1. `cargo build --workspace` — clean +2. `cargo test --workspace` — all pass +3. `GET /songs?q=ocean` returns songs matching title/artist +4. `PATCH /songs/:id` with `{ "title": "New" }` updates title, leaves rest unchanged +5. Library search input debounces — network tab shows requests fire 300ms after typing stops +6. Song detail `⋯` menu shows Edit and Delete +7. Edit sheet pre-fills current values, saves successfully +8. Delete dialog navigates back to library on confirm +9. With API stopped: library shows "Couldn't load" inline + Retry; detail shows "← Back" +10. Failed add/delete fires a sonner toast +11. Bottom nav tab highlights on `/`, not highlighted on `/songs/:id` +12. `npm run typecheck` — clean