From 98daf7756a536f514be17375b7a3353b33e2bc0a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:29:14 +0000 Subject: [PATCH 01/10] feat(workspace): name tonight's first blocked assignment on the map Name the earliest owned blocked job so the room can unblock it before the next run. Open moves to the uniquely named rendered map section. Todo, in-progress, and ready assignments, comments, and approvals stay guidance-only. Distinct from #996/#997/#998. --- AGENTS.md | 1 + ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstBlockedCallout.particle.test.tsx | 61 ++++ ...irstBlockedCallout.reduced-motion.test.tsx | 64 ++++ .../workspace/FirstBlockedCallout.test.tsx | 233 ++++++++++++ .../workspace/FirstBlockedCallout.tsx | 143 ++++++++ .../src/features/workspace/Workspace.test.tsx | 36 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstBlocked.inherited-metadata.test.ts | 107 ++++++ .../features/workspace/firstBlocked.test.ts | 309 ++++++++++++++++ .../src/features/workspace/firstBlocked.ts | 340 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 19 +- apps/desktop/src/i18n/index.ts | 38 +- apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- apps/desktop/vite.config.ts | 4 +- docs/design-system/component-contract.md | 1 + ...reduced-motion-first-blocked-navigation.md | 14 + 20 files changed, 1397 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstBlockedCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBlockedCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBlockedCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstBlocked.test.ts create mode 100644 apps/desktop/src/features/workspace/firstBlocked.ts create mode 100644 docs/doctoring/reduced-motion-first-blocked-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..5a72b463e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Name tonight's first blocked assignment with the assignee, the uniquely named labeled section and time, and the holding part when it is corroborated, so the next action is obvious. Do not invent a blocked job from todo, in-progress, or ready assignments, comments, or approvals. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..5cc92b38b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,11 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-23 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. -- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- The mounted workspace copy for tonight's first blocked assignment must name the assignee, the uniquely named labeled section and time, and the holding part when it is corroborated so the next action is obvious. Open moves to the matching rendered map section. Do not invent a blocked job from todo, in-progress, or ready assignments, comments, or approvals. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..928ca3a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first blocked assignment in the mounted rehearsal workspace so the room can unblock the stuck job before the next run; the Open action moves to the uniquely named rendered map section, while todo, in-progress, ready assignments, comments, or approvals remain guidance-only instead of becoming navigation authority. - 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 82c2c704a..1dbf56dbc 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). `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 mounted workspace names tonight's first blocked assignment and opens the uniquely named rendered map section. Do not invent a blocked job from todo, in-progress, or ready assignments, comments, or approvals. `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/FirstBlockedCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.particle.test.tsx new file mode 100644 index 000000000..a131cedc6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.particle.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; + +describe("FirstBlockedCallout Korean owner copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending owner names particle-safe before and after the blocked action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "verse-blocked"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: "assign-keys-blocked", + assignee: "미나", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "verse-blocked", + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("미나님이 0:10 벌스에서 Keyboard 1 Right Hand 진행이 막혀 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/미나이/)).toBeNull(); + expect(screen.queryByText(/미나가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 벌스 막힘 위치 열기" })); + + expect(screen.getByText("0:10 벌스 막힘을 먼저 풀어 주세요.")).toBeTruthy(); + expect(screen.queryByText(/미나과/)).toBeNull(); + expect(screen.queryByText(/미나를/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..d169cf2b7 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.reduced-motion.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; + +describe("FirstBlockedCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "verse-blocked"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "verse-blocked", + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx new file mode 100644 index 000000000..e00fa0b59 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx @@ -0,0 +1,233 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; + +function songWithBlocked() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "verse-blocked"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "verse-blocked", + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + return song; +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstBlockedCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No blocked job yet. Stay on tonight's map until a part is stuck.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithBlocked(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open verse blocker at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same blocked signature", () => { + const firstSong = songWithBlocked(); + const nextSong = songWithBlocked(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Unblock the verse job at 0:10 before the next run./)).toBeNull(); + + grid.remove(); + }); + + it("names the first blocked job as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.")).toBeTruthy(); + expect(screen.getByText("Wait on the in-ear mix before the verse color pass.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open verse blocker at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + + expect(screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Unblock the verse job at 0:10 before the next run./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithBlocked(); + song.sections[0]!.id = "analysis section / duplicate"; + song.collaboration!.assignments[0]!.sectionId = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the first blocked job changes or returns later", () => { + const initialSong = songWithBlocked(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + + const nextSong = songWithBlocked(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable blocked job guidance-only", () => { + const song = createDemoRehearsalSong(); + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No blocked job yet. Stay on tonight's map until a part is stuck.") + ).toBeTruthy(); + }); + + it("names a band-wide blocked job when the holding role is missing", () => { + const song = songWithBlocked(); + delete song.collaboration!.assignments[0]!.roleId; + const { grid, scrollIntoView } = appendSongStructureTarget(); + render(); + expect(screen.getByText("Keys is blocked in the verse at 0:10.")).toBeTruthy(); + const action = screen.getByRole("button", { name: "Open the blocked job at 0:10" }); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Unblock the job at 0:10 before the next run.")).toBeTruthy(); + grid.remove(); + }); + + it("localizes the blocked form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithBlocked(); + + render(); + + expect(screen.getByText("Keys님이 0:10 벌스에서 Keyboard 1 Right Hand 진행이 막혀 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse의/)).toBeNull(); + }); + + it("renders the owned blocked summary as a text node instead of template syntax", () => { + const song = songWithBlocked(); + song.collaboration!.assignments[0]!.summary = "Lock {assignee} at {at} in {section}"; + render(); + expect(screen.getByText("Lock {assignee} at {at} in {section}")).toBeTruthy(); + expect(screen.queryByText("Lock Keys at 0:10 in verse")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx new file mode 100644 index 000000000..506e8e27b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatBlockedTime, resolveFirstBlockedAssignment } from "./firstBlocked"; + +/** Props for the first-blocked rehearsal callout. */ +export interface FirstBlockedCalloutProps { + song: RehearsalSong; +} + +type BlockedCopyValues = Readonly>; + +type OpenedBlocked = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + assignmentId: string; + atSeconds: number; +}>; + +/** Interpolate blocked-job placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatBlockedCopy(template: string, values: BlockedCopyValues): string { + return template.replace(/\{(role|section|at|assignee)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof BlockedCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredBlockedScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first blocked job and open the matching rendered map section. */ +export function FirstBlockedCallout({ song }: FirstBlockedCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const blocked = resolveFirstBlockedAssignment(song); + const blockedSectionIndex = + blocked && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(blocked.section) + : -1; + const [openedBlocked, setOpenedBlocked] = useState(null); + + useEffect(() => { + setOpenedBlocked(null); + }, [ + songIdentity, + blockedSectionIndex, + blocked?.section.id, + blocked?.holdingRole?.id, + blocked?.assignment.id, + blocked?.atSeconds + ]); + + if (!blocked) { + return ( + + ); + } + + const opened = + openedBlocked !== null && + openedBlocked.songIdentity === songIdentity && + openedBlocked.sectionId === blocked.section.id && + openedBlocked.sectionIndex === blockedSectionIndex && + openedBlocked.holdingRoleId === (blocked.holdingRole?.id ?? null) && + openedBlocked.assignmentId === blocked.assignment.id && + openedBlocked.atSeconds === blocked.atSeconds; + const at = formatBlockedTime(blocked.atSeconds); + const copyValues: BlockedCopyValues = { + role: blocked.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, blocked.section.label), + at, + assignee: blocked.assignment.assignee + }; + const hasRole = blocked.holdingRole !== null; + const actionLabel = formatBlockedCopy( + t(hasRole ? "firstBlockedOpenAction" : "firstBlockedOpenActionBand"), + copyValues + ); + const body = formatBlockedCopy(t(hasRole ? "firstBlockedBody" : "firstBlockedBodyBand"), copyValues); + const armed = formatBlockedCopy(t(hasRole ? "firstBlockedArmed" : "firstBlockedArmedBand"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..922855e29 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,40 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first blocked assignment on the mounted map", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.collaboration = { + ...song.collaboration!, + assignments: [ + ...song.collaboration!.assignments, + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "verse-1", + roleId: "keys-right", + status: "blocked" + } + ] + }; + render(); + + expect(screen.getByText("Tonight's first blocked job")).toBeTruthy(); + expect( + screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.") + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open verse blocker at 0:10" })).toBeTruthy(); + }); + + it("keeps the demo map honest when no assignment is blocked", () => { + setNavigatorLanguage("en-US"); + render(); + + expect( + screen.getByText("No blocked job yet. Stay on tonight's map until a part is stuck.") + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /blocker/i })).toBeNull(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..4bd8a4bc0 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, index) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts new file mode 100644 index 000000000..3bf4960bd --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts @@ -0,0 +1,107 @@ +import { createDemoRehearsalSong, type RehearsalAssignment } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBlockedAssignment } from "./firstBlocked"; + +function songWithBlocked() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "block-own"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "block-own", + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + return { song, section }; +} + +describe("resolveFirstBlockedAssignment inherited metadata", () => { + it("rejects a song or collaboration whose required metadata is inherited", () => { + const { song } = songWithBlocked(); + const inheritedSong = Object.create({ + collaboration: song.collaboration, + sections: song.sections + }) as typeof song; + expect(resolveFirstBlockedAssignment(inheritedSong)).toBeNull(); + + const inheritedCollaboration = Object.create(song.collaboration!) as NonNullable< + typeof song.collaboration + >; + song.collaboration = inheritedCollaboration; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects inherited assignment fields", () => { + const { song } = songWithBlocked(); + song.collaboration!.assignments = [ + Object.create(song.collaboration!.assignments[0]!) as (typeof song.collaboration.assignments)[number] + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects inherited timing fields when a unique section is required", () => { + const { song, section } = songWithBlocked(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song } = songWithBlocked(); + Object.defineProperty(song.collaboration!.assignments[0]!, "summary", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile summary getter"); + } + }); + + expect(() => resolveFirstBlockedAssignment(song)).not.toThrow(); + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not treat own accessors as stable blocked identity authority", () => { + const { song } = songWithBlocked(); + Object.defineProperty(song.collaboration!.assignments[0]!, "id", { + configurable: true, + enumerable: true, + get() { + return "assign-keys-blocked"; + } + }); + + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not let inherited section metadata host the blocked job", () => { + const { song, section } = songWithBlocked(); + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithBlocked(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects sparse assignment arrays", () => { + const { song } = songWithBlocked(); + const sparse: RehearsalAssignment[] = []; + sparse[1] = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = sparse; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBlocked.test.ts b/apps/desktop/src/features/workspace/firstBlocked.test.ts new file mode 100644 index 000000000..e446b3883 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import { + createDemoRehearsalSong, + type RehearsalAssignment, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { formatBlockedTime, resolveFirstBlockedAssignment } from "./firstBlocked"; + +function withBlocked( + overrides: { + assignmentId?: string; + summary?: string; + assignee?: string; + status?: RehearsalAssignment["status"]; + sectionId?: string; + start?: number; + end?: number; + label?: SectionFormLabel; + roleId?: string | undefined; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.sectionId ?? "verse-blocked"; + section.label = overrides.label ?? "verse"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: overrides.assignmentId ?? "assign-keys-blocked", + assignee: overrides.assignee ?? "Keys", + summary: overrides.summary ?? "Wait on the in-ear mix before the verse color pass.", + sectionId: section.id, + roleId: overrides.roleId === undefined ? "keys-right" : overrides.roleId, + status: overrides.status ?? "blocked" + } + ], + comments: [], + approvals: [] + }; + if (overrides.roleId === undefined) { + return song; + } + if (overrides.roleId === "") { + delete song.collaboration.assignments[0]!.roleId; + } + return song; +} + +describe("resolveFirstBlockedAssignment", () => { + it("does not invent a blocked job from the demo in-progress or todo assignments", () => { + expect(resolveFirstBlockedAssignment(createDemoRehearsalSong())).toBeNull(); + }); + + it("picks the earliest owned blocked assignment and its unique section", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked()); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.assignment.assignee).toBe("Keys"); + expect(resolved?.hint).toBe("Wait on the in-ear mix before the verse color pass."); + expect(resolved?.section.id).toBe("verse-blocked"); + expect(resolved?.holdingRole?.id).toBe("keys-right"); + expect(resolved?.atSeconds).toBe(10); + expect(formatBlockedTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatBlockedTime(Number.NaN)).toBe("0:00"); + expect(formatBlockedTime(-4)).toBe("0:00"); + }); + + it("does not invent a blocked job from todo, in_progress, ready, comments, or approvals", () => { + const song = withBlocked({ status: "todo" }); + song.collaboration!.assignments = [ + { + id: "assign-bass-entrance", + assignee: "Rhythm Section", + summary: "Lock the bass entrance against the pickup so the chorus lift lands together.", + sectionId: song.sections[0]!.id, + roleId: "bass-guitar", + status: "in_progress" + }, + { + id: "assign-vocal-ready", + assignee: "Lead Vocal", + summary: "Verse key decision is ready for the first pass.", + sectionId: song.sections[0]!.id, + roleId: "lead-vocal", + status: "ready" + }, + song.collaboration!.assignments[0]! + ]; + song.collaboration!.comments = [ + { + id: "comment-keys-color", + author: "MD", + body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "open" + } + ]; + song.collaboration!.approvals = [ + { + id: "approval-harmony-pass", + scope: "Verse harmony pass", + owner: "MD", + status: "pending" + } + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace summary as a named blocked job", () => { + expect(resolveFirstBlockedAssignment(withBlocked({ summary: "" }))).toBeNull(); + expect(resolveFirstBlockedAssignment(withBlocked({ summary: " \n\t " }))).toBeNull(); + }); + + it("prefers the earlier of two blocked jobs", () => { + const song = withBlocked({ + assignmentId: "assign-late", + start: 40, + end: 56, + label: "chorus", + summary: "Chorus lift is waiting on the in-ear mix." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.label = "verse"; + earlier.timeRange = { start: 8, end: 24 }; + song.sections = [song.sections[0]!, earlier]; + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + id: "assign-early", + assignee: "Rhythm Section", + summary: "Bass entrance is waiting on the click.", + sectionId: "verse-early", + roleId: "bass-guitar", + status: "blocked" + } + ]; + + const resolved = resolveFirstBlockedAssignment(song); + expect(resolved?.assignment.id).toBe("assign-early"); + expect(resolved?.atSeconds).toBe(8); + }); + + it("keeps the blocked job band-wide when the holding role is missing", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked({ roleId: "" })); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.section.id).toBe("verse-blocked"); + }); + + it("does not invent a section from a missing or duplicated section pointer", () => { + const missing = withBlocked(); + missing.collaboration!.assignments[0]!.sectionId = "missing-section"; + expect(resolveFirstBlockedAssignment(missing)).toBeNull(); + + const song = withBlocked(); + const duplicate = structuredClone(song.sections[0]!); + duplicate.timeRange = { start: 40, end: 56 }; + song.sections = [song.sections[0]!, duplicate]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("bounds a long owned summary without splitting a surrogate pair", () => { + const song = withBlocked({ + summary: `${"a".repeat(179)}\uD83D\uDE80trailing` + }); + expect(resolveFirstBlockedAssignment(song)?.hint).toBe(`${"a".repeat(179)}\uD83D\uDE80`); + }); + + it("ties equal blocked times with a stable id", () => { + const song = withBlocked({ assignmentId: "z-late", summary: "Later blocked mix." }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + id: "a-early", + assignee: "MD", + summary: "Earlier blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "blocked" + } + ]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("a-early"); + }); + + it("orders equal-time blocked jobs in both id directions", () => { + const song = withBlocked({ assignmentId: "m-mid", start: 12, end: 28, summary: "Middle blocked mix." }); + song.collaboration!.assignments = [ + { + id: "z-late", + assignee: "MD", + summary: "Later blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "blocked" + }, + song.collaboration!.assignments[0]!, + { + id: "a-early", + assignee: "Rhythm Section", + summary: "Earlier blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "bass-guitar", + status: "blocked" + } + ]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("a-early"); + }); + + it("keeps the blocked job band-wide when role identities are duplicated", () => { + const song = withBlocked(); + const role = song.sections[0]!.roles.find((item) => item.id === "keys-right") ?? song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const resolved = resolveFirstBlockedAssignment(song); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("keeps the blocked job band-wide when the holding role is whitespace", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked({ roleId: " " })); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("keeps the blocked job band-wide when role or graph collections are missing", () => { + const song = withBlocked(); + delete (song.sections[0] as { roles?: unknown }).roles; + expect(resolveFirstBlockedAssignment(song)?.holdingRole).toBeNull(); + + const graphMissing = withBlocked(); + delete (graphMissing.sections[0] as { partGraph?: unknown }).partGraph; + expect(resolveFirstBlockedAssignment(graphMissing)?.holdingRole).toBeNull(); + }); + + it("does not invent a blocked job when the unique section has no owned time range", () => { + const song = withBlocked(); + delete (song.sections[0] as { timeRange?: unknown }).timeRange; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not invent a blocked job when sections are missing, sparse, or not an array", () => { + const missing = withBlocked(); + delete (missing as { sections?: unknown }).sections; + expect(resolveFirstBlockedAssignment(missing)).toBeNull(); + + const sparse = withBlocked(); + const sparseSections: typeof sparse.sections = []; + sparseSections[1] = sparse.sections[0]!; + sparse.sections = sparseSections; + expect(resolveFirstBlockedAssignment(sparse)).toBeNull(); + + const masquerade = withBlocked(); + masquerade.sections = { length: 1, 0: masquerade.sections[0]! } as unknown as typeof masquerade.sections; + expect(resolveFirstBlockedAssignment(masquerade)).toBeNull(); + }); + + it("does not invent a blocked job when a dense array reports a non-integer length", () => { + const song = withBlocked(); + const target = song.sections[0]!; + song.sections = new Proxy([target], { + get(record, property, receiver) { + if (property === "length") { + return 1.5; + } + return Reflect.get(record, property, receiver); + } + }) as typeof song.sections; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("skips non-object assignments while keeping a later owned blocked job", () => { + const song = withBlocked(); + const valid = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = [42 as never, valid]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("assign-keys-blocked"); + }); + + it("does not invent a blocked job from duplicated assignment identities", () => { + const song = withBlocked({ assignmentId: "shared-id" }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + structuredClone(song.collaboration!.assignments[0]!), + structuredClone(song.collaboration!.assignments[0]!) + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("contains throws from untrusted runtime property access", () => { + const song = withBlocked(); + const hostile = new Proxy(song, { + get(target, prop, receiver) { + if (prop === "collaboration") { + throw new Error("hostile collaboration"); + } + return Reflect.get(target, prop, receiver); + } + }); + expect(resolveFirstBlockedAssignment(hostile as typeof song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBlocked.ts b/apps/desktop/src/features/workspace/firstBlocked.ts new file mode 100644 index 000000000..531b8b217 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.ts @@ -0,0 +1,340 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalAssignment, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const ACTIONABLE_STATUS_RANK = { blocked: 0 } as const; +const MAX_ASSIGNMENT_SUMMARY_CHARACTERS = 180; + +/** Tonight's first blocked job: the earliest owned stuck assignment and the part that carries it. */ +export type FirstBlockedAssignment = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + assignment: RehearsalAssignment; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative blocked-job time as m:ss for rehearsal copy. */ +export function formatBlockedTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded owned assignment summary, or null when the field cannot be shown. */ +function ownedAssignmentHint(assignment: RehearsalAssignment): string | null { + if (!hasOwnData(assignment, "summary") || typeof assignment.summary !== "string") { + return null; + } + const hint = assignment.summary.trim(); + if (hint.length === 0) { + return null; + } + return truncateCodePoints(hint, MAX_ASSIGNMENT_SUMMARY_CHARACTERS); +} + +/** Return true when the assignment owns identity, assignee, blocked status, and a section pointer. */ +function isBlockedAssignment(assignment: RehearsalAssignment): boolean { + return ( + isRuntimeObject(assignment) && + hasOwnData(assignment, "id") && + typeof assignment.id === "string" && + assignment.id.trim().length > 0 && + hasOwnData(assignment, "assignee") && + typeof assignment.assignee === "string" && + assignment.assignee.trim().length > 0 && + hasOwnData(assignment, "sectionId") && + typeof assignment.sectionId === "string" && + assignment.sectionId.trim().length > 0 && + hasOwnData(assignment, "status") && + Object.prototype.hasOwnProperty.call(ACTIONABLE_STATUS_RANK, assignment.status) && + ownedAssignmentHint(assignment) !== null + ); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Map unique owned ids onto their records; duplicated ids are not authority. */ +function uniqueOwnedById( + items: T[], + readId: (item: T) => string | null +): Map { + const unique = new Map(); + const repeated = new Set(); + for (const item of items) { + if (!isRuntimeObject(item)) { + continue; + } + const id = readId(item); + if (id === null || repeated.has(id)) { + continue; + } + if (unique.has(id)) { + unique.delete(id); + repeated.add(id); + continue; + } + unique.set(id, item); + } + return unique; +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the corroborated holding part, or null when the blocked job is band-wide. */ +function resolveHoldingRole( + section: RehearsalSection, + assignment: RehearsalAssignment +): RehearsalRole | null { + if (!hasOwnData(assignment, "roleId") || typeof assignment.roleId !== "string") { + return null; + } + const roleId = assignment.roleId.trim(); + if (roleId.length === 0) { + return null; + } + return rankedActiveRoles(section).find((role) => role.id === roleId) ?? null; +} + +/** Return owned sections that can host a blocked assignment. */ +function uniqueReadySections(song: RehearsalSong): Map { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return new Map(); + } + + return uniqueOwnedById( + song.sections.filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ), + (section) => (hasOwnData(section, "id") && typeof section.id === "string" ? section.id : null) + ); +} + +/** Resolve a blocked assignment after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstBlockedAssignment(song: RehearsalSong): FirstBlockedAssignment | null { + if ( + !isRuntimeObject(song) || + !hasOwnData(song, "collaboration") || + !isRuntimeObject(song.collaboration) || + !hasOwnData(song.collaboration, "assignments") || + !isDenseRuntimeArray(song.collaboration.assignments) + ) { + return null; + } + + const sections = uniqueReadySections(song); + if (sections.size === 0) { + return null; + } + + const uniqueAssignments = uniqueOwnedById( + song.collaboration.assignments.filter((assignment) => isBlockedAssignment(assignment)), + (assignment) => + hasOwnData(assignment, "id") && typeof assignment.id === "string" ? assignment.id : null + ); + + const candidates = [...uniqueAssignments.values()] + .flatMap((assignment) => { + const section = sections.get(assignment.sectionId); + const hint = ownedAssignmentHint(assignment); + if (!section || hint === null) { + return []; + } + return [ + { + section, + holdingRole: resolveHoldingRole(section, assignment), + assignment, + atSeconds: section.timeRange.start, + hint + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.assignment.id, right.assignment.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first blocked job, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstBlockedAssignment(song: RehearsalSong): FirstBlockedAssignment | null { + try { + return resolveSafeFirstBlockedAssignment(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..0d0aa7078 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,21 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes Korean section form labels without treating inherited keys as labels", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-blocked next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstBlockedOpenAction")).toBe("{at} {section} 막힘 위치 열기"); + expect(t("firstBlockedBody")).toBe("{assignee}님이 {at} {section}에서 {role} 진행이 막혀 있습니다."); + expect(t("firstBlockedArmed")).toBe("{at} {section} 막힘을 먼저 풀어 주세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..28d9bfd95 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,14 +12,47 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } -/** Documented. */ +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + +/** Detect Korean from the runtime navigator, otherwise English. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { return "ko"; diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..2afc520d7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstBlockedLabel": "Tonight's first blocked job", + "firstBlockedOpenAction": "Open {section} blocker at {at}", + "firstBlockedOpenActionBand": "Open the blocked job at {at}", + "firstBlockedBody": "{assignee} is blocked on {role} in the {section} at {at}.", + "firstBlockedBodyBand": "{assignee} is blocked in the {section} at {at}.", + "firstBlockedArmed": "Unblock the {section} job at {at} before the next run.", + "firstBlockedArmedBand": "Unblock the job at {at} before the next run.", + "firstBlockedUnavailable": "No blocked job yet. Stay on tonight's map until a part is stuck." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..32853ce45 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstBlockedLabel": "오늘 첫 막힘", + "firstBlockedOpenAction": "{at} {section} 막힘 위치 열기", + "firstBlockedOpenActionBand": "{at} 막힘 위치 열기", + "firstBlockedBody": "{assignee}님이 {at} {section}에서 {role} 진행이 막혀 있습니다.", + "firstBlockedBodyBand": "{assignee}님이 {at} {section}에서 막혀 있습니다.", + "firstBlockedArmed": "{at} {section} 막힘을 먼저 풀어 주세요.", + "firstBlockedArmedBand": "{at} 막힘을 먼저 풀어 주세요.", + "firstBlockedUnavailable": "아직 막힌 일이 없습니다. 막힐 때까지 오늘 지도에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..a8651d5b5 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstBlocked.ts", + "src/features/workspace/FirstBlockedCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..fd748c2e7 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -35,6 +35,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | +| First Blocked Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstBlockedCallout.tsx` | Name the assignee, the uniquely named labeled section and time, and the holding part when it is corroborated. Do not invent a blocked job from todo, in-progress, ready assignments, comments, approvals, empty/whitespace summaries, or inherited runtime metadata. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-assignment (#996), first-open-comment (#997), and first-pending-approval (#998). | ## Prop And State Mapping diff --git a/docs/doctoring/reduced-motion-first-blocked-navigation.md b/docs/doctoring/reduced-motion-first-blocked-navigation.md new file mode 100644 index 000000000..10728cc06 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-blocked-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-blocked navigation + +Workspace map navigation for tonight's first blocked assignment follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstBlockedCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Blocked-job resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, collaboration, assignment identity/assignee/summary/status/sectionId/roleId, section, time-range, role, and part-graph tokens are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: blocked resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned assignment summary is interpolated once as copy and is never rescanned as template syntax. Todo, in-progress, ready assignments, comments, and approvals cannot invent a blocked job. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the assignment summary is bounded to 180 Unicode code points. +- Test points: inherited song/collaboration/assignment/section/timing metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From 73d0e8a95c18fc76d8c0eae35a18f750f41af32c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:39:07 -0700 Subject: [PATCH 02/10] test(workspace): type blocked assignment fixture explicitly --- .../features/workspace/firstBlocked.inherited-metadata.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts index 3bf4960bd..319d88498 100644 --- a/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts @@ -45,7 +45,7 @@ describe("resolveFirstBlockedAssignment inherited metadata", () => { it("rejects inherited assignment fields", () => { const { song } = songWithBlocked(); song.collaboration!.assignments = [ - Object.create(song.collaboration!.assignments[0]!) as (typeof song.collaboration.assignments)[number] + Object.create(song.collaboration!.assignments[0]!) as RehearsalAssignment ]; expect(resolveFirstBlockedAssignment(song)).toBeNull(); }); From d61791e76ed2c70e18189b7553f87d37429f835b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:39:57 -0700 Subject: [PATCH 03/10] docs(changelog): clarify blocked-callout exclusions --- CHANGELOG.md | 44 +++++++------------------------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928ca3a0d..01161e9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first blocked assignment in the mounted rehearsal workspace so the room can unblock the stuck job before the next run; the Open action moves to the uniquely named rendered map section, while todo, in-progress, ready assignments, comments, or approvals remain guidance-only instead of becoming navigation authority. +- Name tonight's first blocked assignment in the mounted rehearsal workspace so the room can unblock the stuck job before the next run; the Open action moves to the uniquely named rendered map section, while todo, in-progress, ready assignments, comments, and approvals are excluded from this callout and cannot become navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -23,47 +23,17 @@ ### Fixed -- Stabilized YouTube import fallback behavior in browser and desktop dev paths. -- Guarded OSSF Scorecard execution so release-branch pushes skip unsupported non-default branch runs cleanly. - -## [0.1.1] - 2026-04-28 - -### Added +- Published release assets through a tag-driven draft release flow so immutable GitHub Releases include desktop installers, checksums, SBOM, and supplemental inventory before publication. +- Added a supply-chain regression guard that rejects post-publication release asset uploads. -- Implemented rehearsal workspace design (Issue #107) -- Add capo and tuning detection heuristics (Issue #103) -- Add bandit security scan workflow +## [0.1.1] - 2026-04-29 ### Fixed -- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g -- Resolve npm audit vulnerabilities -- Fix ruff import sorting and formatting errors -- Add missing docstrings to tests -- Fix test configuration and typing issues +- Restored the desktop package lockfile release version after the initial package-version bump missed the lockfile metadata. -## [0.1.0] - 2026-03-27 +## [0.1.0] - 2026-04-29 ### Added -- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) -- Issue #40: Enforced 100% Python docstring and test coverage -- Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- Initial packaged desktop release with local audio analysis, rehearsal workspace, project persistence, and export flows. From d4a86e072474f1d833eda17d667f2b8e14197bd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:40:26 -0700 Subject: [PATCH 04/10] fix(changelog): restore history while clarifying blocked callout --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01161e9f6..47fd1cf51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,17 +23,47 @@ ### Fixed -- Published release assets through a tag-driven draft release flow so immutable GitHub Releases include desktop installers, checksums, SBOM, and supplemental inventory before publication. -- Added a supply-chain regression guard that rejects post-publication release asset uploads. +- Stabilized YouTube import fallback behavior in browser and desktop dev paths. +- Guarded OSSF Scorecard execution so release-branch pushes skip unsupported non-default branch runs cleanly. + +## [0.1.1] - 2026-04-28 + +### Added -## [0.1.1] - 2026-04-29 +- Implemented rehearsal workspace design (Issue #107) +- Add capo and tuning detection heuristics (Issue #103) +- Add bandit security scan workflow ### Fixed -- Restored the desktop package lockfile release version after the initial package-version bump missed the lockfile metadata. +- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g +- Resolve npm audit vulnerabilities +- Fix ruff import sorting and formatting errors +- Add missing docstrings to tests +- Fix test configuration and typing issues -## [0.1.0] - 2026-04-29 +## [0.1.0] - 2026-03-27 ### Added -- Initial packaged desktop release with local audio analysis, rehearsal workspace, project persistence, and export flows. +- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #40: Enforced 100% Python docstring and test coverage +- Issue #32: Implemented local analysis orchestration and secure IPC boundaries +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 7195f213f339530cad1f5da5d73653f87f4fadfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:43:11 -0700 Subject: [PATCH 05/10] docs(design): classify blocked callout as feature-local pattern --- docs/design-system/component-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index fd748c2e7..c3edaae4c 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -35,7 +35,6 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | -| First Blocked Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstBlockedCallout.tsx` | Name the assignee, the uniquely named labeled section and time, and the holding part when it is corroborated. Do not invent a blocked job from todo, in-progress, ready assignments, comments, approvals, empty/whitespace summaries, or inherited runtime metadata. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-assignment (#996), first-open-comment (#997), and first-pending-approval (#998). | ## Prop And State Mapping @@ -95,6 +94,7 @@ These Figma patterns are valid visual guidance but are not yet extracted as stan | Status Pill | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when assignment/comment/approval status UI is reused. | | Song Structure Timeline | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when timeline editing or playback controls are added. | | Export Action Group | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when export controls are reused outside the workspace header. | +| First Blocked Callout | `apps/desktop/src/features/workspace/FirstBlockedCallout.tsx` | Feature-local workspace next-action pattern until a traceable Figma node exists; extract or promote to Canonical Components when the pattern is reused and design authority is published. | ## PR Review Rules From a23d78a3fecf08e5ec4529e1526eec5af065c43b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:51:09 -0700 Subject: [PATCH 06/10] docs(architecture): restore brand source-of-truth guidance --- ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5cc92b38b..fc43565b8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ Last updated: 2026-08-23 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. - The mounted workspace copy for tonight's first blocked assignment must name the assignee, the uniquely named labeled section and time, and the holding part when it is corroborated so the next action is obvious. Open moves to the matching rendered map section. Do not invent a blocked job from todo, in-progress, or ready assignments, comments, or approvals. ## Security source From 565188318680d43b5c31c39ee14261db97d1b4a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:51:45 -0700 Subject: [PATCH 07/10] test(workspace): preserve blocked state across immutable edits --- .../workspace/FirstBlockedCallout.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx index e00fa0b59..b323b6c8c 100644 --- a/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.test.tsx @@ -99,6 +99,22 @@ describe("FirstBlockedCallout", () => { grid.remove(); }); + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithBlocked(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open verse blocker at 0:10" })); + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText(/Unblock the verse job at 0:10 before the next run./)).toBeTruthy(); + expect(screen.queryByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + it("names the first blocked job as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); From 446f630040434b7ef0901418282eedc0f0f46a09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:52:23 -0700 Subject: [PATCH 08/10] fix(workspace): stabilize blocked callout identity --- .../workspace/FirstBlockedCallout.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx index 506e8e27b..5f751bbb2 100644 --- a/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -24,6 +24,25 @@ type OpenedBlocked = Readonly<{ atSeconds: number; }>; +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableBlockedSongIdentity(song: RehearsalSong): unknown { + if (song === null || typeof song !== "object" || Array.isArray(song)) { + return song; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(song, "id"); + } catch { + return song; + } + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ? descriptor.value + : song; +} + /** Interpolate blocked-job placeholders once so rehearsal data is never rescanned as template syntax. */ function formatBlockedCopy(template: string, values: BlockedCopyValues): string { return template.replace(/\{(role|section|at|assignee)\}/g, (placeholder) => { @@ -42,11 +61,11 @@ function preferredBlockedScrollBehavior(): ScrollBehavior { /** Name tonight's first blocked job and open the matching rendered map section. */ export function FirstBlockedCallout({ song }: FirstBlockedCalloutProps) { - const locale = detectPreferredLocale(); - const t = createTranslator(locale); - const songIdentity: unknown = song; + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + const songIdentity = stableBlockedSongIdentity(song); const runtimeSong = song as unknown as Partial | null; - const blocked = resolveFirstBlockedAssignment(song); + const blocked = useMemo(() => resolveFirstBlockedAssignment(song), [song]); const blockedSectionIndex = blocked && Array.isArray(runtimeSong?.sections) ? runtimeSong.sections.indexOf(blocked.section) From 2679150eb7b5650d09574aa82313c73b00a13c87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:19:22 -0700 Subject: [PATCH 09/10] test(workspace): scope blocked-job navigation --- ...rstBlockedCallout.workspace-scope.test.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..44da1ae01 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; + +function blockedSong(id: string) { + const song = createDemoRehearsalSong(); + song.id = id; + const section = structuredClone(song.sections[0]!); + section.id = `${id}-blocked-section`; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: `${id}-blocked-assignment`, + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: section.id, + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + return song; +} + +describe("FirstBlockedCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { name: "Open verse blocker at 0:10" }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); From 6c6b3e75616e36f7b2997b92ea13bdca7628b523 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:19:46 -0700 Subject: [PATCH 10/10] fix(workspace): scope blocked-job navigation --- .../workspace/FirstBlockedCallout.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx index 5f751bbb2..6a1f7e82a 100644 --- a/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.tsx @@ -59,6 +59,22 @@ function preferredBlockedScrollBehavior(): ScrollBehavior { : "smooth"; } +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveBlockedRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + /** Name tonight's first blocked job and open the matching rendered map section. */ export function FirstBlockedCallout({ song }: FirstBlockedCalloutProps) { const locale = useMemo(() => detectPreferredLocale(), []); @@ -131,8 +147,8 @@ export function FirstBlockedCallout({ song }: FirstBlockedCalloutProps) {