From 2b8542ecec025ad489c1f5fbc43865b56390d80b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 14:39:00 +0000 Subject: [PATCH 1/5] feat(workspace): find tonight's first range on the roadmap The ready map already names tonight's first playable range. Players still had to hunt the section roadmap for the chord, cue, and clash on that part. The range card now offers Find {section} for {role} on the roadmap and highlights the matching unique section and part without starting playback. Fail closed on duplicate section or part identity. Repeated Find clicks scroll again. Replacing the loaded song cannot inherit a previous highlight. Roadmap scrolling honors prefers-reduced-motion. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../src/features/workspace/SectionRoadmap.tsx | 69 ++++++++- .../src/features/workspace/Workspace.test.tsx | 36 +++++ .../src/features/workspace/Workspace.tsx | 54 ++++++- .../firstRangeRoadmap.regression.test.tsx | 141 ++++++++++++++++++ .../workspace/firstRangeSqueeze.test.ts | 77 +++++++++- .../features/workspace/firstRangeSqueeze.ts | 124 +++++++++++++++ apps/desktop/src/locales/en/common.json | 1 + apps/desktop/src/locales/ko/common.json | 1 + docs/architecture/overview.md | 2 +- 13 files changed, 498 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..26fb21d49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and offers a next action that finds that part on the section roadmap. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..4c4a57df2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, offering a roadmap find control, and naming the next instrument check - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..9c5b8e89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. +- Name tonight's first playable range on the ready rehearsal map, offer Find {section} for {role} on the roadmap so the player can locate that part, and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..560abc0ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, offers a control that finds that part on the section roadmap, and tells the player to check that span on their instrument. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..2e92cba2a 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,5 +1,5 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useRef } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; @@ -12,13 +12,41 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + focusSectionId?: string | null; + focusRoleId?: string | null; + focusRequestSequence?: number; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ + song, + activeRole, + onSongUpdate, + focusSectionId = null, + focusRoleId = null, + focusRequestSequence = 0 +}: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const sectionCardRefs = useRef(new Map()); + + useEffect(() => { + if (!focusSectionId || focusRequestSequence < 1) { + return; + } + const sectionCard = sectionCardRefs.current.get(focusSectionId); + if (sectionCard && typeof sectionCard.scrollIntoView === "function") { + const reducedMotionPreferred = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + sectionCard.scrollIntoView({ + behavior: reducedMotionPreferred ? "auto" : "smooth", + inline: "center", + block: "nearest" + }); + } + }, [focusRequestSequence, focusSectionId]); /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { @@ -104,11 +132,29 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( - { + const sectionFocused = focusSectionId === section.id; + return ( +
{ + if (sectionNode) { + sectionCardRefs.current.set(section.id, sectionNode); + } else { + sectionCardRefs.current.delete(section.id); + } + }} + data-testid={`section-roadmap-section-${section.id}`} + aria-current={sectionFocused ? "location" : undefined} + className="w-80 flex-none shrink-0 snap-start" + > + @@ -127,10 +173,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma .filter(role => !activeRole || role.id === activeRole) .map(role => { const validatedRange = playableRange(role.range.lowestNote, role.range.highestNote); + const roleFocused = sectionFocused && focusRoleId === role.id; return (
@@ -228,7 +279,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma })} - ))} +
+ ); + })}
); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..3db8d3b9a 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -140,6 +140,40 @@ describe("Workspace", () => { expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); + it("finds tonight's first range on the section roadmap", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute("aria-current", "location"); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).toHaveAttribute("aria-current", "true"); + }); + + it("hides the roadmap find control when the named section identity is duplicated", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections.push({ + ...song.sections[0]!, + id: "verse-2", + label: "verse" + }); + + render(); + + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); + }); + + it("localizes the first-range roadmap find control", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "로드맵에서 Bass Guitar verse 찾기" })).toBeTruthy(); + }); + it("names tonight's first playable range and the next instrument check", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -151,6 +185,7 @@ describe("Workspace", () => { expect(callout).toHaveTextContent( "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." ); + expect(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })).toBeTruthy(); }); it("asks for an ear check when the selected part has no named span", () => { @@ -167,6 +202,7 @@ describe("Workspace", () => { expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." ); + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); }); it("limits the range callout to the selected role", () => { diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..10a1e4504 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,7 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillRangeCopy, firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,14 @@ interface WorkspaceProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** Request identity for a user-initiated section-roadmap focus action. */ +type RoadmapFocusRequest = { + rehearsalSongId: string; + sectionId: string; + roleId: string; + requestSequence: number; +}; + /** Documented. */ function formatTimelineTime(totalSeconds: number): string { const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; @@ -121,6 +129,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [roadmapFocusRequest, setRoadmapFocusRequest] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -163,6 +172,35 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstRangeBoard = firstRangeRoadmap(song, firstRange); + const firstRangeFindCopy = firstRangeBoard + ? fillRangeCopy(t("workspaceFirstRangeFindRoadmap"), { + sectionLabel: firstRangeBoard.sectionLabel, + roleName: firstRangeBoard.roleName + }) + : null; + const focusedSectionId = + roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.sectionId : null; + const focusedRoleId = + roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.roleId : null; + const focusRequestSequence = + roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.requestSequence : 0; + + /** Request the first-range roadmap cell on every activation, even when it is already highlighted. */ + const handleFindFirstRangeRoadmap = () => { + if (!firstRangeBoard) { + return; + } + setRoadmapFocusRequest((previousFocusRequest) => ({ + rehearsalSongId: song.id, + sectionId: firstRangeBoard.sectionId, + roleId: firstRangeBoard.roleId, + requestSequence: + previousFocusRequest?.rehearsalSongId === song.id + ? previousFocusRequest.requestSequence + 1 + : 1 + })); + }; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -308,6 +346,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp >

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+ {firstRangeBoard && firstRangeFindCopy ? ( + + ) : null}
@@ -506,6 +555,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp song={song} activeRole={activeRole} onSongUpdate={onSongUpdate} + focusSectionId={focusedSectionId} + focusRoleId={focusedRoleId} + focusRequestSequence={focusRequestSequence} /> diff --git a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx new file mode 100644 index 000000000..0c639441f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; +import { firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze"; + +const originalMatchMedia = window.matchMedia; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +/** Record scrollIntoView so repeated Find activations can be counted. */ +function installScrollRecorder() { + const scrollRequests = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollRequests + }); + return scrollRequests; +} + +/** Stub matchMedia for the reduced-motion preference used by roadmap scrolling. */ +function setReducedMotionPreference(reducedMotionPreferred: boolean) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockReturnValue({ matches: reducedMotionPreferred }) + }); +} + +describe("first-range roadmap interaction regressions", () => { + afterEach(() => { + if (originalScrollIntoView) { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView + }); + } else { + delete (HTMLElement.prototype as { scrollIntoView?: typeof HTMLElement.prototype.scrollIntoView }).scrollIntoView; + } + + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia + }); + vi.restoreAllMocks(); + }); + + it("requests roadmap focus again when Find is activated twice for the same part", () => { + const scrollRequests = installScrollRecorder(); + const rehearsalSong = createDemoRehearsalSong(); + + render(); + const findRoadmapButton = screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }); + + fireEvent.click(findRoadmapButton); + fireEvent.click(findRoadmapButton); + + expect(scrollRequests).toHaveBeenCalledTimes(2); + }); + + it("does not carry a focused roadmap cell into a replacement rehearsal song", () => { + installScrollRecorder(); + const rehearsalSong = createDemoRehearsalSong(); + const replacementSong = { + ...createDemoRehearsalSong(), + id: "replacement-song" + }; + + const renderedWorkspace = render(); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute( + "aria-current", + "location" + ); + + renderedWorkspace.rerender(); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).not.toHaveAttribute( + "aria-current" + ); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).not.toHaveAttribute( + "aria-current" + ); + }); + + it("fails closed when the target section identifier is duplicated elsewhere", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: rehearsalSong.sections[0]!.id, + label: "chorus" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + render(); + expect( + screen.queryByRole("button", { name: /Find .+ on the roadmap/ }) + ).toBeNull(); + expect( + screen.queryAllByTestId("section-roadmap-section-verse-1").filter( + (sectionCard) => sectionCard.getAttribute("aria-current") === "location" + ) + ).toHaveLength(0); + }); + + it("removes the buyer-visible control when the named part is duplicated on the section", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + id: "bass-guitar-double" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + render(); + expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); + expect(screen.queryByTestId("section-roadmap-section-verse-1")?.getAttribute("aria-current")).toBeNull(); + }); + + it("avoids smooth scrolling when reduced motion is preferred", () => { + const scrollRequests = installScrollRecorder(); + setReducedMotionPreference(true); + const rehearsalSong = createDemoRehearsalSong(); + + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + + expect(scrollRequests).toHaveBeenCalledWith( + expect.objectContaining({ behavior: "auto" }) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts index 643935954..3ad5d0abe 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -1,6 +1,6 @@ import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; -import { fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; +import { firstRangeRoadmap, fillRangeCopy, firstRangeSqueeze, meaningfulRangeText, playableRange } from "./firstRangeSqueeze"; function blankRoleRange(song: RehearsalSong): RehearsalSong { return { @@ -140,6 +140,81 @@ describe("firstRangeSqueeze", () => { }); }); +describe("firstRangeRoadmap", () => { + it("returns the unique section and part for tonight's first named span", () => { + const rehearsalSong = createDemoRehearsalSong(); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("limits the roadmap target to the selected role", () => { + const rehearsalSong = createDemoRehearsalSong(); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong, "lead-vocal"))).toEqual({ + sectionId: "verse-1", + roleId: "lead-vocal", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("fails closed when the named section label is duplicated", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: "verse-2", + label: "verse" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("fails closed when the matching section identifier is duplicated", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections.push({ + ...rehearsalSong.sections[0]!, + id: "verse-1", + label: "chorus" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("fails closed when the named part is duplicated on the matching section", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + id: "bass-guitar-double" + }); + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("fails closed when the matching role identifier is duplicated", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles[1] = { + ...rehearsalSong.sections[0]!.roles[1]!, + id: "bass-guitar" + }; + + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); + + it("fails closed on missing squeeze, blank identifiers, and malformed roots", () => { + const rehearsalSong = createDemoRehearsalSong(); + expect(firstRangeRoadmap(rehearsalSong, null)).toBeNull(); + expect(firstRangeRoadmap({} as RehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + + rehearsalSong.sections[0]!.id = " "; + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + }); +}); + describe("fillRangeCopy", () => { it("replaces every token occurrence", () => { expect( diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts index 47270d2a9..cd2135813 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -9,6 +9,14 @@ export type FirstRangeSqueeze = { overlapWarning?: string; }; +/** Trusted roadmap cell for tonight's first playable span. */ +export type FirstRangeRoadmap = { + sectionId: string; + roleId: string; + sectionLabel: string; + roleName: string; +}; + const NATURAL_PITCH_CLASS = { C: 0, D: 2, @@ -162,6 +170,122 @@ export function firstRangeSqueeze( return fallback; } +/** + * Offer the named first-range section and part only when both identities are unique and trusted. + * + * Fail closed when the squeeze is missing, the section label is not unique on + * the current map, the matching cell has no uniquely owned identity, or the + * named part is not unique on that section. Does not start playback; #961 owns + * the rehearsal player. Does not mix with #1143 timeline find. + */ +export function firstRangeRoadmap( + rehearsalSong: RehearsalSong, + rangeSqueeze: FirstRangeSqueeze | null +): FirstRangeRoadmap | null { + if (!rangeSqueeze) { + return null; + } + + const runtimeSong: unknown = rehearsalSong; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const sectionIdOccurrences = new Map(); + const sectionLabelOccurrences = new Map(); + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + const sectionId = meaningfulRangeText(sectionValue.id); + if (sectionId) { + sectionIdOccurrences.set(sectionId, (sectionIdOccurrences.get(sectionId) ?? 0) + 1); + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (sectionLabel) { + sectionLabelOccurrences.set( + sectionLabel, + (sectionLabelOccurrences.get(sectionLabel) ?? 0) + 1 + ); + } + } + + if (sectionLabelOccurrences.get(rangeSqueeze.sectionLabel) !== 1) { + return null; + } + + let roadmapMatch: FirstRangeRoadmap | null = null; + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (sectionLabel !== rangeSqueeze.sectionLabel) { + continue; + } + + const sectionId = meaningfulRangeText(sectionValue.id); + if (!sectionId || sectionIdOccurrences.get(sectionId) !== 1) { + return null; + } + + const roleIdOccurrences = new Map(); + const roleNameOccurrences = new Map(); + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + const roleId = meaningfulRangeText(roleValue.id); + if (roleId) { + roleIdOccurrences.set(roleId, (roleIdOccurrences.get(roleId) ?? 0) + 1); + } + const roleName = meaningfulRangeText(roleValue.name); + if (roleName) { + roleNameOccurrences.set(roleName, (roleNameOccurrences.get(roleName) ?? 0) + 1); + } + } + + if (roleNameOccurrences.get(rangeSqueeze.roleName) !== 1) { + return null; + } + + let matchingRole: FirstRangeRoadmap | null = null; + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + const roleName = meaningfulRangeText(roleValue.name); + if (roleName !== rangeSqueeze.roleName) { + continue; + } + const roleId = meaningfulRangeText(roleValue.id); + if (!roleId || roleIdOccurrences.get(roleId) !== 1) { + return null; + } + if (matchingRole) { + return null; + } + matchingRole = { + sectionId, + roleId, + sectionLabel, + roleName + }; + } + + if (!matchingRole) { + return null; + } + if (roadmapMatch) { + return null; + } + roadmapMatch = matchingRole; + } + + return roadmapMatch; +} + /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ export function fillRangeCopy(template: string, values: Record): string { return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (placeholder, token: string) => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..2824457ff 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,7 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstRangeFindRoadmap": "Find {sectionLabel} for {roleName} on the roadmap", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5fee3d96f 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,7 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstRangeFindRoadmap": "로드맵에서 {roleName} {sectionLabel} 찾기", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..2e172f254 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -11,7 +11,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, and handoffs - groove and timing cues -- role ranges, overlap warnings, and simplification guidance +- role ranges, overlap warnings, and simplification guidance, with the ready workspace naming tonight's first span and offering a control that finds that part on the section roadmap - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority From 58cfbb8d651286000e449c6639cd86b3fa0b0f0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:03:48 +0900 Subject: [PATCH 2/5] test(workspace): reproduce repeated roadmap label rejection --- .../firstRangeRoadmap.regression.test.tsx | 23 +++++++++++++-- .../workspace/firstRangeSqueeze.test.ts | 28 +++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx index 0c639441f..cbec0d017 100644 --- a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx +++ b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx @@ -108,18 +108,35 @@ describe("first-range roadmap interaction regressions", () => { ).toHaveLength(0); }); - it("removes the buyer-visible control when the named part is duplicated on the section", () => { + it("keeps the buyer-visible control when display names repeat but IDs remain unique", () => { const rehearsalSong = createDemoRehearsalSong(); rehearsalSong.sections[0]!.roles.push({ ...rehearsalSong.sections[0]!.roles[0]!, id: "bass-guitar-double" }); + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + + render(); + expect(screen.getByRole("button", { name: "Find verse for Bass Guitar on the roadmap" })).toBeInTheDocument(); + }); + + it("fails closed when the target role identifier is duplicated on the section", () => { + const rehearsalSong = createDemoRehearsalSong(); + rehearsalSong.sections[0]!.roles.push({ + ...rehearsalSong.sections[0]!.roles[0]!, + name: "Bass Guitar Double" + }); + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); render(); expect(screen.queryByRole("button", { name: /Find .+ on the roadmap/ })).toBeNull(); - expect(screen.queryByTestId("section-roadmap-section-verse-1")?.getAttribute("aria-current")).toBeNull(); }); it("avoids smooth scrolling when reduced motion is preferred", () => { @@ -138,4 +155,4 @@ describe("first-range roadmap interaction regressions", () => { expect.objectContaining({ behavior: "auto" }) ); }); -}); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts index 3ad5d0abe..392442b7e 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts @@ -51,7 +51,9 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong()); expect(squeeze).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -67,7 +69,9 @@ describe("firstRangeSqueeze", () => { })); expect(firstRangeSqueeze(song)).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -114,7 +118,9 @@ describe("firstRangeSqueeze", () => { expect( firstRangeSqueeze({ ...song, sections: [malformedSection] } as unknown as RehearsalSong) ).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "bass-guitar", roleName: "Bass Guitar", lowestNote: "C#2", highestNote: "E3", @@ -126,7 +132,9 @@ describe("firstRangeSqueeze", () => { const squeeze = firstRangeSqueeze(createDemoRehearsalSong(), "lead-vocal"); expect(squeeze).toEqual({ + sectionId: "verse-1", sectionLabel: "verse", + roleId: "lead-vocal", roleName: "Lead Vocal", lowestNote: "G#3", highestNote: "C#5", @@ -163,7 +171,7 @@ describe("firstRangeRoadmap", () => { }); }); - it("fails closed when the named section label is duplicated", () => { + it("allows repeated section labels when section identifiers remain unique", () => { const rehearsalSong = createDemoRehearsalSong(); rehearsalSong.sections.push({ ...rehearsalSong.sections[0]!, @@ -171,7 +179,12 @@ describe("firstRangeRoadmap", () => { label: "verse" }); - expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); }); it("fails closed when the matching section identifier is duplicated", () => { @@ -185,14 +198,19 @@ describe("firstRangeRoadmap", () => { expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); }); - it("fails closed when the named part is duplicated on the matching section", () => { + it("allows repeated part display names when role identifiers remain unique", () => { const rehearsalSong = createDemoRehearsalSong(); rehearsalSong.sections[0]!.roles.push({ ...rehearsalSong.sections[0]!.roles[0]!, id: "bass-guitar-double" }); - expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toBeNull(); + expect(firstRangeRoadmap(rehearsalSong, firstRangeSqueeze(rehearsalSong))).toEqual({ + sectionId: "verse-1", + roleId: "bass-guitar", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); }); it("fails closed when the matching role identifier is duplicated", () => { @@ -239,4 +257,4 @@ describe("fillRangeCopy", () => { fillRangeCopy("Check {toString} before {missingToken}.", { sectionLabel: "verse" }) ).toBe("Check {toString} before {missingToken}."); }); -}); +}); \ No newline at end of file From ee8e079fcdd3f2d8192d28bb95210dcbd45ca14c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:04:29 +0900 Subject: [PATCH 3/5] fix(workspace): navigate repeated form labels by owned IDs --- .../features/workspace/firstRangeSqueeze.ts | 127 +++++++----------- 1 file changed, 45 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts index cd2135813..048419859 100644 --- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts +++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts @@ -2,7 +2,9 @@ import type { RehearsalSong } from "@bandscope/shared-types"; /** Tonight's first named playable span on the rehearsal map. */ export type FirstRangeSqueeze = { + sectionId?: string; sectionLabel: string; + roleId: string; roleName: string; lowestNote: string; highestNote: string; @@ -116,6 +118,7 @@ export function firstRangeSqueeze( if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { continue; } + const sectionId = meaningfulRangeText(sectionValue.id); const sectionLabel = meaningfulRangeText(sectionValue.label); if (!sectionLabel) { continue; @@ -151,7 +154,9 @@ export function firstRangeSqueeze( } const candidate: FirstRangeSqueeze = { + sectionId, sectionLabel, + roleId, roleName, ...range, overlapWarning @@ -171,12 +176,13 @@ export function firstRangeSqueeze( } /** - * Offer the named first-range section and part only when both identities are unique and trusted. + * Offer the originating first-range section and part only when their IDs remain unique and trusted. * - * Fail closed when the squeeze is missing, the section label is not unique on - * the current map, the matching cell has no uniquely owned identity, or the - * named part is not unique on that section. Does not start playback; #961 owns - * the rehearsal player. Does not mix with #1143 timeline find. + * The first-range selection carries the section/role identifiers that owned + * the range evidence. Display labels and names may legitimately repeat in a + * song, so they are presentation data rather than navigation authority. Fail + * closed when either identifier is absent/duplicated or the referenced runtime + * cell is malformed. Does not start playback; #961 owns the rehearsal player. */ export function firstRangeRoadmap( rehearsalSong: RehearsalSong, @@ -186,104 +192,61 @@ export function firstRangeRoadmap( return null; } + const targetSectionId = meaningfulRangeText(rangeSqueeze.sectionId); + const targetRoleId = meaningfulRangeText(rangeSqueeze.roleId); + if (!targetSectionId || !targetRoleId) { + return null; + } + const runtimeSong: unknown = rehearsalSong; if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { return null; } - const sectionIdOccurrences = new Map(); - const sectionLabelOccurrences = new Map(); + let sectionIdOccurrences = 0; + let targetSection: Record | null = null; for (const sectionValue of runtimeSong.sections) { if (!isRuntimeObject(sectionValue)) { continue; } - const sectionId = meaningfulRangeText(sectionValue.id); - if (sectionId) { - sectionIdOccurrences.set(sectionId, (sectionIdOccurrences.get(sectionId) ?? 0) + 1); - } - const sectionLabel = meaningfulRangeText(sectionValue.label); - if (sectionLabel) { - sectionLabelOccurrences.set( - sectionLabel, - (sectionLabelOccurrences.get(sectionLabel) ?? 0) + 1 - ); + if (meaningfulRangeText(sectionValue.id) === targetSectionId) { + sectionIdOccurrences += 1; + targetSection = sectionValue; } } - if (sectionLabelOccurrences.get(rangeSqueeze.sectionLabel) !== 1) { + if (sectionIdOccurrences !== 1 || !targetSection || !Array.isArray(targetSection.roles)) { return null; } - let roadmapMatch: FirstRangeRoadmap | null = null; - - for (const sectionValue of runtimeSong.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + let roleIdOccurrences = 0; + let targetRole: Record | null = null; + for (const roleValue of targetSection.roles) { + if (!isRuntimeObject(roleValue)) { continue; } - const sectionLabel = meaningfulRangeText(sectionValue.label); - if (sectionLabel !== rangeSqueeze.sectionLabel) { - continue; - } - - const sectionId = meaningfulRangeText(sectionValue.id); - if (!sectionId || sectionIdOccurrences.get(sectionId) !== 1) { - return null; - } - - const roleIdOccurrences = new Map(); - const roleNameOccurrences = new Map(); - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue)) { - continue; - } - const roleId = meaningfulRangeText(roleValue.id); - if (roleId) { - roleIdOccurrences.set(roleId, (roleIdOccurrences.get(roleId) ?? 0) + 1); - } - const roleName = meaningfulRangeText(roleValue.name); - if (roleName) { - roleNameOccurrences.set(roleName, (roleNameOccurrences.get(roleName) ?? 0) + 1); - } - } - - if (roleNameOccurrences.get(rangeSqueeze.roleName) !== 1) { - return null; + if (meaningfulRangeText(roleValue.id) === targetRoleId) { + roleIdOccurrences += 1; + targetRole = roleValue; } + } - let matchingRole: FirstRangeRoadmap | null = null; - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue)) { - continue; - } - const roleName = meaningfulRangeText(roleValue.name); - if (roleName !== rangeSqueeze.roleName) { - continue; - } - const roleId = meaningfulRangeText(roleValue.id); - if (!roleId || roleIdOccurrences.get(roleId) !== 1) { - return null; - } - if (matchingRole) { - return null; - } - matchingRole = { - sectionId, - roleId, - sectionLabel, - roleName - }; - } + if (roleIdOccurrences !== 1 || !targetRole) { + return null; + } - if (!matchingRole) { - return null; - } - if (roadmapMatch) { - return null; - } - roadmapMatch = matchingRole; + const sectionLabel = meaningfulRangeText(targetSection.label); + const roleName = meaningfulRangeText(targetRole.name); + if (!sectionLabel || !roleName) { + return null; } - return roadmapMatch; + return { + sectionId: targetSectionId, + roleId: targetRoleId, + sectionLabel, + roleName + }; } /** Fill trusted `{token}` placeholders once while keeping rehearsal values literal. */ @@ -293,4 +256,4 @@ export function fillRangeCopy(template: string, values: Record): // never satisfy a token, or the raw function source would be rendered. return Object.prototype.hasOwnProperty.call(values, token) ? values[token] : placeholder; }); -} +} \ No newline at end of file From e3ad077e6578bb63baef69040d71c84ce265c365 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:09:01 +0900 Subject: [PATCH 4/5] test(workspace): reproduce reused analysis identity focus leak --- .../firstRangeRoadmap.regression.test.tsx | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx index cbec0d017..3594da8ef 100644 --- a/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx +++ b/apps/desktop/src/features/workspace/firstRangeRoadmap.regression.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { createDemoRehearsalSong, type ProjectBootstrapSummary } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; import { firstRangeRoadmap, firstRangeSqueeze } from "./firstRangeSqueeze"; @@ -25,6 +25,23 @@ function setReducedMotionPreference(reducedMotionPreferred: boolean) { }); } +/** Create a valid local-audio project identity without exposing a real local path. */ +function projectBootstrap(projectId: string): ProjectBootstrapSummary { + return { + projectId, + sourceMode: "reference", + projectRoot: `/tmp/bandscope/projects/${projectId}`, + cacheRoot: `/tmp/bandscope/projects/${projectId}/cache`, + tempRoot: `/tmp/bandscope/projects/${projectId}/tmp`, + source: { + sourcePath: `/tmp/bandscope/projects/${projectId}/source.wav`, + fileName: "source.wav", + extension: "wav", + fileSizeBytes: 1024 + } + }; +} + describe("first-range roadmap interaction regressions", () => { afterEach(() => { if (originalScrollIntoView) { @@ -87,6 +104,42 @@ describe("first-range roadmap interaction regressions", () => { ); }); + it("does not carry focus across local projects that reuse the analyzed-song identity", () => { + installScrollRecorder(); + const analyzedSong = { + ...createDemoRehearsalSong(), + id: "analyzed-song" + }; + const replacementAnalysis = { + ...createDemoRehearsalSong(), + id: "analyzed-song" + }; + + const renderedWorkspace = render( + + ); + fireEvent.click( + screen.getByRole("button", { + name: "Find verse for Bass Guitar on the roadmap" + }) + ); + expect(screen.getByTestId("section-roadmap-section-verse-1")).toHaveAttribute( + "aria-current", + "location" + ); + + renderedWorkspace.rerender( + + ); + + expect(screen.getByTestId("section-roadmap-section-verse-1")).not.toHaveAttribute( + "aria-current" + ); + expect(screen.getByTestId("section-roadmap-role-verse-1-bass-guitar")).not.toHaveAttribute( + "aria-current" + ); + }); + it("fails closed when the target section identifier is duplicated elsewhere", () => { const rehearsalSong = createDemoRehearsalSong(); rehearsalSong.sections.push({ From 54d8ddfc0d76e5c70ae07d3b4a72663d762f99c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 02:10:35 +0900 Subject: [PATCH 5/5] fix(workspace): scope roadmap focus to local project identity --- .../src/features/workspace/Workspace.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 10a1e4504..a84a7aa0f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -19,7 +19,7 @@ interface WorkspaceProps { /** Request identity for a user-initiated section-roadmap focus action. */ type RoadmapFocusRequest = { - rehearsalSongId: string; + rehearsalSourceIdentity: string; sectionId: string; roleId: string; requestSequence: number; @@ -131,6 +131,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const [activeRole, setActiveRole] = useState(null); const [roadmapFocusRequest, setRoadmapFocusRequest] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const parsedSourceBootstrap = useMemo( + () => safeProjectBootstrapSummary(sourceBootstrap), + [sourceBootstrap] + ); + const rehearsalSourceIdentity = parsedSourceBootstrap?.projectId ?? song.id; // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -180,11 +185,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp }) : null; const focusedSectionId = - roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.sectionId : null; + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.sectionId + : null; const focusedRoleId = - roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.roleId : null; + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.roleId + : null; const focusRequestSequence = - roadmapFocusRequest?.rehearsalSongId === song.id ? roadmapFocusRequest.requestSequence : 0; + roadmapFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity + ? roadmapFocusRequest.requestSequence + : 0; /** Request the first-range roadmap cell on every activation, even when it is already highlighted. */ const handleFindFirstRangeRoadmap = () => { @@ -192,11 +203,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp return; } setRoadmapFocusRequest((previousFocusRequest) => ({ - rehearsalSongId: song.id, + rehearsalSourceIdentity, sectionId: firstRangeBoard.sectionId, roleId: firstRangeBoard.roleId, requestSequence: - previousFocusRequest?.rehearsalSongId === song.id + previousFocusRequest?.rehearsalSourceIdentity === rehearsalSourceIdentity ? previousFocusRequest.requestSequence + 1 : 1 })); @@ -278,7 +289,6 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp /** Documented. */ const handleExportHandoff = () => { - const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap); const json = generateMetadataHandoffJson(song, { sourceBootstrap: parsedSourceBootstrap, workspaceId: song.id,