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

11 KiB

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)

#[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:

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)

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)

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:

#[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:

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:

{ "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

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:

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

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)

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

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).

<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.


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()
// 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 };
}
// 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

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:

{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:

{!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