From b09094658c0b5ce0032bad5b4e1b4abf78f1fcad Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Mon, 6 Apr 2026 18:37:49 +0200 Subject: [PATCH 1/3] Redesign Library view to match mockup spec - Replace band-name header + tab structure (By Date / Search) with a unified Library view: title, inline search input, filter pills (All / instrument / Commented), and date-group headers - Session rows now use the recording-row card style (play circle, mono filename, recording count) - Move mini waveform bars from session list to individual recording rows in SessionPage, where they correspond to a single track - Fix Invalid Date by appending T12:00:00 when parsing date-only ISO strings in both BandPage and SessionPage - Update tests: drop tab assertions (TC-07), add Library heading (TC-08) and filter pill (TC-09) checks, update upload button label Co-Authored-By: Claude Sonnet 4.6 --- web/src/pages/BandPage.test.tsx | 31 +- web/src/pages/BandPage.tsx | 788 ++++++++++++-------------------- web/src/pages/SessionPage.tsx | 32 +- 3 files changed, 332 insertions(+), 519 deletions(-) diff --git a/web/src/pages/BandPage.test.tsx b/web/src/pages/BandPage.test.tsx index 0417c35..9905540 100644 --- a/web/src/pages/BandPage.test.tsx +++ b/web/src/pages/BandPage.test.tsx @@ -43,14 +43,13 @@ const renderBandPage = () => // ── Tests ───────────────────────────────────────────────────────────────────── -describe("BandPage — cleanliness (TC-01 to TC-07)", () => { +describe("BandPage — Library view (TC-01 to TC-09)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("TC-01: does not render a member list", async () => { renderBandPage(); - // Allow queries to settle await new Promise((r) => setTimeout(r, 50)); expect(screen.queryByText(/members/i)).toBeNull(); }); @@ -70,7 +69,6 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => { it("TC-04: renders sessions grouped by date", async () => { renderBandPage(); - // Sessions appear after the query resolves const sessionEl = await screen.findByText("Late Night Jam"); expect(sessionEl).toBeTruthy(); }); @@ -81,17 +79,30 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => { expect(btn).toBeTruthy(); }); - it("TC-06: renders the + New Song button", async () => { + it("TC-06: renders the + Upload button", async () => { renderBandPage(); - const btn = await screen.findByText(/\+ new song/i); + const btn = await screen.findByText(/\+ upload/i); expect(btn).toBeTruthy(); }); - it("TC-07: renders both By Date and Search tabs", async () => { + it("TC-07: does not render By Date / Search tabs", async () => { renderBandPage(); - const byDate = await screen.findByText(/by date/i); - const search = await screen.findByText(/^search$/i); - expect(byDate).toBeTruthy(); - expect(search).toBeTruthy(); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/by date/i)).toBeNull(); + expect(screen.queryByText(/^search$/i)).toBeNull(); + }); + + it("TC-08: renders the Library heading", async () => { + renderBandPage(); + const heading = await screen.findByText("Library"); + expect(heading).toBeTruthy(); + }); + + it("TC-09: renders filter pills including All and Guitar", async () => { + renderBandPage(); + const allPill = await screen.findByText("all"); + const guitarPill = await screen.findByText("guitar"); + expect(allPill).toBeTruthy(); + expect(guitarPill).toBeTruthy(); }); }); diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index 2a6de0b..6dfb29c 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getBand } from "../api/bands"; @@ -21,34 +21,37 @@ interface SessionSummary { recording_count: number; } +type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented"; + +const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"]; + function formatDate(iso: string): string { - const d = new Date(iso); + const d = new Date(iso + "T12:00:00"); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } -function weekday(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { weekday: "short" }); +function formatDateLabel(iso: string): string { + const d = new Date(iso + "T12:00:00"); + const today = new Date(); + today.setHours(12, 0, 0, 0); + const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) { + return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } export function BandPage() { const { bandId } = useParams<{ bandId: string }>(); const qc = useQueryClient(); - const [tab, setTab] = useState<"dates" | "search">("dates"); const [showCreate, setShowCreate] = useState(false); - const [title, setTitle] = useState(""); + const [newTitle, setNewTitle] = useState(""); const [error, setError] = useState(null); const [scanning, setScanning] = useState(false); const [scanProgress, setScanProgress] = useState(null); const [scanMsg, setScanMsg] = useState(null); - - // Search state - const [searchQ, setSearchQ] = useState(""); - const [searchKey, setSearchKey] = useState(""); - const [searchBpmMin, setSearchBpmMin] = useState(""); - const [searchBpmMax, setSearchBpmMax] = useState(""); - const [searchTagInput, setSearchTagInput] = useState(""); - const [searchTags, setSearchTags] = useState([]); - const [searchDirty, setSearchDirty] = useState(false); + const [librarySearch, setLibrarySearch] = useState(""); + const [activePill, setActivePill] = useState("all"); const { data: band, isLoading } = useQuery({ queryKey: ["band", bandId], @@ -59,35 +62,41 @@ export function BandPage() { const { data: sessions } = useQuery({ queryKey: ["sessions", bandId], queryFn: () => api.get(`/bands/${bandId}/sessions`), - enabled: !!bandId && tab === "dates", + enabled: !!bandId, }); const { data: unattributedSongs } = useQuery({ queryKey: ["songs-unattributed", bandId], queryFn: () => api.get(`/bands/${bandId}/songs/search?unattributed=true`), - enabled: !!bandId && tab === "dates", + enabled: !!bandId, }); - // Search results — only fetch when user has triggered a search - const searchParams = new URLSearchParams(); - if (searchQ) searchParams.set("q", searchQ); - if (searchKey) searchParams.set("key", searchKey); - if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin); - if (searchBpmMax) searchParams.set("bpm_max", searchBpmMax); - searchTags.forEach((t) => searchParams.append("tags", t)); + const filteredSessions = useMemo(() => { + return (sessions ?? []).filter((s) => { + if (!librarySearch) return true; + const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase(); + return haystack.includes(librarySearch.toLowerCase()); + }); + }, [sessions, librarySearch]); - const { data: searchResults, isFetching: searchFetching } = useQuery({ - queryKey: ["songs-search", bandId, searchParams.toString()], - queryFn: () => api.get(`/bands/${bandId}/songs/search?${searchParams}`), - enabled: !!bandId && tab === "search" && searchDirty, - }); + const filteredUnattributed = useMemo(() => { + return (unattributedSongs ?? []).filter((song) => { + const matchesSearch = + !librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase()); + const matchesPill = + activePill === "all" || + activePill === "commented" || + song.tags.some((t) => t.toLowerCase() === activePill); + return matchesSearch && matchesPill; + }); + }, [unattributedSongs, librarySearch, activePill]); const createMutation = useMutation({ - mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }), + mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["sessions", bandId] }); setShowCreate(false); - setTitle(""); + setNewTitle(""); setError(null); }, onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"), @@ -152,533 +161,304 @@ export function BandPage() { } } - function addTag() { - const t = searchTagInput.trim(); - if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]); - setSearchTagInput(""); - } - - function removeTag(t: string) { - setSearchTags((prev) => prev.filter((x) => x !== t)); - } - if (isLoading) return
Loading...
; if (!band) return
Band not found
; + const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0; + return ( -
- {/* ── Page header ───────────────────────────────────────── */} -
-
-

{band.name}

- {band.genre_tags.length > 0 && ( -
- {band.genre_tags.map((t: string) => ( - - {t} - - ))} -
- )} +
+ + {/* ── Header ─────────────────────────────────────────────── */} +
+ {/* Title row + search + actions */} +
+

+ Library +

+ + {/* Search input */} +
+ + + + + setLibrarySearch(e.target.value)} + placeholder="Search recordings, comments…" + style={{ + width: "100%", + padding: "7px 12px 7px 30px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#e2e2e8", + fontSize: 13, + fontFamily: "inherit", + outline: "none", + boxSizing: "border-box", + }} + onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")} + /> +
+ +
+ + +
-
- - + {/* Filter pills */} +
+ {PILLS.map((pill) => { + const active = activePill === pill; + return ( + + ); + })}
{/* ── Scan feedback ─────────────────────────────────────── */} {scanning && scanProgress && ( -
- {scanProgress} +
+
+ {scanProgress} +
)} {scanMsg && ( -
- {scanMsg} -
- )} - - {/* ── New song form ─────────────────────────────────────── */} - {showCreate && ( -
- {error &&

{error}

} - - setTitle(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} - style={{ - width: "100%", - padding: "8px 12px", - background: "rgba(255,255,255,0.05)", - border: "1px solid rgba(255,255,255,0.08)", - borderRadius: 7, - color: "#eeeef2", - marginBottom: 12, - fontSize: 14, - fontFamily: "inherit", - boxSizing: "border-box", - outline: "none", - }} - autoFocus - /> -
- - +
+
+ {scanMsg}
)} - {/* ── Tabs ──────────────────────────────────────────────── */} -
- {(["dates", "search"] as const).map((t) => ( - - ))} -
+ {/* ── New song / upload form ─────────────────────────────── */} + {showCreate && ( +
+
+ {error &&

{error}

} + + setNewTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()} + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", marginBottom: 12, fontSize: 14, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + autoFocus + /> +
+ + +
+
+
+ )} - {/* ── By Date tab ───────────────────────────────────────── */} - {tab === "dates" && ( -
- {sessions?.map((s) => ( + {/* ── Scrollable content ────────────────────────────────── */} +
+ + {/* Sessions — one date group per session */} + {filteredSessions.map((s) => ( +
+ {/* Date group header */} +
+ + {formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""} + +
+ + {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} + +
+ + {/* Session row */} { - (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)"; - (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)"; + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; - (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > -
- - {weekday(s.date)} - - {formatDate(s.date)} - {s.label && ( - - {s.label} - - )} + {/* Play button circle */} +
+ + +
- - {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} + + {/* Session name */} + + {s.label ?? formatDate(s.date)} + + + {/* Recording count */} + + {s.recording_count} - ))} - - {sessions?.length === 0 && !unattributedSongs?.length && ( -

- No sessions yet. Scan Nextcloud or create a song to get started. -

- )} - - {/* Unattributed songs */} - {!!unattributedSongs?.length && ( -
-
- Unattributed Recordings -
-
- {unattributedSongs.map((song) => ( - -
-
{song.title}
-
- {song.tags.map((t) => ( - - {t} - - ))} -
-
-
- - {song.status} - - {song.version_count} version{song.version_count !== 1 ? "s" : ""} -
- - ))} -
-
- )} -
- )} - - {/* ── Search tab ────────────────────────────────────────── */} - {tab === "search" && ( -
-
-
-
- - setSearchQ(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }} - placeholder="Search by name…" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchKey(e.target.value)} - placeholder="e.g. Am, C, F#" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchBpmMin(e.target.value)} - type="number" - min={0} - placeholder="e.g. 80" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- - setSearchBpmMax(e.target.value)} - type="number" - min={0} - placeholder="e.g. 140" - style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} - /> -
-
- -
- -
- {searchTags.map((t) => ( - - {t} - - - ))} -
-
- setSearchTagInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addTag()} - placeholder="Add tag…" - style={{ flex: 1, padding: "6px 10px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 12, fontFamily: "inherit", outline: "none" }} - /> - -
-
- -
+ ))} - {searchFetching &&

Searching…

} - {!searchFetching && searchDirty && ( -
- {searchResults?.map((song) => ( + {/* Unattributed recordings */} + {filteredUnattributed.length > 0 && ( +
0 ? 28 : 18 }}> + {/* Section header */} +
+ + Unattributed + +
+ + {filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""} + +
+ +
+ {filteredUnattributed.map((song) => ( { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > -
-
{song.title}
+
+ + + +
+ +
+
+ {song.title} +
{song.tags.map((t) => ( - {t} + + {t} + ))} {song.global_key && ( - {song.global_key} + + {song.global_key} + )} {song.global_bpm && ( - {song.global_bpm.toFixed(0)} BPM + + {song.global_bpm.toFixed(0)} BPM + )}
-
- {song.status} - {song.version_count} version{song.version_count !== 1 ? "s" : ""} -
+ + + {song.version_count} ver{song.version_count !== 1 ? "s" : ""} + ))} - {searchResults?.length === 0 && ( -

No songs match your filters.

- )}
- )} - {!searchDirty && ( -

Enter filters above and hit Search.

- )} -
- )} +
+ )} + + {/* Empty state */} + {!hasResults && ( +

+ {librarySearch + ? "No results match your search." + : "No sessions yet. Scan Nextcloud or create a song to get started."} +

+ )} +
); } + diff --git a/web/src/pages/SessionPage.tsx b/web/src/pages/SessionPage.tsx index 52e65ea..e2e8173 100644 --- a/web/src/pages/SessionPage.tsx +++ b/web/src/pages/SessionPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; @@ -24,10 +24,29 @@ interface SessionDetail { } function formatDate(iso: string): string { - const d = new Date(iso); + const d = new Date(iso + "T12:00:00"); return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); } +function computeWaveBars(seed: string): number[] { + let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337); + return Array.from({ length: 14 }, () => { + s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0; + return Math.max(15, Math.floor((s / 0xffffffff) * 100)); + }); +} + +function MiniWaveBars({ seed }: { seed: string }) { + const bars = useMemo(() => computeWaveBars(seed), [seed]); + return ( +
+ {bars.map((h, i) => ( +
+ ))} +
+ ); +} + export function SessionPage() { const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>(); const qc = useQueryClient(); @@ -165,9 +184,12 @@ export function SessionPage() { )}
-
- {song.status} - {song.version_count} version{song.version_count !== 1 ? "s" : ""} +
+ +
+ {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""} +
))} From 013a2fc2d6f569d1d7378c7388bb53b80b54cc9a Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Mon, 6 Apr 2026 18:40:38 +0200 Subject: [PATCH 2/3] Fix Invalid Date for datetime strings from API The API returns dates as "2024-12-11T00:00:00" (datetime, no timezone), not bare "2024-12-11". Appending T12:00:00 directly produced an invalid string. Use .slice(0, 10) to extract the date part first before parsing. Co-Authored-By: Claude Sonnet 4.6 --- web/src/pages/BandPage.tsx | 4 ++-- web/src/pages/SessionPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index 6dfb29c..c135cb3 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -26,12 +26,12 @@ type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"]; function formatDate(iso: string): string { - const d = new Date(iso + "T12:00:00"); + const d = new Date(iso.slice(0, 10) + "T12:00:00"); return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } function formatDateLabel(iso: string): string { - const d = new Date(iso + "T12:00:00"); + const d = new Date(iso.slice(0, 10) + "T12:00:00"); const today = new Date(); today.setHours(12, 0, 0, 0); const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)); diff --git a/web/src/pages/SessionPage.tsx b/web/src/pages/SessionPage.tsx index e2e8173..7adab04 100644 --- a/web/src/pages/SessionPage.tsx +++ b/web/src/pages/SessionPage.tsx @@ -24,7 +24,7 @@ interface SessionDetail { } function formatDate(iso: string): string { - const d = new Date(iso + "T12:00:00"); + const d = new Date(iso.slice(0, 10) + "T12:00:00"); return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }); } From 659598913b3a25f0d3ecf76dfdf5b5329f979dc5 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Mon, 6 Apr 2026 18:44:02 +0200 Subject: [PATCH 3/3] Remove play button from Library session rows Play buttons don't make sense at the session level since sessions group multiple recordings. Removed from both session rows and unattributed song rows. Co-Authored-By: Claude Sonnet 4.6 --- web/src/pages/BandPage.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index c135cb3..427be08 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -362,13 +362,6 @@ export function BandPage() { (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > - {/* Play button circle */} -
- - - -
- {/* Session name */} {s.label ?? formatDate(s.date)} @@ -411,12 +404,6 @@ export function BandPage() { (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)"; }} > -
- - - -
-
{song.title}