Files
pocket-chords/docs/superpowers/specs/2026-04-08-search-nav-management-design.md

354 lines
11 KiB
Markdown

# 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<dyn SongSearchPort> → SqliteSongRepository
GET /songs → SongService → Box<dyn SongRepositoryPort> → 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<Vec<SongSummary>, 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<dyn SongSearchPort>,
}
impl SongSearchService {
pub fn new(search: Box<dyn SongSearchPort>) -> Self
pub async fn search(&self, query: &str) -> Result<Vec<SongSummary>, RepositoryError>
}
```
### `AppState` update (`crates/api/src/routes/tabs.rs`)
```rust
pub struct AppState {
pub fetcher: Box<dyn TabFetcherPort>,
pub parser: Box<dyn TabParserPort>,
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<String> }
pub async fn list_songs(
State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>,
) -> Result<Json<Vec<SongSummary>>, ...> {
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<SqliteSongRepository>` 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<SqliteSongRepository>` to implement them via blanket delegation. In practice: implement the traits on `Arc<SqliteSongRepository>` directly, or on `SqliteSongRepository` and add `#[async_trait] impl SongRepositoryPort for Arc<SqliteSongRepository> { ... }` 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<StoredSong, RepositoryError>;
async fn list(&self) -> Result<Vec<SongSummary>, RepositoryError>;
async fn get(&self, id: Uuid) -> Result<Option<Song>, 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<SongSummary, RepositoryError>;
}
```
### `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<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<UpdateSongRequest>,
) -> Result<Json<SongSummary>, (StatusCode, Json<ErrorResponse>)>
```
---
## 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 (
<div className="flex flex-col h-dvh">
<div className="flex-1 overflow-hidden">
<Outlet />
</div>
<BottomNav />
</div>
);
}
```
### `bottom-nav.tsx`
Single tab: Library icon + label, links to `/`, highlights when active (`useLocation`).
```tsx
<nav className="border-t bg-background">
<NavLink to="/" className={({ isActive }) => ...}>
<Music className="w-5 h-5" />
<span className="text-xs">Library</span>
</NavLink>
</nav>
```
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<SongSummary[]> {
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<SongSummary>
export async function deleteSong(id: string): Promise<void> // 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 && (
<div className="flex flex-col items-center gap-3 pt-12 text-center px-6">
<p className="text-sm text-muted-foreground">
Couldn't load your songs. Is the API running?
</p>
<Button variant="outline" size="sm" onClick={() => revalidator.revalidate()}>
Retry
</Button>
</div>
)}
```
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 && (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-muted-foreground">Song not found or unavailable.</p>
<Link to="/" className="text-sm text-primary"> Back to library</Link>
</div>
)}
```
### 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 `<Toaster />` 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 `<Outlet>` + `<BottomNav>`
- `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