From 54b75a265ef3b7265461abf0601b6daf52d67b5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:27:30 +0000 Subject: [PATCH] feat(workspace): name tonight's first assignment on the map Name the in-progress rehearsal job so the room can keep the bass entrance moving instead of rediscovering the collaboration list. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstAssignmentCallout.particle.test.tsx | 68 ++++ ...tAssignmentCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstAssignmentCallout.test.tsx | 217 +++++++++++ .../workspace/FirstAssignmentCallout.tsx | 149 ++++++++ .../src/features/workspace/Workspace.test.tsx | 26 +- .../src/features/workspace/Workspace.tsx | 11 +- ...firstAssignment.inherited-metadata.test.ts | 113 ++++++ .../workspace/firstAssignment.test.ts | 296 +++++++++++++++ .../src/features/workspace/firstAssignment.ts | 351 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 47 ++- apps/desktop/src/i18n/index.ts | 36 +- 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 + ...uced-motion-first-assignment-navigation.md | 14 + 20 files changed, 1392 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstAssignmentCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstAssignmentCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstAssignmentCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstAssignmentCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstAssignment.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstAssignment.test.ts create mode 100644 apps/desktop/src/features/workspace/firstAssignment.ts create mode 100644 docs/doctoring/reduced-motion-first-assignment-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..a4cbc74c4 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 assignment with the assignee, the corroborated holding part when an active role matches, the owned assignment summary, the labeled section, and the time so the next action is obvious. - 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..c7eee6515 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ Last updated: 2026-03-11 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first assignment must name the assignee, the corroborated holding part when an active graph node matches, the owned assignment summary, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a job from comments, approvals, rehearsal priority, setup, cue, groove, simplification, overlap, range copy, ready, or blocked status. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..48f677ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first assignment in the mounted rehearsal workspace so the room can continue the job already in motion; the Open action moves to the matching rendered map section, while comments, approvals, ready, blocked, or inherited runtime metadata 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..4d8f74711 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 assignment and opens the matching rendered map section. Do not invent a job from comments, approvals, rehearsal priority, setup, cue, groove, simplification, overlap, range copy, ready, or blocked status. `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/FirstAssignmentCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstAssignmentCallout.particle.test.tsx new file mode 100644 index 000000000..6d5cd69ed --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstAssignmentCallout.particle.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstAssignmentCallout } from "./FirstAssignmentCallout"; + +describe("FirstAssignmentCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the assignment action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high" + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep assignments local for now.", + assignments: [ + { + id: "assign-piano", + assignee: "리듬팀", + summary: "픽업 다음 2박에 들어가세요.", + sectionId: seed.id, + roleId: "piano", + status: "in_progress" + } + ], + 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 벌스에서 피아노 파트 담당은 리듬팀입니다.")).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/FirstAssignmentCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstAssignmentCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..3d2da92f2 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstAssignmentCallout.reduced-motion.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstAssignmentCallout } from "./FirstAssignmentCallout"; + +describe("FirstAssignmentCallout 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 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 Bass Guitar assignment at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstAssignmentCallout.test.tsx b/apps/desktop/src/features/workspace/FirstAssignmentCallout.test.tsx new file mode 100644 index 000000000..3dea518ce --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstAssignmentCallout.test.tsx @@ -0,0 +1,217 @@ +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 { FirstAssignmentCallout } from "./FirstAssignmentCallout"; + +function songWithAssignment() { + return createDemoRehearsalSong(); +} + +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("FirstAssignmentCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No assignment yet. Stay on tonight's map until a part has a job.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithAssignment(); + 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 Bass Guitar assignment at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same assignment signature", () => { + const firstSong = songWithAssignment(); + const nextSong = songWithAssignment(); + 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 Bass Guitar assignment at 0:10" })); + expect(screen.getByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Rhythm Section holds Bass Guitar in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).toBeNull(); + + grid.remove(); + }); + + it("names the first assignment as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Lock the bass entrance against the pickup so the chorus lift lands together.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar assignment at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).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 Bass Guitar assignment at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).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 Bass Guitar assignment at 0:10" })); + + expect(screen.getByText("Rhythm Section holds Bass Guitar in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithAssignment(); + 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 Bass Guitar assignment 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 Bass Guitar assignment 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 assignment changes or returns later", () => { + const initialSong = songWithAssignment(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar assignment at 0:10" })); + expect(screen.getByText(/Keep the Bass Guitar assignment moving at 0:10. Lock it in together./)).toBeTruthy(); + + const nextSong = songWithAssignment(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Rhythm Section holds Bass Guitar in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable assignment guidance-only", () => { + const song = songWithAssignment(); + song.collaboration = undefined; + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No assignment yet. Stay on tonight's map until a part has a job.") + ).toBeTruthy(); + }); + + it("names a band-wide assignment when no part carries it", () => { + const song = songWithAssignment(); + for (const node of song.sections[0]!.partGraph) { + node.is_active = false; + } + const { grid, scrollIntoView } = appendSongStructureTarget(); + render(); + const action = screen.getByRole("button", { name: "Open the first assignment at 0:10" }); + expect(screen.getByText("Rhythm Section holds the verse assignment at 0:10.")).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Keep the assignment moving at 0:10. Lock it in together.")).toBeTruthy(); + grid.remove(); + }); + + it("localizes the assignment form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithAssignment(); + song.sections[0]!.roles[0]!.name = "베이스 기타"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트 담당은 Rhythm Section입니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned assignment summary as a text node instead of template syntax", () => { + const song = songWithAssignment(); + song.collaboration!.assignments[0]!.summary = "Lock {role} at {at} with {assignee}"; + song.collaboration!.assignments[1]!.status = "ready"; + render(); + expect(screen.getByText("Lock {role} at {at} with {assignee}")).toBeTruthy(); + expect(screen.queryByText("Lock Bass Guitar at 0:10 with Rhythm Section")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstAssignmentCallout.tsx b/apps/desktop/src/features/workspace/FirstAssignmentCallout.tsx new file mode 100644 index 000000000..8f489c0c3 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstAssignmentCallout.tsx @@ -0,0 +1,149 @@ +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 { formatAssignmentTime, resolveFirstAssignment } from "./firstAssignment"; + +/** Props for the first-assignment rehearsal callout. */ +export interface FirstAssignmentCalloutProps { + song: RehearsalSong; +} + +type AssignmentCopyValues = Readonly>; + +type OpenedAssignment = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + assignmentId: string; + atSeconds: number; +}>; + +/** Interpolate assignment placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatAssignmentCopy(template: string, values: AssignmentCopyValues): string { + return template.replace(/\{(role|section|at|assignee)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof AssignmentCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredAssignmentScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first assignment and open the matching rendered map section. */ +export function FirstAssignmentCallout({ song }: FirstAssignmentCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const assignment = resolveFirstAssignment(song); + const assignmentSectionIndex = + assignment && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(assignment.section) + : -1; + const [openedAssignment, setOpenedAssignment] = useState(null); + + useEffect(() => { + setOpenedAssignment(null); + }, [ + songIdentity, + assignmentSectionIndex, + assignment?.section.id, + assignment?.holdingRole?.id, + assignment?.assignment.id, + assignment?.atSeconds + ]); + + if (!assignment) { + return ( + + ); + } + + const opened = + openedAssignment !== null && + openedAssignment.songIdentity === songIdentity && + openedAssignment.sectionId === assignment.section.id && + openedAssignment.sectionIndex === assignmentSectionIndex && + openedAssignment.holdingRoleId === (assignment.holdingRole?.id ?? null) && + openedAssignment.assignmentId === assignment.assignment.id && + openedAssignment.atSeconds === assignment.atSeconds; + const at = formatAssignmentTime(assignment.atSeconds); + const copyValues: AssignmentCopyValues = { + role: assignment.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, assignment.section.label), + at, + assignee: assignment.assignment.assignee + }; + const hasRole = assignment.holdingRole !== null; + const actionLabel = formatAssignmentCopy( + t(hasRole ? "firstAssignmentOpenAction" : "firstAssignmentOpenActionBand"), + copyValues + ); + const body = formatAssignmentCopy( + t(hasRole ? "firstAssignmentBody" : "firstAssignmentBodyBand"), + copyValues + ); + const armed = formatAssignmentCopy( + t(hasRole ? "firstAssignmentArmed" : "firstAssignmentArmedBand"), + copyValues + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..632f712f1 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -136,7 +136,7 @@ describe("Workspace", () => { expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); expect(screen.getByText(/whole step lower/i)).toBeTruthy(); - expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); + expect(screen.getAllByText(/Lock the bass entrance against the pickup/i).length).toBeGreaterThan(0); expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); @@ -270,4 +270,28 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first assignment as workspace navigation", () => { + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText("Lock the bass entrance against the pickup so the chorus lift lands together.").length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar assignment at 0:10" + }); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Keep the Bass Guitar assignment moving at 0:10. Lock it in together.")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..df95ee7ed 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 { FirstAssignmentCallout } from "./FirstAssignmentCallout"; 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, sectionIndex) => ( +

{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/firstAssignment.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstAssignment.inherited-metadata.test.ts new file mode 100644 index 000000000..9c1815d61 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstAssignment.inherited-metadata.test.ts @@ -0,0 +1,113 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstAssignment } from "./firstAssignment"; + +function songWithAssignment() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "assign-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar" + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep assignments local for now.", + assignments: [ + { + id: "assign-bass", + assignee: "Rhythm Section", + summary: "Lock the bass entrance against the pickup so the chorus lift lands together.", + sectionId: "assign-own", + roleId: "bass-guitar", + status: "in_progress" + } + ], + comments: [], + approvals: [] + }; + return { song, section }; +} + +describe("resolveFirstAssignment inherited metadata", () => { + it("rejects a song or collaboration whose required metadata is inherited", () => { + const { song } = songWithAssignment(); + const inheritedSong = Object.create({ collaboration: song.collaboration, sections: song.sections }) as typeof song; + expect(resolveFirstAssignment(inheritedSong)).toBeNull(); + + const inheritedCollaboration = Object.create(song.collaboration!) as NonNullable; + song.collaboration = inheritedCollaboration; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("rejects inherited assignment fields", () => { + const { song } = songWithAssignment(); + song.collaboration!.assignments = [ + Object.create(song.collaboration!.assignments[0]!) as (typeof song.collaboration.assignments)[number] + ]; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithAssignment(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song } = songWithAssignment(); + Object.defineProperty(song.collaboration!.assignments[0]!, "summary", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile summary getter"); + } + }); + + expect(() => resolveFirstAssignment(song)).not.toThrow(); + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("does not treat own accessors as stable assignment identity authority", () => { + const { song } = songWithAssignment(); + Object.defineProperty(song.collaboration!.assignments[0]!, "id", { + configurable: true, + enumerable: true, + get() { + return "assign-bass"; + } + }); + + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("does not let inherited section metadata host the assignment", () => { + const { song, section } = songWithAssignment(); + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithAssignment(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstAssignment(song); + expect(resolved?.section.id).toBe("assign-own"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Lock the bass entrance against the pickup so the chorus lift lands together."); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithAssignment(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstAssignment(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstAssignment.test.ts b/apps/desktop/src/features/workspace/firstAssignment.test.ts new file mode 100644 index 000000000..e3e9e9cba --- /dev/null +++ b/apps/desktop/src/features/workspace/firstAssignment.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong, type RehearsalAssignment } from "@bandscope/shared-types"; +import { formatAssignmentTime, resolveFirstAssignment } from "./firstAssignment"; + +function withAssignment( + overrides: { + assignmentId?: string; + sectionId?: string; + start?: number; + end?: number; + summary?: string; + assignee?: string; + status?: RehearsalAssignment["status"]; + roleId?: string | undefined; + roleName?: string; + isActive?: boolean; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag"; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + const roleId = overrides.roleId === undefined ? "bass-guitar" : overrides.roleId; + section.id = overrides.sectionId ?? "verse-assign"; + section.label = overrides.label ?? "verse"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId || "bass-guitar", + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: roleId || "bass-guitar", + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep assignments local for now.", + assignments: [ + { + id: overrides.assignmentId ?? "assign-bass", + assignee: overrides.assignee ?? "Rhythm Section", + summary: overrides.summary ?? "Lock the bass entrance against the pickup so the chorus lift lands together.", + sectionId: section.id, + ...(overrides.roleId === undefined || overrides.roleId ? { roleId: roleId || "bass-guitar" } : {}), + status: overrides.status ?? "in_progress" + } + ], + comments: [], + approvals: [] + }; + return song; +} + +describe("resolveFirstAssignment", () => { + it("picks the demo song's in-progress assignment and the part that carries it", () => { + const resolved = resolveFirstAssignment(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.assignment.id).toBe("assign-bass-entrance"); + expect(resolved?.assignment.assignee).toBe("Rhythm Section"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Lock the bass entrance against the pickup so the chorus lift lands together."); + expect(formatAssignmentTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatAssignmentTime(Number.NaN)).toBe("0:00"); + expect(formatAssignmentTime(-4)).toBe("0:00"); + }); + + it("does not invent an assignment from comments, approvals, priority, setup, cue, groove, or empty summary", () => { + const song = withAssignment({ summary: " " }); + 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: "bass-guitar", + status: "open" + } + ]; + song.collaboration!.approvals = [ + { + id: "approval-harmony-pass", + scope: "Verse harmony pass", + owner: "MD", + status: "pending" + } + ]; + song.sections[0]!.roles[0]!.rehearsalPriority = "high"; + song.sections[0]!.roles[0]!.setupNote = "Keep the attack short so the verse breathes."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup before the downbeat." }; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace summary as a named assignment", () => { + expect(resolveFirstAssignment(withAssignment({ summary: "" }))).toBeNull(); + expect(resolveFirstAssignment(withAssignment({ summary: " \n\t " }))).toBeNull(); + }); + + it("skips ready and blocked jobs instead of treating them as tonight's next action", () => { + expect(resolveFirstAssignment(withAssignment({ status: "ready" }))).toBeNull(); + expect(resolveFirstAssignment(withAssignment({ status: "blocked" }))).toBeNull(); + }); + + it("prefers an in-progress assignment over an earlier todo", () => { + const song = withAssignment({ + assignmentId: "assign-late", + start: 40, + end: 56, + status: "in_progress", + summary: "Keep the chorus entrance locked." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.timeRange = { start: 8, end: 24 }; + song.sections = [song.sections[0]!, earlier]; + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + id: "assign-early-todo", + assignee: "Lead Vocal", + summary: "Confirm the verse key before the first pass.", + sectionId: "verse-early", + roleId: "bass-guitar", + status: "todo" + } + ]; + + const resolved = resolveFirstAssignment(song); + expect(resolved?.assignment.id).toBe("assign-late"); + expect(resolved?.atSeconds).toBe(40); + }); + + it("prefers the earlier of two in-progress assignments", () => { + const song = withAssignment({ assignmentId: "assign-late", start: 40, end: 56, summary: "Late lock." }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + 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: "Come in on the and of four.", + sectionId: "verse-early", + roleId: "bass-guitar", + status: "in_progress" + } + ]; + + const resolved = resolveFirstAssignment(song); + expect(resolved?.assignment.id).toBe("assign-early"); + expect(resolved?.atSeconds).toBe(8); + expect(resolved?.hint).toBe("Come in on the and of four."); + }); + + it("breaks same-time assignment ties with locale-independent id ordering", () => { + const song = withAssignment({ assignmentId: "ä-assign", start: 10, end: 26 }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + ...song.collaboration!.assignments[0]!, + id: "z-assign", + summary: "ASCII assignment" + } + ]; + + expect(resolveFirstAssignment(song)?.assignment.id).toBe("z-assign"); + }); + + it("keeps a band-wide assignment when no active ranked role carries it", () => { + const song = withAssignment({ isActive: false }); + const resolved = resolveFirstAssignment(song); + expect(resolved?.section.id).toBe("verse-assign"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Lock the bass entrance against the pickup so the chorus lift lands together."); + }); + + it("keeps a band-wide assignment when the job has no role pointer", () => { + const song = withAssignment({ roleId: "" }); + delete song.collaboration!.assignments[0]!.roleId; + const resolved = resolveFirstAssignment(song); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.assignment.assignee).toBe("Rhythm Section"); + }); + + it("skips an assignment whose rehearsal window is unbounded", () => { + expect(resolveFirstAssignment(withAssignment({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips an assignment whose end precedes its start", () => { + expect(resolveFirstAssignment(withAssignment({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length assignment window", () => { + expect(resolveFirstAssignment(withAssignment({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips an assignment whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstAssignment( + withAssignment({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstAssignment(null as never)).toBeNull(); + }); + + it("returns null when the runtime assignment collection is sparse", () => { + const song = withAssignment(); + const sparseAssignments: typeof song.collaboration.assignments = new Array(2); + sparseAssignments[1] = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = sparseAssignments; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withAssignment(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("keeps the assignment band-wide when role identities are duplicated", () => { + const song = withAssignment(); + const role = 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 = resolveFirstAssignment(song); + expect(resolved?.section.id).toBe("verse-assign"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("bounds the assignment summary to 180 Unicode code points", () => { + const song = withAssignment({ summary: `${"a".repeat(200)}` }); + const resolved = resolveFirstAssignment(song); + expect(resolved?.hint.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the summary boundary", () => { + const song = withAssignment({ summary: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstAssignment(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); + + it("skips non-object assignments while keeping a later owned job", () => { + const song = withAssignment(); + const valid = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = [42 as never, valid]; + const resolved = resolveFirstAssignment(song); + expect(resolved?.assignment.id).toBe("assign-bass"); + expect(resolved?.hint).toBe("Lock the bass entrance against the pickup so the chorus lift lands together."); + }); + + it("keeps a deterministic winner when two named assignments share time and id", () => { + const song = withAssignment({ assignmentId: "shared-id", start: 10, end: 26 }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + structuredClone(song.collaboration!.assignments[0]!) + ]; + expect(resolveFirstAssignment(song)).toBeNull(); + }); + + it("contains throws from untrusted runtime property access", () => { + const song = withAssignment(); + const hostile = new Proxy(song, { + get(target, prop, receiver) { + if (prop === "collaboration") { + throw new Error("hostile collaboration"); + } + return Reflect.get(target, prop, receiver); + } + }); + expect(resolveFirstAssignment(hostile as typeof song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstAssignment.ts b/apps/desktop/src/features/workspace/firstAssignment.ts new file mode 100644 index 000000000..7a8ca0156 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstAssignment.ts @@ -0,0 +1,351 @@ +import { + MAX_SECTION_TIME_SECONDS, + type CollaborationAssignmentStatus, + 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 = { in_progress: 0, todo: 1 } as const; +const MAX_ASSIGNMENT_SUMMARY_CHARACTERS = 180; + +/** Tonight's first named assignment: the earliest owned actionable job and the part that carries it. */ +export type FirstAssignment = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + assignment: RehearsalAssignment; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative assignment time as m:ss for rehearsal copy. */ +export function formatAssignmentTime(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, status, and a section pointer. */ +function isActionableAssignment(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 assignment 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 an 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 an assignment after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstAssignment(song: RehearsalSong): FirstAssignment | 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) => isActionableAssignment(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) => { + const statusDelta = + ACTIONABLE_STATUS_RANK[left.assignment.status as Exclude] - + ACTIONABLE_STATUS_RANK[right.assignment.status as Exclude]; + if (statusDelta !== 0) { + return statusDelta; + } + 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 named assignment, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstAssignment(song: RehearsalSong): FirstAssignment | null { + try { + return resolveSafeFirstAssignment(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..4193d8414 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,49 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect([ + translateSectionFormLabel("ko", "intro"), + translateSectionFormLabel("ko", "verse"), + translateSectionFormLabel("ko", "pre-chorus"), + translateSectionFormLabel("ko", "chorus"), + translateSectionFormLabel("ko", "bridge"), + translateSectionFormLabel("ko", "outro"), + translateSectionFormLabel("ko", "tag"), + translateSectionFormLabel("ko", "pickup"), + translateSectionFormLabel("ko", "stop"), + translateSectionFormLabel("ko", "handoff") + ]).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-assignment next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstAssignmentOpenAction")).toBe("{at} {role} 과제 위치 열기"); + expect(t("firstAssignmentBody")).toBe("{at} {section}에서 {role} 파트 담당은 {assignee}입니다."); + expect(t("firstAssignmentArmed")).toBe("{at}에서 {role} 파트 과제를 이어서 하세요. 함께 잠그세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 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,13 +12,46 @@ 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]; }; } +/** 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); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..d248cb17e 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", + "firstAssignmentLabel": "Tonight's first assignment", + "firstAssignmentOpenAction": "Open {role} assignment at {at}", + "firstAssignmentOpenActionBand": "Open the first assignment at {at}", + "firstAssignmentBody": "{assignee} holds {role} in the {section} at {at}.", + "firstAssignmentBodyBand": "{assignee} holds the {section} assignment at {at}.", + "firstAssignmentArmed": "Keep the {role} assignment moving at {at}. Lock it in together.", + "firstAssignmentArmedBand": "Keep the assignment moving at {at}. Lock it in together.", + "firstAssignmentUnavailable": "No assignment yet. Stay on tonight's map until a part has a job." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..72aeca464 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": "진척도 증가", + "firstAssignmentLabel": "오늘 첫 과제", + "firstAssignmentOpenAction": "{at} {role} 과제 위치 열기", + "firstAssignmentOpenActionBand": "{at} 첫 과제 위치 열기", + "firstAssignmentBody": "{at} {section}에서 {role} 파트 담당은 {assignee}입니다.", + "firstAssignmentBodyBand": "{at} {section} 담당은 {assignee}입니다.", + "firstAssignmentArmed": "{at}에서 {role} 파트 과제를 이어서 하세요. 함께 잠그세요.", + "firstAssignmentArmedBand": "{at}에서 과제를 이어서 하세요. 함께 잠그세요.", + "firstAssignmentUnavailable": "아직 과제가 없습니다. 파트가 할 일이 생길 때까지 오늘 지도에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..6dea7840b 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/firstAssignment.ts", + "src/features/workspace/FirstAssignmentCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..585526a38 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Assignment Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstAssignmentCallout.tsx` | Name the assignee, the corroborated holding part when an active graph node matches the owned `roleId`, the owned assignment summary, the labeled section, and the time. Prefer an `in_progress` job over `todo`. Do not invent a job from comments, approvals, rehearsal priority, setup, cue, groove, simplification, overlap, range copy, ready, blocked, or empty/whitespace summaries. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-count (#995), first-lyric (#913), first-transition (#994), parts-to-lock (#874), export-and-priority (#900), and first-part-from-ready-board (#901). | | 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()`. | diff --git a/docs/doctoring/reduced-motion-first-assignment-navigation.md b/docs/doctoring/reduced-motion-first-assignment-navigation.md new file mode 100644 index 000000000..10007da2b --- /dev/null +++ b/docs/doctoring/reduced-motion-first-assignment-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-assignment navigation + +Workspace map navigation for tonight's first assignment follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstAssignmentCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Assignment resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, collaboration, assignment identity/assignee/summary/status/section/role pointers, section, time-range, role, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: assignment 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 rendered as a text node and is never rescanned as template syntax. Comments, approvals, rehearsal priority, setup, cue, groove, simplification, overlap, range copy, ready, and blocked jobs cannot invent an assignment. +- 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/role/graph metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.