diff --git a/app/.env.example b/app/.env.example index 5934e2e..e82c617 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1 +1 @@ -VITE_API_URL=http://localhost:8000 +VITE_API_URL=/api diff --git a/docs/superpowers/plans/2026-04-08-search-nav-management.md b/docs/superpowers/plans/2026-04-08-search-nav-management.md deleted file mode 100644 index 46f1acf..0000000 --- a/docs/superpowers/plans/2026-04-08-search-nav-management.md +++ /dev/null @@ -1,1122 +0,0 @@ -# Search, Nav, Management & Error States — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add server-side search (dedicated `SongSearchService`), bottom nav shell, song edit/delete management, and proper error states. - -**Architecture:** Backend — `SongSearchPort` + `SongSearchService` decouple search from CRUD; `update_meta` extends the repository port; `SqliteSongRepository` is made `Clone` so a single pool is shared. Frontend — a layout route wraps all pages with a bottom nav bar; home uses URL-based search with debounce; song detail gains a `⋯` menu; errors show inline or as toasts. - -**Tech Stack:** Rust/Axum, SQLx/SQLite, React Router 7, TailwindCSS, shadcn/ui (DropdownMenu, AlertDialog, Toaster from sonner). - ---- - -## File Map - -**New Rust:** -- `crates/domain/src/ports.rs` — add `SongSearchPort`, `update_meta` to `SongRepositoryPort` -- `crates/common/src/lib.rs` — add `SongSearchService`, `SongService::update_meta` -- `crates/infrastructure/persistence/src/lib.rs` — derive `Clone`, impl `SongSearchPort`, impl `update_meta` -- `crates/api/src/routes/songs.rs` — update `list_songs` with `?q=`, add `update_song` -- `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState` -- `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}` - -**New Frontend:** -- `app/app/routes/layout.tsx` — shell with `` + `` + `` -- `app/app/components/bottom-nav.tsx` — single Library tab -- `app/app/components/edit-song-sheet.tsx` — edit title/artist/key sheet -- `app/app/components/delete-song-dialog.tsx` — confirm delete AlertDialog - -**Modified Frontend:** -- `app/app/routes.ts` — wrap routes in layout -- `app/app/routes/home.tsx` — URL-based search, error state, revalidator -- `app/app/routes/songs.$id.tsx` — edit/delete integration, error fallback -- `app/app/components/transpose-bar.tsx` — add `onEdit`/`onDelete` + DropdownMenu -- `app/app/lib/api.ts` — `listSongs(q?)`, `updateSong` -- `app/app/lib/types.ts` — add `UpdateSongRequest` - ---- - -## Task 1: Backend search service - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/domain/src/lib.rs` -- Modify: `crates/common/src/lib.rs` -- Modify: `crates/infrastructure/persistence/src/lib.rs` -- Modify: `crates/api/src/routes/tabs.rs` -- Modify: `crates/api/src/routes/songs.rs` -- Modify: `crates/api/src/main.rs` - -- [ ] **Add `SongSearchPort` to `crates/domain/src/ports.rs`** (append after `SongRepositoryPort`): - -```rust -#[async_trait] -pub trait SongSearchPort: Send + Sync { - async fn search(&self, query: &str) -> Result, RepositoryError>; -} -``` - -- [ ] **Re-export `SongSearchPort` in `crates/domain/src/lib.rs`**: - -```rust -pub use ports::{FetchError, ParseError, RepositoryError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource}; -``` - -- [ ] **Add `SongSearchService` to `crates/common/src/lib.rs`** (append after `SongService`): - -```rust -use domain::SongSearchPort; - -pub struct SongSearchService { - search: Box, -} - -impl SongSearchService { - pub fn new(search: Box) -> Self { - Self { search } - } - - pub async fn search(&self, query: &str) -> Result, domain::RepositoryError> { - self.search.search(query).await - } -} -``` - -- [ ] **Derive `Clone` on `SqliteSongRepository` and implement `SongSearchPort`** in `crates/infrastructure/persistence/src/lib.rs`: - -Add `#[derive(Clone)]` to `SqliteSongRepository`: -```rust -#[derive(Clone)] -pub struct SqliteSongRepository { - pool: SqlitePool, -} -``` - -Append at the bottom of the file: -```rust -use domain::SongSearchPort; - -#[async_trait] -impl SongSearchPort for SqliteSongRepository { - async fn search(&self, query: &str) -> Result, RepositoryError> { - let pattern = format!("%{}%", query); - let rows = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ - WHERE title LIKE ? OR artist LIKE ? ORDER BY created_at DESC" - ) - .bind(&pattern) - .bind(&pattern) - .fetch_all(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - rows.into_iter() - .map(|row| { - let id = Uuid::parse_str(&row.id) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - Ok(SongSummary { - id, - meta: SongMeta { - title: row.title, - artist: row.artist, - original_key: row.original_key, - capo: None, - tuning: None, - tempo: None, - }, - preview_chords, - }) - }) - .collect() - } -} -``` - -- [ ] **Add `search: SongSearchService` to `AppState`** in `crates/api/src/routes/tabs.rs`: - -```rust -pub struct AppState { - pub fetcher: Box, - pub parser: Box, - pub songs: common::SongService, - pub search: common::SongSearchService, -} -``` - -- [ ] **Update `list_songs` to branch on `?q=`** in `crates/api/src/routes/songs.rs`: - -Add import at top: -```rust -use axum::extract::Query; -use serde::Deserialize; -``` - -Add struct before `list_songs`: -```rust -#[derive(Deserialize)] -pub struct ListQuery { - pub q: Option, -} -``` - -Replace `list_songs`: -```rust -pub async fn list_songs( - State(state): State>, - Query(params): Query, -) -> Result>, (StatusCode, Json)> { - let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { - state.search.search(&q).await - } else { - state.songs.list().await - }; - result - .map(Json) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))) -} -``` - -- [ ] **Wire `SongSearchService` in `crates/api/src/main.rs`**: - -```rust -mod routes; - -use axum::{Router, routing::{delete, get, patch, post}}; -use common::{SongSearchService, SongService}; -use persistence::SqliteRepositoryFactory; -use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; -use routes::tabs::{AppState, parse_tab}; -use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; -use ug_parser::{UgHtmlParser, UgTabFetcher}; - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt::init(); - - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "sqlite://./pocket-chords.db".into()); - let repo = SqliteRepositoryFactory::create(&database_url) - .await - .expect("failed to connect to database"); - let songs = SongService::new(Box::new(repo.clone())); - let search = SongSearchService::new(Box::new(repo)); - - let state = Arc::new(AppState { - fetcher: Box::new(UgTabFetcher::new()), - parser: Box::new(UgHtmlParser), - songs, - search, - }); - - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - let app = Router::new() - .route("/tabs/parse", post(parse_tab)) - .route("/songs", post(create_song).get(list_songs)) - .route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song)) - .layer(cors) - .with_state(state); - - let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); - tracing::info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, app).await.unwrap(); -} -``` - -- [ ] **Build and test** - -```bash -cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 -cargo test --workspace 2>&1 | tail -5 -``` - -Expected: clean build, all tests pass. - -- [ ] **Smoke test search endpoint** - -```bash -cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & -sleep 2 -curl -s "http://localhost:8000/songs?q=ocean" | head -c 200 -kill %1 -``` - -Expected: JSON array (may be empty if DB is fresh, non-empty if songs exist). - -- [ ] **Commit** - -```bash -cd /mnt/drive/dev/pocket-chords -git add crates/ -git commit -m "feat: add SongSearchService and GET /songs?q= search endpoint" -``` - ---- - -## Task 2: Backend edit endpoint - -**Files:** -- Modify: `crates/domain/src/ports.rs` -- Modify: `crates/common/src/lib.rs` -- Modify: `crates/infrastructure/persistence/src/lib.rs` -- Modify: `crates/api/src/routes/songs.rs` - -- [ ] **Add `update_meta` to `SongRepositoryPort`** in `crates/domain/src/ports.rs`: - -```rust -#[async_trait] -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; -} -``` - -- [ ] **Add `update_meta` to `SongService`** in `crates/common/src/lib.rs`: - -```rust -pub async fn update_meta( - &self, - id: Uuid, - title: Option<&str>, - artist: Option<&str>, - original_key: Option<&str>, -) -> Result { - self.repo.update_meta(id, title, artist, original_key).await -} -``` - -- [ ] **Implement `update_meta` on `SqliteSongRepository`** in `crates/infrastructure/persistence/src/lib.rs`: - -Add inside the `impl SongRepositoryPort for SqliteSongRepository` block: -```rust -async fn update_meta( - &self, - id: Uuid, - title: Option<&str>, - artist: Option<&str>, - original_key: Option<&str>, -) -> Result { - let id_str = id.to_string(); - - // Fetch current row - let row = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))? - .ok_or(RepositoryError::NotFound)?; - - // Patch the body JSON - let mut song: Song = serde_json::from_str(&row.body) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - if let Some(t) = title { song.meta.title = t.to_string(); } - if let Some(a) = artist { song.meta.artist = a.to_string(); } - if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } - let new_body = serde_json::to_string(&song) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - let new_title = title.unwrap_or(&row.title); - let new_artist = artist.unwrap_or(&row.artist); - let new_key = original_key.or(row.original_key.as_deref()); - - sqlx::query( - "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" - ) - .bind(new_title) - .bind(new_artist) - .bind(new_key) - .bind(&new_body) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - let preview_chords: Vec = serde_json::from_str(&row.preview_chords) - .map_err(|e| RepositoryError::Internal(e.to_string()))?; - - Ok(SongSummary { - id, - meta: song.meta, - preview_chords, - }) -} -``` - -- [ ] **Add `update_song` handler** in `crates/api/src/routes/songs.rs`: - -Add import: `use axum::extract::Path;` (already present). Add: -```rust -#[derive(serde::Deserialize)] -pub struct UpdateSongRequest { - pub title: Option, - pub artist: Option, - pub original_key: Option, -} - -pub async fn update_song( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let uuid = Uuid::parse_str(&id).map_err(|_| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) - })?; - - state.songs - .update_meta( - uuid, - body.title.as_deref(), - body.artist.as_deref(), - body.original_key.as_deref(), - ) - .await - .map(Json) - .map_err(|e| match e { - domain::RepositoryError::NotFound => - (StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })), - e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })), - }) -} -``` - -- [ ] **Build** - -```bash -cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 -``` - -Expected: clean. - -- [ ] **Commit** - -```bash -cd /mnt/drive/dev/pocket-chords -git add crates/ -git commit -m "feat: add update_meta to SongRepositoryPort and PATCH /songs/{id}" -``` - ---- - -## Task 3: Frontend layout shell and bottom nav - -**Files:** -- Modify: `app/app/routes.ts` -- Create: `app/app/routes/layout.tsx` -- Create: `app/app/components/bottom-nav.tsx` - -- [ ] **Check if DropdownMenu and AlertDialog are installed** - -```bash -ls /mnt/drive/dev/pocket-chords/app/app/components/ui/ | grep -E "dropdown|alert-dialog" -``` - -If missing, install: -```bash -cd /mnt/drive/dev/pocket-chords/app && npx shadcn add dropdown-menu alert-dialog 2>&1 -``` - -- [ ] **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; -``` - -- [ ] **Create `app/app/components/bottom-nav.tsx`** - -```tsx -import { NavLink } from "react-router"; -import { Music } from "lucide-react"; -import { cn } from "~/lib/utils"; - -export function BottomNav() { - return ( - - ); -} -``` - -- [ ] **Create `app/app/routes/layout.tsx`** - -```tsx -import { Outlet } from "react-router"; -import { Toaster } from "sonner"; -import { BottomNav } from "~/components/bottom-nav"; - -export default function Layout() { - return ( -
-
- -
- - -
- ); -} -``` - -- [ ] **Typecheck** - -```bash -cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 -``` - -- [ ] **Run typegen if needed** (if `+types/layout` errors): - -```bash -cd /mnt/drive/dev/pocket-chords/app && npx react-router typegen 2>&1 -``` - -- [ ] **Commit** - -```bash -cd /mnt/drive/dev/pocket-chords -git add app/app/routes.ts app/app/routes/layout.tsx app/app/components/bottom-nav.tsx -git commit -m "feat(app): add layout shell with bottom nav and Toaster" -``` - ---- - -## Task 4: Live server-side search - -**Files:** -- Modify: `app/app/lib/api.ts` -- Modify: `app/app/lib/types.ts` -- Modify: `app/app/routes/home.tsx` - -- [ ] **Update `listSongs` in `app/app/lib/api.ts`** to accept optional query: - -```ts -export async function listSongs(q = ""): Promise { - const url = q.trim() - ? `${getApiBase()}/songs?q=${encodeURIComponent(q.trim())}` - : `${getApiBase()}/songs`; - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`); - return res.json(); -} -``` - -- [ ] **Add `UpdateSongRequest` to `app/app/lib/types.ts`**: - -```ts -export interface UpdateSongRequest { - title?: string; - artist?: string; - original_key?: string; -} -``` - -- [ ] **Add `updateSong` to `app/app/lib/api.ts`**: - -```ts -export async function updateSong(id: string, patch: UpdateSongRequest): Promise { - const res = await fetch(`${getApiBase()}/songs/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`); - } - return res.json(); -} -``` - -Add `import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";` to the top of `api.ts`. - -- [ ] **Rewrite `app/app/routes/home.tsx`** - -```tsx -import { useCallback, useEffect, useRef, useState } from "react"; -import { useNavigate, useSearchParams, useRevalidator } from "react-router"; -import type { Route } from "./+types/home"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Card, CardContent } from "~/components/ui/card"; -import { Plus } from "lucide-react"; -import { SongCard } from "~/components/song-card"; -import { AddSongSheet } from "~/components/add-song-sheet"; -import { listSongs } from "~/lib/api"; -import type { SongSummary } from "~/lib/types"; - -export function meta({}: Route.MetaArgs) { - return [ - { title: "PocketChords" }, - { name: "description", content: "Your personal chord chart library" }, - ]; -} - -export async function loader({ request }: Route.LoaderArgs) { - const q = new URL(request.url).searchParams.get("q") ?? ""; - try { - const songs = await listSongs(q); - return { songs, q, error: false }; - } catch { - return { songs: [], q, error: true }; - } -} - -export default function Home({ loaderData }: Route.ComponentProps) { - const { songs, q: initialQ, error } = loaderData; - const [searchParams, setSearchParams] = useSearchParams(); - const [sheetOpen, setSheetOpen] = useState(false); - const [localSongs, setLocalSongs] = useState([]); - const revalidator = useRevalidator(); - - // Input value tracks immediately; URL updates are debounced - const [inputValue, setInputValue] = useState(initialQ); - const debounceRef = useRef | null>(null); - - const handleSearch = useCallback((value: string) => { - setInputValue(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - setSearchParams(value.trim() ? { q: value.trim() } : {}, { replace: true }); - }, 300); - }, [setSearchParams]); - - // Clear local songs when search changes (they may not match) - useEffect(() => { setLocalSongs([]); }, [initialQ]); - - const allSongs = [...songs, ...localSongs]; - - return ( -
- {/* Header */} -
-

PocketChords

- -
- - {/* Search */} -
- handleSearch(e.target.value)} - className="w-full" - /> -
- - {/* Error state */} - {error && ( -
-

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

- -
- )} - - {/* Grid */} -
- {!error && allSongs.length === 0 && ( -

- {initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."} -

- )} -
- {allSongs.map((song) => ( - - ))} - setSheetOpen(true)} - > - - - - -
-
- - setLocalSongs((prev) => [...prev, summary])} - /> -
- ); -} -``` - -- [ ] **Typecheck** - -```bash -cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 -``` - -- [ ] **Commit** - -```bash -cd /mnt/drive/dev/pocket-chords -git add app/app/lib/api.ts app/app/lib/types.ts app/app/routes/home.tsx -git commit -m "feat(app): live server-side search with 300ms debounce" -``` - ---- - -## Task 5: Song management — edit and delete - -**Files:** -- Create: `app/app/components/edit-song-sheet.tsx` -- Create: `app/app/components/delete-song-dialog.tsx` -- Modify: `app/app/components/transpose-bar.tsx` -- Modify: `app/app/routes/songs.$id.tsx` - -- [ ] **Create `app/app/components/edit-song-sheet.tsx`** - -```tsx -import { useState } from "react"; -import { - Sheet, SheetContent, SheetHeader, SheetTitle, -} from "~/components/ui/sheet"; -import { Input } from "~/components/ui/input"; -import { Button } from "~/components/ui/button"; -import { toast } from "sonner"; -import { updateSong } from "~/lib/api"; -import type { SongMeta, SongSummary } from "~/lib/types"; - -interface Props { - id: string; - meta: SongMeta; - open: boolean; - onOpenChange: (open: boolean) => void; - onUpdated: (summary: SongSummary) => void; -} - -export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props) { - const [title, setTitle] = useState(meta.title); - const [artist, setArtist] = useState(meta.artist); - const [key, setKey] = useState(meta.original_key ?? ""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - try { - const updated = await updateSong(id, { - title: title.trim() || undefined, - artist: artist.trim() || undefined, - original_key: key.trim() || undefined, - }); - onUpdated(updated); - onOpenChange(false); - } catch (err) { - toast.error("Failed to save changes", { - description: err instanceof Error ? err.message : undefined, - }); - } finally { - setLoading(false); - } - } - - return ( - - - - Edit Song - -
-
- - setTitle(e.target.value)} disabled={loading} /> -
-
- - setArtist(e.target.value)} disabled={loading} /> -
-
- - setKey(e.target.value)} - placeholder="e.g. Em, G, Bb" - disabled={loading} - /> -
-
- - -
-
-
-
- ); -} -``` - -- [ ] **Create `app/app/components/delete-song-dialog.tsx`** - -```tsx -import { - AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertDialogContent, AlertDialogDescription, AlertDialogFooter, - AlertDialogHeader, AlertDialogTitle, -} from "~/components/ui/alert-dialog"; -import { toast } from "sonner"; -import { deleteSong } from "~/lib/api"; -import { useNavigate } from "react-router"; -import { useState } from "react"; - -interface Props { - id: string; - title: string; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function DeleteSongDialog({ id, title, open, onOpenChange }: Props) { - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - - async function handleDelete() { - setLoading(true); - try { - await deleteSong(id); - navigate("/"); - } catch { - toast.error("Failed to delete song"); - setLoading(false); - onOpenChange(false); - } - } - - return ( - - - - Delete "{title}"? - - This cannot be undone. The song will be permanently removed. - - - - Cancel - - {loading ? "Deleting..." : "Delete"} - - - - - ); -} -``` - -- [ ] **Update `app/app/components/transpose-bar.tsx`** — add `onEdit`/`onDelete` props and DropdownMenu: - -Replace entire file: -```tsx -import { useState } from "react"; -import { Button } from "~/components/ui/button"; -import { - DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu"; -import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; -import type { SongMeta } from "~/lib/types"; - -interface Props { - meta: SongMeta; - offset: number; - onOffsetChange: (offset: number) => void; - onEdit?: () => void; - onDelete?: () => void; -} - -export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: Props) { - const [expanded, setExpanded] = useState(true); - - const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; - - const menuButton = (onEdit || onDelete) ? ( - - - - - - {onEdit && ( - - - Edit - - )} - {onDelete && ( - - - Delete - - )} - - - ) : null; - - if (!expanded) { - return ( -
- {meta.title} -
- {menuButton} - -
-
- ); - } - - return ( -
-
-
- {meta.title} - {meta.artist} -
-
- {menuButton} - -
-
- -
-
- {meta.original_key && Key: {meta.original_key}} - {meta.capo != null && Capo: {meta.capo}} - {meta.tuning && {meta.tuning}} -
-
- - {label} - -
-
-
- ); -} -``` - -- [ ] **Update `app/app/routes/songs.$id.tsx`** - -```tsx -import { useState } from "react"; -import { data, Link } from "react-router"; -import type { Route } from "./+types/songs.$id"; -import { TransposeBar } from "~/components/transpose-bar"; -import { ChordChart } from "~/components/chord-chart"; -import { EditSongSheet } from "~/components/edit-song-sheet"; -import { DeleteSongDialog } from "~/components/delete-song-dialog"; -import { transposeSong } from "~/lib/transpose"; -import { getSong } from "~/lib/api"; -import type { Song, SongSummary } from "~/lib/types"; - -export function meta({ data }: Route.MetaArgs) { - if (!data?.song) return [{ title: "PocketChords" }]; - return [ - { title: `${data.song.meta.title} — PocketChords` }, - { name: "description", content: data.song.meta.artist }, - ]; -} - -export async function loader({ params }: Route.LoaderArgs) { - const id = params.id ?? ""; - try { - const song = await getSong(id); - if (!song) throw data("Song not found", { status: 404 }); - return { song, id }; - } catch (err: any) { - if (err?.status === 404) throw err; - return { song: null as unknown as Song, id }; - } -} - -export default function SongDetail({ loaderData }: Route.ComponentProps) { - const { song: initialSong, id } = loaderData; - const [song, setSong] = useState(initialSong ?? null); - const [offset, setOffset] = useState(0); - const [editOpen, setEditOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - - if (!song) { - return ( -
-

Song not found or unavailable.

- - ← Back to library - -
- ); - } - - const displayed = transposeSong(song, offset); - - function handleUpdated(summary: SongSummary) { - setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); - } - - return ( -
- setEditOpen(true)} - onDelete={() => setDeleteOpen(true)} - /> -
- -
- - -
- ); -} -``` - -- [ ] **Typecheck** - -```bash -cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 -``` - -- [ ] **Commit** - -```bash -cd /mnt/drive/dev/pocket-chords -git add app/app/components/ app/app/routes/songs.\$id.tsx -git commit -m "feat(app): add song edit and delete with dropdown menu" -``` - ---- - -## Task 6: Verification - -- [ ] **Start API** - -```bash -cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & -sleep 2 -``` - -- [ ] **Start frontend** - -```bash -cd /mnt/drive/dev/pocket-chords/app && npm run dev & -sleep 3 -``` - -- [ ] **Verify search** - -Open `http://localhost:5173/`. Type in search bar — requests should fire 300ms after typing stops (check browser network tab). Empty query returns full list. - -- [ ] **Verify nav** - -Bottom nav shows Library tab. Active on `/`, inactive on song detail. Tapping it from a song detail navigates to `/`. - -- [ ] **Verify edit** - -Open a song → tap `⋯` → Edit → change title → Save. Header updates immediately. Refresh — change persists. - -- [ ] **Verify delete** - -Open a song → tap `⋯` → Delete → confirm → navigates to library, song gone. - -- [ ] **Verify error state** - -Stop the API (`kill %1`). Reload library page — "Couldn't load your songs" inline with Retry button. Try navigating to a song URL directly — "Song not found or unavailable" with back link. - -- [ ] **Final checks** - -```bash -cd /mnt/drive/dev/pocket-chords && cargo test --workspace 2>&1 | tail -5 -cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 -``` - -Both clean. - -- [ ] **Stop dev servers** - -```bash -kill %1 %2 2>/dev/null; true -``` diff --git a/docs/superpowers/plans/2026-04-09-chord-diagram.md b/docs/superpowers/plans/2026-04-09-chord-diagram.md deleted file mode 100644 index 630fe90..0000000 --- a/docs/superpowers/plans/2026-04-09-chord-diagram.md +++ /dev/null @@ -1,1107 +0,0 @@ -# Chord Diagram Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add piano and guitar chord diagrams to the song detail page — shown in a side column on desktop and a scrollable bottom grid + inline tap popup on mobile. - -**Architecture:** Three layers: `tonal` parses chord names into note sets → `chord-voicing.ts` maps notes/quality to renderable positions → dumb React components render the positions. Guitar uses moveable barre-chord templates per quality type, transposed by shifting the baseFret. Piano highlights the matching keys in a fixed C-to-B octave. - -**Tech Stack:** React, TypeScript, Tailwind v4, `tonal` (music theory), `vitest` (unit tests) - ---- - -## File Map - -| File | Action | Purpose | -|------|--------|---------| -| `app/package.json` | Modify | Add `tonal` dep, `vitest` devDep, `test` script | -| `app/vitest.config.ts` | Create | Vitest config with `~` alias | -| `app/app/lib/guitar-voicings.ts` | Create | ~12 barre-chord quality templates (pure data) | -| `app/app/lib/chord-voicing.ts` | Create | `getPianoNotes` + `getGuitarVoicing` using tonal | -| `app/app/lib/song-utils.ts` | Modify | Add `extractUniqueChords` | -| `app/app/components/chord-diagram/piano-keys.tsx` | Create | Dumb piano keyboard renderer | -| `app/app/components/chord-diagram/guitar-fretboard.tsx` | Create | Dumb guitar fretboard renderer | -| `app/app/components/chord-diagram/chord-diagram.tsx` | Create | Entry point: chord+instrument → voicing → renderer | -| `app/app/components/chord-diagram/chord-grid.tsx` | Create | Wrapped grid of ChordDiagram cards | -| `app/app/components/chord-chart.tsx` | Modify | Make chord names tappable via `onChordClick` | -| `app/app/routes/songs.$id.tsx` | Modify | Add uniqueChords, instrument state, inline popup, two-column layout | - ---- - -## Task 1: Install tonal + set up vitest - -**Files:** -- Modify: `app/package.json` -- Create: `app/vitest.config.ts` - -- [ ] **Step 1: Install dependencies** - -```bash -cd app && npm install tonal && npm install -D vitest -``` - -Expected: `tonal` added to dependencies, `vitest` to devDependencies in `package.json`. - -- [ ] **Step 2: Add test script to package.json** - -In `app/package.json`, add to `"scripts"`: -```json -"test": "vitest run" -``` - -- [ ] **Step 3: Create vitest config** - -Create `app/vitest.config.ts`: -```ts -import { defineConfig } from 'vitest/config'; -import { resolve } from 'path'; - -export default defineConfig({ - test: { - environment: 'node', - }, - resolve: { - alias: { - '~': resolve(__dirname, './app'), - }, - }, -}); -``` - -- [ ] **Step 4: Verify vitest runs** - -```bash -cd app && npm test -``` - -Expected: `No test files found, exiting with code 0` (no tests yet — that's fine). - -- [ ] **Step 5: Commit** - -```bash -git add app/package.json app/package-lock.json app/vitest.config.ts -git commit -m "chore: add tonal + vitest" -``` - ---- - -## Task 2: Guitar voicing data - -**Files:** -- Create: `app/app/lib/guitar-voicings.ts` - -Templates use **0-based relative fret positions** where `0` = the root fret (open position for E/A root). `null` = muted string. `rootString` determines which open string carries the root for transposition. - -- [ ] **Step 1: Create guitar-voicings.ts** - -Create `app/app/lib/guitar-voicings.ts`: - -```ts -export interface GuitarVoicingTemplate { - /** 6 strings low→high; 0 = root position, 1 = one fret above root, null = muted */ - frets: (number | null)[]; - /** Fret (0-based relative) where a full barre is drawn, or null */ - barre: number | null; - /** Which open string carries the root — determines transposition offset */ - rootString: 'E' | 'A'; -} - -/** - * Moveable barre-chord templates keyed by tonal chord type name. - * Verified fingerings at root = E (E-shape) or root = A (A-shape). - * To add a new quality: look up `Chord.get('').type` in tonal, - * then define the fingering at root E or A and add it here. - */ -export const GUITAR_VOICINGS: Record = { - // ── E-shape (root on 6th string) ────────────────────────────────────── - // E major open: [0,2,2,1,0,0] E B E G# B E - 'major': { - frets: [0, 2, 2, 1, 0, 0], - barre: null, - rootString: 'E', - }, - // E7: [0,2,0,1,0,0] E B D G# B E - 'dominant seventh': { - frets: [0, 2, 0, 1, 0, 0], - barre: null, - rootString: 'E', - }, - // Emaj7: [0,2,1,1,0,0] E B D# G# B E - 'major seventh': { - frets: [0, 2, 1, 1, 0, 0], - barre: null, - rootString: 'E', - }, - // Eaug: [0,3,2,1,1,0] E C(=B#) E G# C E - 'augmented': { - frets: [0, 3, 2, 1, 1, 0], - barre: null, - rootString: 'E', - }, - // Esus4: [0,2,2,2,0,0] E B E A B E - 'suspended fourth': { - frets: [0, 2, 2, 2, 0, 0], - barre: null, - rootString: 'E', - }, - - // ── A-shape (root on 5th string) ────────────────────────────────────── - // Am open: [x,0,2,2,1,0] A E A C E - // barre: 0 so that transposed versions (Bm, Cm, etc.) draw the barre bar; - // the renderer suppresses the barre when baseFret===0 (open position = no barre needed) - 'minor': { - frets: [null, 0, 2, 2, 1, 0], - barre: 0, - rootString: 'A', - }, - // Am7: [x,0,2,0,1,0] A E G C E - 'minor seventh': { - frets: [null, 0, 2, 0, 1, 0], - barre: null, - rootString: 'A', - }, - // AmMaj7: [x,0,2,1,1,0] A E G# C E - 'minor major seventh': { - frets: [null, 0, 2, 1, 1, 0], - barre: null, - rootString: 'A', - }, - // Adim: [x,0,1,2,1,x] A Eb A C (string 1 muted) - 'diminished': { - frets: [null, 0, 1, 2, 1, null], - barre: null, - rootString: 'A', - }, - // Am7b5 (half-dim): [x,0,1,0,1,x] A Eb G C - 'half-diminished': { - frets: [null, 0, 1, 0, 1, null], - barre: null, - rootString: 'A', - }, - // Asus4: [x,0,2,2,3,0] A E A D E - 'suspended second': { - frets: [null, 0, 2, 2, 0, 0], - barre: null, - rootString: 'A', - }, -}; -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/app/lib/guitar-voicings.ts -git commit -m "feat: guitar voicing templates" -``` - ---- - -## Task 3: Theory layer + tests - -**Files:** -- Create: `app/app/lib/chord-voicing.ts` -- Create: `app/app/lib/chord-voicing.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `app/app/lib/chord-voicing.test.ts`: - -```ts -import { describe, it, expect } from 'vitest'; -import { getPianoNotes, getGuitarVoicing } from './chord-voicing'; - -describe('getPianoNotes', () => { - it('returns note names for a major chord', () => { - expect(getPianoNotes('C')).toEqual(['C', 'E', 'G']); - }); - - it('returns note names for Cmaj7', () => { - expect(getPianoNotes('Cmaj7')).toEqual(['C', 'E', 'G', 'B']); - }); - - it('returns note names for Am', () => { - expect(getPianoNotes('Am')).toEqual(['A', 'C', 'E']); - }); - - it('returns [] for unparseable chord', () => { - expect(getPianoNotes('???')).toEqual([]); - }); - - it('returns [] for empty string', () => { - expect(getPianoNotes('')).toEqual([]); - }); -}); - -describe('getGuitarVoicing', () => { - it('returns voicing for E major (open position, baseFret=0)', () => { - const v = getGuitarVoicing('E'); - expect(v).not.toBeNull(); - expect(v!.baseFret).toBe(0); - expect(v!.frets).toEqual([0, 2, 2, 1, 0, 0]); - }); - - it('returns voicing for Am (open position, baseFret=0)', () => { - const v = getGuitarVoicing('Am'); - expect(v).not.toBeNull(); - expect(v!.baseFret).toBe(0); - expect(v!.frets).toEqual([null, 0, 2, 2, 1, 0]); - }); - - it('transposes Bm correctly (A-shape, shift=2)', () => { - const v = getGuitarVoicing('Bm'); - expect(v).not.toBeNull(); - // Am shifted up 2: [null,2,4,4,3,2], baseFret=2 - expect(v!.baseFret).toBe(2); - expect(v!.frets).toEqual([null, 2, 4, 4, 3, 2]); - }); - - it('transposes G major correctly (E-shape, shift=3)', () => { - const v = getGuitarVoicing('G'); - expect(v).not.toBeNull(); - // E major shifted up 3: [3,5,5,4,3,3], baseFret=3 - expect(v!.baseFret).toBe(3); - expect(v!.frets).toEqual([3, 5, 5, 4, 3, 3]); - }); - - it('returns null for unknown quality', () => { - // 'add9' is not in the voicing map - expect(getGuitarVoicing('Cadd9')).toBeNull(); - }); - - it('returns null for unparseable chord', () => { - expect(getGuitarVoicing('???')).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd app && npm test -``` - -Expected: errors like `Cannot find module './chord-voicing'`. - -- [ ] **Step 3: Implement chord-voicing.ts** - -Create `app/app/lib/chord-voicing.ts`: - -```ts -import { Chord, Note } from 'tonal'; -import { GUITAR_VOICINGS } from './guitar-voicings'; - -export interface GuitarVoicing { - /** Absolute fret numbers per string (low→high); null = muted, 0 = open */ - frets: (number | null)[]; - /** Lowest fret displayed on the diagram (0 = show nut) */ - baseFret: number; - /** Absolute fret to draw a barre bar across, or null */ - barre: number | null; -} - -const ROOT_STRING_CHROMA: Record<'E' | 'A', number> = { - E: Note.chroma('E')!, // 4 - A: Note.chroma('A')!, // 9 -}; - -/** - * Returns the note names (e.g. ["C","E","G"]) for a chord string. - * Returns [] if the chord cannot be parsed. - */ -export function getPianoNotes(chord: string): string[] { - if (!chord) return []; - const parsed = Chord.get(chord); - if (!parsed.tonic || parsed.empty) return []; - return parsed.notes; -} - -/** - * Returns a transposed GuitarVoicing for a chord string, or null if the - * chord quality has no template or the chord cannot be parsed. - */ -export function getGuitarVoicing(chord: string): GuitarVoicing | null { - if (!chord) return null; - const parsed = Chord.get(chord); - if (!parsed.tonic || parsed.empty) return null; - - const template = GUITAR_VOICINGS[parsed.type]; - if (!template) return null; - - const rootChroma = ROOT_STRING_CHROMA[template.rootString]; - const tonicChroma = Note.chroma(parsed.tonic); - if (tonicChroma === undefined) return null; - - const shift = (tonicChroma - rootChroma + 12) % 12; - - return { - frets: template.frets.map((f) => (f === null ? null : f + shift)), - baseFret: shift, - barre: template.barre === null ? null : template.barre + shift, - }; -} -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -cd app && npm test -``` - -Expected: all 11 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add app/app/lib/chord-voicing.ts app/app/lib/chord-voicing.test.ts -git commit -m "feat: chord voicing theory layer" -``` - ---- - -## Task 4: PianoKeys component - -**Files:** -- Create: `app/app/components/chord-diagram/piano-keys.tsx` - -- [ ] **Step 1: Create the component** - -Create `app/app/components/chord-diagram/piano-keys.tsx`: - -```tsx -/** Chroma value (0=C … 11=B) for every note name tonal might return */ -const NOTE_CHROMA: Record = { - 'C': 0, 'C#': 1, 'Db': 1, - 'D': 2, 'D#': 3, 'Eb': 3, - 'E': 4, 'Fb': 4, - 'F': 5, 'E#': 5, 'F#': 6, 'Gb': 6, - 'G': 7, 'G#': 8, 'Ab': 8, - 'A': 9, 'A#': 10, 'Bb': 10, - 'B': 11, 'Cb': 11, 'B#': 0, -}; - -const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; -const WHITE_KEY_W = 14; // px -const WHITE_KEY_H = 56; // px -const BLACK_KEY_W = 9; // px -const BLACK_KEY_H = 34; // px - -/** Left offset (px) of each black key from the left edge of the keyboard */ -const BLACK_KEY_LEFT: Record = { - 'C#': 1 * WHITE_KEY_W - BLACK_KEY_W / 2, - 'D#': 2 * WHITE_KEY_W - BLACK_KEY_W / 2, - 'F#': 4 * WHITE_KEY_W - BLACK_KEY_W / 2, - 'G#': 5 * WHITE_KEY_W - BLACK_KEY_W / 2, - 'A#': 6 * WHITE_KEY_W - BLACK_KEY_W / 2, -}; - -const BLACK_KEY_NAMES = Object.keys(BLACK_KEY_LEFT); - -interface Props { - /** Note names from tonal, e.g. ["C","E","G"] or ["Ab","C","Eb"] */ - notes: string[]; -} - -export function PianoKeys({ notes }: Props) { - const activeChroma = new Set( - notes.map((n) => NOTE_CHROMA[n]).filter((c) => c !== undefined) - ); - const totalWidth = WHITE_KEY_W * 7; - - if (notes.length === 0) { - return ( -
- ? -
- ); - } - - return ( -
- {/* White keys */} - {WHITE_KEYS.map((note, i) => { - const active = activeChroma.has(NOTE_CHROMA[note]); - return ( -
- {active && ( -
- )} -
- ); - })} - - {/* Black keys (rendered on top) */} - {BLACK_KEY_NAMES.map((note) => { - const chroma = NOTE_CHROMA[note]; - const active = activeChroma.has(chroma); - return ( -
- {active && ( -
- )} -
- ); - })} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/app/components/chord-diagram/piano-keys.tsx -git commit -m "feat: PianoKeys component" -``` - ---- - -## Task 5: GuitarFretboard component - -**Files:** -- Create: `app/app/components/chord-diagram/guitar-fretboard.tsx` - -- [ ] **Step 1: Create the component** - -Create `app/app/components/chord-diagram/guitar-fretboard.tsx`: - -```tsx -import type { GuitarVoicing } from '~/lib/chord-voicing'; - -const FRETS_SHOWN = 4; -const STRING_COUNT = 6; - -interface Props { - voicing: GuitarVoicing | null; -} - -export function GuitarFretboard({ voicing }: Props) { - if (!voicing) { - return ( -
- no voicing -
- ); - } - - const { frets, baseFret, barre } = voicing; - - // Show fret number label when not at open position - const showFretLabel = baseFret > 0; - - return ( -
- {/* Open/muted string indicators above nut */} -
- {frets.map((f, i) => ( -
- {f === null ? ( - - ) : f === 0 || (baseFret === 0 && f === 0) ? ( - - ) : null} -
- ))} -
- - {/* Fretboard grid */} -
- {/* Fret number label */} - {showFretLabel && ( -
- {baseFret} -
- )} - - {/* Strings (columns) */} - {frets.map((fret, stringIdx) => ( -
- {/* Nut or top border */} -
- {/* Fret cells */} - {Array.from({ length: FRETS_SHOWN }, (_, fretIdx) => { - // Open position (baseFret=0): rows represent frets 1-4 (nut is shown above) - // Barre position (baseFret>0): rows represent frets baseFret, baseFret+1, … - const absoluteFret = baseFret === 0 ? fretIdx + 1 : baseFret + fretIdx; - const hasDot = fret !== null && fret > 0 && fret === absoluteFret; - return ( -
- {hasDot && ( -
- )} -
- ); - })} -
- ))} - - {/* Barre indicator — only for actual barre chords (not open position) */} - {barre !== null && barre > 0 && ( -
- )} -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/app/components/chord-diagram/guitar-fretboard.tsx -git commit -m "feat: GuitarFretboard component" -``` - ---- - -## Task 6: ChordDiagram entry-point component - -**Files:** -- Create: `app/app/components/chord-diagram/chord-diagram.tsx` - -- [ ] **Step 1: Create the component** - -Create `app/app/components/chord-diagram/chord-diagram.tsx`: - -```tsx -import { getPianoNotes, getGuitarVoicing } from '~/lib/chord-voicing'; -import { PianoKeys } from './piano-keys'; -import { GuitarFretboard } from './guitar-fretboard'; - -export type Instrument = 'piano' | 'guitar'; - -interface Props { - chord: string; - instrument: Instrument; -} - -export function ChordDiagram({ chord, instrument }: Props) { - return ( -
- {chord} - {instrument === 'piano' ? ( - - ) : ( - - )} -
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/app/components/chord-diagram/chord-diagram.tsx -git commit -m "feat: ChordDiagram component" -``` - ---- - -## Task 7: ChordGrid component - -**Files:** -- Create: `app/app/components/chord-diagram/chord-grid.tsx` - -`ChordGrid` receives `instrument` and `onInstrumentChange` from the parent (songs.$id.tsx) so the inline popup and the grid share the same instrument selection. - -- [ ] **Step 1: Create the component** - -Create `app/app/components/chord-diagram/chord-grid.tsx`: - -```tsx -import { ChordDiagram } from './chord-diagram'; -import type { Instrument } from './chord-diagram'; - -interface Props { - chords: string[]; - instrument: Instrument; - onInstrumentChange: (i: Instrument) => void; -} - -export function ChordGrid({ chords, instrument, onInstrumentChange }: Props) { - if (chords.length === 0) return null; - - return ( -
- {/* Instrument toggle */} -
- Chords -
- - -
-
- - {/* Chord cards */} -
- {chords.map((chord) => ( -
- -
- ))} -
-
- ); -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add app/app/components/chord-diagram/chord-grid.tsx -git commit -m "feat: ChordGrid component" -``` - ---- - -## Task 8: Refactor ChordChart for tappable chords - -**Files:** -- Modify: `app/app/components/chord-chart.tsx` -- Modify: `app/app/lib/song-utils.ts` - -Replace the string-built chord row with positioned `` elements using `ch` units. Add `onChordClick` prop threaded through `ChordChart → SectionBlock → LineBlock`. - -Also add `extractUniqueChords` to song-utils. - -- [ ] **Step 1: Add extractUniqueChords to song-utils.ts** - -Replace the full contents of `app/app/lib/song-utils.ts` with: - -```ts -import type { Song, Section } from "./types"; - -export function previewChords(song: Song): string[] { - const seen = new Set(); - const result: string[] = []; - for (const section of song.sections) { - for (const line of section.lines) { - for (const cp of line.chords) { - if (!seen.has(cp.chord)) { - seen.add(cp.chord); - result.push(cp.chord); - } - } - } - if (result.length >= 5) break; - } - return result.slice(0, 5); -} - -/** All unique chord names in order of first appearance across all sections. */ -export function extractUniqueChords(sections: Section[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const section of sections) { - for (const line of section.lines) { - for (const cp of line.chords) { - if (!seen.has(cp.chord)) { - seen.add(cp.chord); - result.push(cp.chord); - } - } - } - } - return result; -} -``` - -- [ ] **Step 2: Refactor chord-chart.tsx** - -Replace the full content of `app/app/components/chord-chart.tsx`: - -```tsx -import type { LyricLine, Section } from "~/lib/types"; - -const MAX_WIDTH = 38; - -interface Props { - sections: Section[]; - fontSize?: 'sm' | 'base' | 'lg'; - onChordClick?: (chord: string) => void; -} - -/** Split one LyricLine into segments that each fit within maxWidth characters. */ -function segmentLine(line: LyricLine, maxWidth: number): LyricLine[] { - const { text, chords } = line; - if (text.length <= maxWidth) return [line]; - - const segments: LyricLine[] = []; - let start = 0; - - while (start < text.length) { - let end = start + maxWidth; - if (end < text.length) { - const breakAt = text.lastIndexOf(" ", end); - if (breakAt > start) end = breakAt + 1; - } else { - end = text.length; - } - const segText = text.slice(start, end).trimEnd(); - const segChords = chords - .filter((cp) => cp.offset >= start && cp.offset < end) - .map((cp) => ({ ...cp, offset: cp.offset - start })); - segments.push({ text: segText, chords: segChords }); - start = end; - while (start < text.length && text[start] === " ") start++; - } - return segments; -} - -function ChordRow({ - chords, - sizeClass, - onChordClick, -}: { - chords: { offset: number; chord: string }[]; - sizeClass: string; - onChordClick?: (chord: string) => void; -}) { - return ( -
- {chords.map(({ offset, chord }, i) => ( - onChordClick?.(chord)} - > - {chord} - - ))} -
- ); -} - -function LineBlock({ - line, - sizeClass, - onChordClick, -}: { - line: LyricLine; - sizeClass: string; - onChordClick?: (chord: string) => void; -}) { - return ( -
- {line.chords.length > 0 && ( - - )} - {line.text && ( -
-          {line.text}
-        
- )} -
- ); -} - -function SectionBlock({ - section, - sizeClass, - onChordClick, -}: { - section: Section; - sizeClass: string; - onChordClick?: (chord: string) => void; -}) { - return ( -
- {section.label && ( -

[{section.label}]

- )} - {section.lines.flatMap((line, i) => - segmentLine(line, MAX_WIDTH).map((seg, j) => ( - - )) - )} -
- ); -} - -export function ChordChart({ sections, fontSize, onChordClick }: Props) { - const sizeClass = { sm: 'text-sm', base: 'text-base', lg: 'text-lg' }[fontSize ?? 'sm']; - return ( -
- {sections.map((section, i) => ( - - ))} -
- ); -} -``` - -- [ ] **Step 3: Verify typecheck passes** - -```bash -cd app && npm run typecheck -``` - -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add app/app/components/chord-chart.tsx app/app/lib/song-utils.ts -git commit -m "feat: tappable chord names in ChordChart, extractUniqueChords" -``` - ---- - -## Task 9: Wire up songs.$id.tsx - -**Files:** -- Modify: `app/app/routes/songs.$id.tsx` - -Add `uniqueChords`, `instrument` state, inline popup (mobile), two-column layout (desktop). - -- [ ] **Step 1: Add imports to songs.$id.tsx** - -At the top of `app/app/routes/songs.$id.tsx`, add these imports alongside the existing ones: - -```tsx -import { useRef, useCallback } from "react"; -import { ChordGrid } from "~/components/chord-diagram/chord-grid"; -import { ChordDiagram } from "~/components/chord-diagram/chord-diagram"; -import type { Instrument } from "~/components/chord-diagram/chord-diagram"; -import { extractUniqueChords } from "~/lib/song-utils"; -``` - -- [ ] **Step 2: Add instrument state + activeChord state** - -At **module level** in `songs.$id.tsx`, after the existing `initFontSize` function, add: - -```tsx -function initInstrument(): Instrument { - try { - const v = localStorage.getItem('chordDiagramInstrument'); - if (v === 'piano' || v === 'guitar') return v; - } catch { /* noop */ } - return 'piano'; -} -``` - -Then inside the `SongDetail` component, alongside the existing `const [offset, setOffset]` etc., add: - -```tsx -const [activeChord, setActiveChord] = useState(null); -const [instrument, setInstrument] = useState(initInstrument); -const scrollRef = useRef(null); -``` - -- [ ] **Step 3: Add instrument persist handler + scroll close effect** - -Still inside `SongDetail`, after `handleFontSizeChange`: - -```tsx -function handleInstrumentChange(i: Instrument) { - setInstrument(i); - try { localStorage.setItem('chordDiagramInstrument', i); } catch { /* noop */ } -} - -const handleScroll = useCallback(() => setActiveChord(null), []); -``` - -- [ ] **Step 4: Compute uniqueChords + add handleChordClick** - -After `const displayed = transposeSong(displayedSong, offset);`, add: - -```tsx -const uniqueChords = extractUniqueChords(displayed.sections); -const handleChordClick = (chord: string) => setActiveChord(chord); -``` - -- [ ] **Step 5: Replace the return JSX** - -Replace the entire `return (...)` block in `SongDetail` (the part starting with `
`) with: - -```tsx -return ( -
- setEditOpen(true)} - onDelete={() => setDeleteOpen(true)} - fontSize={fontSize} - onFontSizeChange={handleFontSizeChange} - capo={baseSong.meta.capo ?? undefined} - applyCapo={applyCapo} - onToggleCapo={() => setApplyCapo((v) => !v)} - /> - - {/* Body: single column on mobile, two columns on desktop */} -
- {/* Left / main column */} -
-
- - - {/* Mobile bottom chord grid (hidden on desktop) */} -
- -
-
-
- - {/* Desktop side column (hidden on mobile) */} -
- -
-
- - {/* Mobile inline popup — fixed bottom, dismissed on scroll */} - {activeChord && ( -
- - -
- )} - - - -
-); -``` - -- [ ] **Step 6: Typecheck** - -```bash -cd app && npm run typecheck -``` - -Expected: no errors. - -- [ ] **Step 7: Smoke test in browser** - -```bash -cd app && npm run dev -``` - -Open a song. Verify: -- Chord names are underlined on hover. -- Tapping a chord name opens the fixed-bottom popup with the piano diagram. -- Scrolling the lyrics dismisses the popup. -- Piano/Guitar toggle switches all diagrams. -- On a wide window (≥ 1024px), the side column appears with all chord cards. -- Mobile bottom grid appears below all lyrics. - -- [ ] **Step 8: Final commit** - -```bash -git add app/app/routes/songs.$id.tsx -git commit -m "feat: chord diagram — piano/guitar diagrams in song detail" -``` 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 deleted file mode 100644 index 89bbe66..0000000 --- a/docs/superpowers/specs/2026-04-08-search-nav-management-design.md +++ /dev/null @@ -1,353 +0,0 @@ -# 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 diff --git a/docs/superpowers/specs/2026-04-09-chord-diagram-design.md b/docs/superpowers/specs/2026-04-09-chord-diagram-design.md deleted file mode 100644 index b2a996c..0000000 --- a/docs/superpowers/specs/2026-04-09-chord-diagram-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Chord Diagram Feature Design - -**Date:** 2026-04-09 - -## Overview - -A chord diagram feature for the song detail page that shows users how to play each chord on piano or guitar. The core component is dumb — it receives only a chord name string and renders the diagram. All music theory and voicing logic lives in a separate library layer. - -## Architecture - -Three cleanly separated layers: - -``` -chord name string ("Cmaj7") - │ - ▼ - [theory layer] — tonal parses name → root + note set {C, E, G, B} - │ - ▼ - [voicing layer] — maps note set → renderable positions - ├── Piano: note set → highlight keys on 1-octave keyboard - └── Guitar: chord quality + root → transpose moveable shape template - │ - ▼ - [render layer] — dumb components, no music theory knowledge - ├── - └── -``` - -## Files - -``` -app/app/ - lib/ - chord-voicing.ts # theory layer: tonal → notes + guitar voicing - guitar-voicings.ts # data: ~25 quality templates - components/ - chord-diagram/ - piano-keys.tsx # dumb renderer: string[] of note names → keyboard - guitar-fretboard.tsx # dumb renderer: frets[] + baseFret → fretboard grid - chord-diagram.tsx # entry point: chord+instrument → voicing → renderer - chord-grid.tsx # wrapped grid of ChordDiagram cards for all song chords -``` - -## Component API - -### `` - -```tsx - - -``` - -Renders nothing (graceful empty) if the chord cannot be parsed or has no voicing. - -### `` - -```tsx - -``` - -Owns the `instrument` state (`"piano" | "guitar"`), persisted to `localStorage` as `chordDiagramInstrument`. Renders a global piano/guitar toggle and a `flex-wrap` grid of `` cards. - -## Diagram Styles - -- **Piano:** dot notation — white keys with filled circles on pressed keys, black keys overlaid. 1 fixed octave shown (C to B); notes are matched by name regardless of octave. -- **Guitar:** standard vertical fretboard — nut at top, 4 frets shown, dots on finger positions, O/X above strings for open/muted. Barre indicator where applicable. - -## Theory Layer (`chord-voicing.ts`) - -Uses `@tonaljs/tonal` (already in npm, tree-shakeable): - -```ts -export function getPianoNotes(chord: string): string[] -// "Cmaj7" → ["C", "E", "G", "B"] -// Returns [] if unparseable - -export function getGuitarVoicing(chord: string): GuitarVoicing | null -// "Am" → { frets: [0,0,2,2,1,0], baseFret: 1, barre: null } -// Returns null if quality not in voicing map -``` - -## Guitar Voicing Data (`guitar-voicings.ts`) - -~25 moveable barre-chord templates keyed by `tonal` chord type name. Each template is a barre shape (no open strings) so it can be transposed by shifting `baseFret`. Two shape families are used: E-shapes (root on 6th string) and A-shapes (root on 5th string). `baseFret` is computed as the semitone distance from the template shape's root string pitch (E or A) to the target chord root. - -```ts -interface GuitarVoicingTemplate { - frets: (number | null)[] // 6 strings; null = muted; fret numbers relative to baseFret - baseFret: number // 1 in template; shifted when transposing to target root - barre: number | null // fret (relative to baseFret) to draw barre, or null - rootString: 'E' | 'A' // which string carries the root (determines transposition offset) -} -``` - -Quality names match `tonal`'s `Chord.get(name).type` output (e.g. `"major"`, `"minor"`, `"major seventh"`, `"dominant seventh"`, `"minor seventh"`, `"diminished"`, `"augmented"`, `"suspended fourth"`, `"suspended second"`, `"half-diminished"`, `"dominant seventh flat five"`, etc.). ~25 entries total. - -If `tonal` returns a quality name not in the map, `getGuitarVoicing` returns `null` and the diagram renders a "no guitar voicing" placeholder. - -## Layout & Integration - -### Breakpoint - -`lg` (Tailwind) divides mobile from desktop layout. - -### Mobile - -- Below `lg`: lyrics and diagrams in a single column. -- **Inline popup:** tapping a chord name in `chord-chart.tsx` sets `activeChord` state in `songs.$id.tsx`. An inline `` panel appears immediately below the tapped line. It closes when the scroll container fires a `scroll` event. -- **Bottom grid:** `` rendered after `` in the scroll column. Not sticky — scrolls with content. - -### Desktop - -- At `lg` and above: `songs.$id.tsx` switches to a two-column layout. -- Left column: `` (existing). -- Right column: `` showing all unique chords in the song, wrapped. No inline popup on desktop (side column is always visible). - -### Chord list source - -`songs.$id.tsx` derives `uniqueChords: string[]` from `displayed.sections` — all unique chord names in order of first appearance, deduplicated. This list is passed to `` and also used to determine which chord names in `` are tappable. - -### Instrument toggle - -Global piano/guitar toggle lives in ``. State persisted to `localStorage` as `chordDiagramInstrument`. Switching updates all visible diagrams at once. - -## Error / Unknown Chord Handling - -- `getPianoNotes` returns `[]` → `` renders with no dots highlighted and a subtle "?" label. -- `getGuitarVoicing` returns `null` → `` renders an empty fretboard with a "no voicing" label. -- Unparseable chord name (garbage string) → same fallback as above. - -## Dependency - -Add `tonal` to `app/package.json`. It is tree-shakeable; only chord parsing and note utilities will be bundled.