From f640e5776b4f7d8227ff1ae8ff5bd1719c5bf40d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 02:19:10 +0000 Subject: [PATCH 01/11] feat(workspace): name tonight's first leftover return on the map Tell the leftover part to come back after a leftover sit-out, or count that leftover part in from the top of the named return section. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 94 +++++ .../src/features/workspace/Workspace.tsx | 27 ++ .../firstLeftoverReturn.selected-role.test.ts | 74 ++++ .../workspace/firstLeftoverReturn.test.ts | 350 ++++++++++++++++++ .../features/workspace/firstLeftoverReturn.ts | 248 +++++++++++++ apps/desktop/src/i18n/index.test.ts | 1 + apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + docs/design-system/component-contract.md | 1 + docs/doctoring/first-leftover-return.md | 18 + 14 files changed, 825 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverReturn.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverReturn.ts create mode 100644 docs/doctoring/first-leftover-return.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..17fc9234e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, leftover-return cues after a leftover sit-out, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..db91bd3bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and tonight's first leftover return after a leftover sit-out - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..1842d2150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first leftover return on the ready rehearsal map and tell the leftover part to come back, or the band to count that leftover part in. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..f13c18e09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and tonight's first leftover return after a leftover sit-out. `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/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..36eeb58cf 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,100 @@ describe("Workspace", () => { ); }); + it("names tonight's first leftover return after a leftover sit-out", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || node.role_id === "keys-right" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + } + } + ]; + + render(); + + const callout = screen.getByTestId("first-leftover-return"); + expect(callout).toHaveTextContent("Tonight's first leftover return"); + expect(callout).toHaveTextContent( + "Keyboard 1 Right Hand comes back at bridge after staying out of chorus. Count Keyboard 1 Right Hand in from the top of bridge." + ); + }); + + it("tells the leftover part to come back at the named return", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => + node.role_id === "bass-guitar" || node.role_id === "keys-right" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: verse.timeRange.end, end: verse.timeRange.end + 20 }, + partGraph: verse.partGraph.map((node) => + node.role_id === "keys-right" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + } + } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Keyboard 1 Right Hand" })); + + expect(screen.getByTestId("first-leftover-return")).toHaveTextContent( + "Keyboard 1 Right Hand comes back at bridge after staying out of chorus. Play from the top of bridge." + ); + }); + + it("asks the player to confirm the leftover return when every part stays active", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-leftover-return")).toHaveTextContent( + "Tonight's first leftover return still needs a named leftover part that comes back after a leftover sit-out. Confirm where that leftover part returns before the first section." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..74115a4db 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { firstLeftoverReturn } from "./firstLeftoverReturn"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,24 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const namedLeftoverReturn = useMemo( + () => firstLeftoverReturn(song, activeRole), + [activeRole, song] + ); + const firstLeftoverReturnCopy = namedLeftoverReturn + ? fillRangeCopy( + t( + activeRole && activeRole === namedLeftoverReturn.leftoverRoleId + ? "workspaceFirstLeftoverReturnComeBack" + : "workspaceFirstLeftoverReturnNamed" + ), + { + leftoverRoleName: namedLeftoverReturn.leftoverRoleName, + sectionLabel: namedLeftoverReturn.sectionLabel, + leftoverSectionLabel: namedLeftoverReturn.leftoverSectionLabel + } + ) + : t("workspaceFirstLeftoverReturnMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -309,6 +328,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+

{t("workspaceFirstLeftoverReturnTitle")}

+

{firstLeftoverReturnCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts new file mode 100644 index 000000000..b3f46315f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts @@ -0,0 +1,74 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstLeftoverReturn } from "./firstLeftoverReturn"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: RehearsalSong["sections"][number]["label"], + start: number, + inactiveRoleIds: readonly string[] +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + return { + ...template, + id, + label, + timeRange: { start, end: start + 20 }, + partGraph: template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })) + }; +} + +describe("firstLeftoverReturn selected-role search", () => { + it("keeps searching after the selected part newly drops out during an earlier leftover sit-out", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "lead-vocal", + "keys-right" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; + + expect(firstLeftoverReturn(song, "lead-vocal")).toEqual({ + sectionLabel: "outro", + leftoverSectionLabel: "bridge", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("does not tell a new dropout to come back from an earlier leftover sit-out", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "lead-vocal", + "keys-right" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverReturn(song, "lead-vocal")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.test.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.test.ts new file mode 100644 index 000000000..b5626f0e1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.test.ts @@ -0,0 +1,350 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstLeftoverReturn } from "./firstLeftoverReturn"; + +function sectionWithInactiveRoles( + template: RehearsalSong["sections"][number], + id: string, + label: string, + start: number, + inactiveRoleIds: readonly string[], + activeOnlyRoles = false +): RehearsalSong["sections"][number] { + const inactive = new Set(inactiveRoleIds); + const partGraph = template.partGraph.map((node) => ({ + ...node, + is_active: !inactive.has(node.role_id) + })); + return { + ...template, + id, + label: label as RehearsalSong["sections"][number]["label"], + timeRange: { start, end: start + 20 }, + partGraph, + roles: activeOnlyRoles + ? template.roles.filter((role) => !inactive.has(role.id)) + : template.roles + }; +} + +function leftoverThenReturn( + returningRoleId = "bass-guitar", + leftoverRoleId = "keys-right" +): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + return { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + returningRoleId, + leftoverRoleId + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [leftoverRoleId]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; +} + +describe("firstLeftoverReturn", () => { + it("returns null on the demo song where every graph node is active", () => { + expect(firstLeftoverReturn(createDemoRehearsalSong())).toBeNull(); + }); + + it("names the leftover return after a leftover sit-out", () => { + expect(firstLeftoverReturn(leftoverThenReturn())).toEqual({ + sectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("uses song-wide role names when inactive analysis roles are omitted from section roles", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "opening-1", "opening", 0, []), + sectionWithInactiveRoles( + template, + "bridge-1", + "bridge", + 20, + ["bass-guitar", "keys-right"], + true + ), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 40, ["keys-right"], true), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, [], true) + ] + }; + + expect(firstLeftoverReturn(song)).toEqual({ + sectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "bridge", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("treats repeated form labels as distinct timeline sections", () => { + const song = leftoverThenReturn(); + song.sections[2] = { + ...song.sections[2]!, + label: song.sections[1]!.label + }; + + expect(firstLeftoverReturn(song)).toEqual({ + sectionLabel: "chorus", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("skips a continued leftover sit-out until the leftover part is own-property active", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "tag-1", "tag", 40, ["keys-right"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; + + expect(firstLeftoverReturn(song)).toEqual({ + sectionLabel: "outro", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("fails closed when the leftover part never returns", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]) + ] + }; + + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("keeps the selected leftover part and returning part on tonight's first leftover return", () => { + const song = leftoverThenReturn(); + expect(firstLeftoverReturn(song, "keys-right")).toEqual({ + sectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + expect(firstLeftoverReturn(song, "bass-guitar")).toEqual({ + sectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "keys-right", + leftoverRoleName: "Keyboard 1 Right Hand" + }); + expect(firstLeftoverReturn(song, "missing-role")).toBeNull(); + }); + + it("does not treat a leftover sit-out without a later return as a leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + expect( + firstLeftoverReturn({ + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]) + ] + }) + ).toBeNull(); + }); + + it("does not treat a come-in without a leftover sit-out as a leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []) + ] + }; + + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("does not treat a tutti after a full original return as a leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("does not treat a new dropout after every original sit-out returns as a leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["bass-guitar", "keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, []), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]) + ] + }; + + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("does not treat a continued sit-out with nobody returning as a leftover return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, ["keys-right"]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("ignores inherited is_active evidence", () => { + const song = leftoverThenReturn(); + const inherited = Object.create({ + is_active: false, + role_id: "keys-right" + }) as RehearsalSong["sections"][number]["partGraph"][number]; + song.sections[0] = { + ...song.sections[0]!, + partGraph: [inherited, ...song.sections[0]!.partGraph] + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("does not treat a missing is_active flag as leftover-return evidence", () => { + const song = leftoverThenReturn(); + song.sections[1] = { + ...song.sections[1]!, + partGraph: song.sections[1]!.partGraph.map((node) => { + if (node.role_id !== "keys-right") { + return node; + } + const rest: Record = { + role_id: node.role_id, + handoff_to: node.handoff_to, + handoff_from: node.handoff_from + }; + return rest as RehearsalSong["sections"][number]["partGraph"][number]; + }) + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("fails closed on contradictory duplicate graph identities", () => { + const song = leftoverThenReturn(); + const section = song.sections[2]!; + const keysNode = section.partGraph.find((node) => node.role_id === "keys-right")!; + const withoutKeys = section.partGraph.filter((node) => node.role_id !== "keys-right"); + song.sections[2] = { + ...section, + partGraph: [...withoutKeys, { ...keysNode, is_active: true }, { ...keysNode, is_active: false }] + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("skips blank leftover-return labels until a named return exists", () => { + const song = leftoverThenReturn(); + song.sections[2] = { + ...song.sections[2]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("fails closed when the later section has no named leftover role", () => { + const song = leftoverThenReturn(); + song.sections[0] = { + ...song.sections[0]!, + roles: song.sections[0]!.roles.map((role) => ({ ...role, name: " " })) + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("fails closed when a later section has no named graph", () => { + const song = leftoverThenReturn(); + song.sections[2] = { + ...song.sections[2]!, + partGraph: [] + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect(firstLeftoverReturn(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); + + it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => { + const song = leftoverThenReturn(); + song.sections[0] = { + ...song.sections[0]!, + partGraph: [ + { role_id: " ", is_active: false, handoff_to: [], handoff_from: [] }, + { role_id: "ghost", is_active: false, handoff_to: [], handoff_from: [] }, + { + role_id: "keys-right", + is_active: "no" as unknown as boolean, + handoff_to: [], + handoff_from: [] + }, + ...song.sections[0]!.partGraph + ] + }; + expect(firstLeftoverReturn(song)).toBeNull(); + }); +}); + +describe("leftover-return copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy( + "{leftoverRoleName} comes back at {sectionLabel} after staying out of {leftoverSectionLabel}.", + { + leftoverRoleName: "Keyboard 1 Right Hand {sectionLabel}", + sectionLabel: "bridge", + leftoverSectionLabel: "chorus" + } + ) + ).toBe( + "Keyboard 1 Right Hand {sectionLabel} comes back at bridge after staying out of chorus." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts new file mode 100644 index 000000000..ac201c65d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts @@ -0,0 +1,248 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named leftover return after a leftover sit-out. */ +export type FirstLeftoverReturn = { + sectionLabel: string; + leftoverSectionLabel: string; + fromSectionLabel: string; + leftoverRoleId: string; + leftoverRoleName: string; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Admit an own-property boolean `is_active` flag. Inherited evidence is isolated. */ +function ownActiveFlag(value: Record): boolean | null { + if (!Object.prototype.hasOwnProperty.call(value, "is_active")) { + return null; + } + if (value.is_active === true) { + return true; + } + if (value.is_active === false) { + return false; + } + return null; +} + +type NamedRoleCatalog = Map; + +/** + * Build trustworthy role identity evidence across the whole song. + * + * Production analysis emits active-only section `roles` while keeping inactive + * identities in `partGraph`. The song-wide catalog therefore lets a leftover + * part keep its trustworthy display name across the sit-out and the return. + */ +function namedSongRoles(songValue: Record): NamedRoleCatalog | null { + if (!Array.isArray(songValue.sections)) { + return null; + } + + const namedRoles: NamedRoleCatalog = new Map(); + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return null; + } + + const sectionRoleIds = new Set(); + for (const roleValue of sectionValue.roles) { + if ( + !isRuntimeObject(roleValue) || + !Object.prototype.hasOwnProperty.call(roleValue, "id") || + !Object.prototype.hasOwnProperty.call(roleValue, "name") + ) { + return null; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName || sectionRoleIds.has(roleId)) { + return null; + } + sectionRoleIds.add(roleId); + + const knownName = namedRoles.get(roleId); + if (knownName && knownName !== roleName) { + return null; + } + namedRoles.set(roleId, roleName); + } + } + + return namedRoles.size > 0 ? namedRoles : null; +} + +type NamedGraphNode = { + roleId: string; + active: boolean; +}; + +/** + * Collect one complete, unique activity record for every song-wide named role. + * + * Missing, unknown, duplicate, inherited, or non-boolean graph evidence fails + * closed so a leftover part cannot be both sitting out and returning in the + * same section. + */ +function namedGraphNodes( + sectionValue: Record, + namedRoles: NamedRoleCatalog +): NamedGraphNode[] | null { + if (!Array.isArray(sectionValue.partGraph)) { + return null; + } + + const nodes: NamedGraphNode[] = []; + const seenRoleIds = new Set(); + for (const nodeValue of sectionValue.partGraph) { + if ( + !isRuntimeObject(nodeValue) || + !Object.prototype.hasOwnProperty.call(nodeValue, "role_id") + ) { + return null; + } + + const roleId = meaningfulRangeText(nodeValue.role_id); + if (!roleId || !namedRoles.has(roleId) || seenRoleIds.has(roleId)) { + return null; + } + + const active = ownActiveFlag(nodeValue); + if (active === null) { + return null; + } + + seenRoleIds.add(roleId); + nodes.push({ roleId, active }); + } + + return seenRoleIds.size === namedRoles.size ? nodes : null; +} + +type PendingLeftover = { + leftoverSectionLabel: string; + fromSectionLabel: string; + leftoverRoleId: string; + leftoverRoleName: string; +}; + +/** + * Pick the first leftover return a player should honor after a leftover sit-out. + * + * A leftover sit-out is the first later named section where at least one member + * of the current reduced cohort has returned and at least one remains out. The + * leftover return is the first named section after that leftover sit-out where + * the leftover part is own-property active. A tutti, come-in, continued sit-out, + * or a new dropout after a full original return is not a leftover return. + * + * Inherited/missing activity, incomplete or contradictory graphs, unnamed + * roles, and malformed runtime data fail closed. When a role is selected, a + * leftover return is shown only after a leftover sit-out that includes that + * named part, so a silent new dropout is never told to come back. + */ +export function firstLeftoverReturn( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstLeftoverReturn | null { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return null; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles || (activeRole && !namedRoles.has(activeRole))) { + return null; + } + + let reducedFrom: string | null = null; + let sittingOutIds: Set | null = null; + let pending: PendingLeftover | null = null; + + for (const sectionValue of song.sections) { + if (!isRuntimeObject(sectionValue)) { + return null; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + const nodes = namedGraphNodes(sectionValue, namedRoles); + if (!nodes) { + return null; + } + + if (pending) { + const leftoverNode = nodes.find((node) => node.roleId === pending.leftoverRoleId); + if (!leftoverNode) { + return null; + } + if (leftoverNode.active) { + return { + sectionLabel, + leftoverSectionLabel: pending.leftoverSectionLabel, + fromSectionLabel: pending.fromSectionLabel, + leftoverRoleId: pending.leftoverRoleId, + leftoverRoleName: pending.leftoverRoleName + }; + } + continue; + } + + const sittingOut = nodes.filter((node) => node.active === false); + if (!sittingOutIds || !reducedFrom) { + if (sittingOut.length === 0) { + continue; + } + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } + + const baselineIds = sittingOutIds; + const returning = nodes.filter( + (node) => node.active === true && baselineIds.has(node.roleId) + ); + const leftovers = sittingOut.filter((node) => baselineIds.has(node.roleId)); + + if (returning.length > 0 && leftovers.length > 0) { + let leftover = leftovers[0]!; + if (activeRole) { + const activeRoleNode = nodes.find((node) => node.roleId === activeRole); + if (!activeRoleNode) { + return null; + } + if (!activeRoleNode.active) { + const selectedLeftover = leftovers.find((node) => node.roleId === activeRole); + if (!selectedLeftover) { + continue; + } + leftover = selectedLeftover; + } + } + pending = { + leftoverSectionLabel: sectionLabel, + fromSectionLabel: reducedFrom, + leftoverRoleId: leftover.roleId, + leftoverRoleName: namedRoles.get(leftover.roleId)! + }; + continue; + } + + if (returning.length === baselineIds.size && leftovers.length === 0) { + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } + } + + return null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..8329fec1f 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -60,6 +60,7 @@ describe("i18n", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); + expect(t("workspaceFirstLeftoverReturnTitle")).toBe("오늘 먼저 남을 쉬는 자리의 복귀"); }); it("falls back to English when a Korean translation is missing", () => { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..e3e22a8b2 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstLeftoverReturnTitle": "Tonight's first leftover return", + "workspaceFirstLeftoverReturnNamed": "{leftoverRoleName} comes back at {sectionLabel} after staying out of {leftoverSectionLabel}. Count {leftoverRoleName} in from the top of {sectionLabel}.", + "workspaceFirstLeftoverReturnComeBack": "{leftoverRoleName} comes back at {sectionLabel} after staying out of {leftoverSectionLabel}. Play from the top of {sectionLabel}.", + "workspaceFirstLeftoverReturnMissing": "Tonight's first leftover return still needs a named leftover part that comes back after a leftover sit-out. Confirm where that leftover part returns before the first section.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..e0fc92275 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstLeftoverReturnTitle": "오늘 먼저 남을 쉬는 자리의 복귀", + "workspaceFirstLeftoverReturnNamed": "{leftoverRoleName}은 {leftoverSectionLabel}에서 쉰 뒤 {sectionLabel}에서 돌아옵니다. {sectionLabel} 처음부터 {leftoverRoleName}을 들어오게 하세요.", + "workspaceFirstLeftoverReturnComeBack": "{leftoverRoleName}은 {leftoverSectionLabel}에서 쉰 뒤 {sectionLabel}부터 들어오세요.", + "workspaceFirstLeftoverReturnMissing": "오늘 먼저 남을 쉬는 자리의 복귀는 아직 확인이 필요합니다. 남은 쉬는 파트가 언제 들어오는지 첫 구간 전에 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..8a70dcd79 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro - `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`. - `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy. - `EmptyState` must remain an actionable state card, not a blank placeholder panel. +- Ready `Workspace` names tonight's first playable range and tonight's first leftover return so the map enables the next rehearsal action without opening files or export paths. - If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging. ## Pattern Backlog diff --git a/docs/doctoring/first-leftover-return.md b/docs/doctoring/first-leftover-return.md new file mode 100644 index 000000000..dca99d220 --- /dev/null +++ b/docs/doctoring/first-leftover-return.md @@ -0,0 +1,18 @@ +# Tonight's first leftover return + +The ready rehearsal map names the first leftover return from existing `partGraph` evidence: a named leftover sit-out, then a later named section where that leftover named part is own-property active. This is where the leftover part comes back after staying out while others returned. It is not a come-in, tacet, leftover sit-out, tutti, handoff, Fine, last-line breath, a continued sit-out with nobody returning, or a new dropout after every original sit-out returns. + +## Next action + +- Named leftover part: play from the top of the named return section after staying out of the leftover sit-out. +- Named returning or other included part: count the leftover part in from the top of the named return section. +- Missing: confirm where the leftover part returns before the first section. + +## Security Notes + +- Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project. +- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits an own-property leftover sit-out followed by a later named section where the leftover part is own-property active. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover return. Inherited `is_active` is isolated. Same-section false-then-true nodes are not a return. All-active later sections after a full original return are tuttis, not leftover returns. Continued sit-outs with nobody returning are not leftover returns. A new dropout after every original sit-out returns is a dropout, not a leftover return. When a role is selected, only a leftover return after a leftover sit-out that includes that named part is shown. +- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without a later return, come-ins without a leftover, full-band returns, continued sit-outs, new dropouts after a full original return, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a leftover return. +- Logging/privacy: rejected or accepted leftover returns are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstLeftoverReturn.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys return after a leftover sit-out, selected-role scoping, inherited flags, missing `is_active`, continued sit-outs, tuttis, come-ins, new dropouts after a full original return, unnamed roles, empty graphs, and literal copy filling. From 2e27dded7944da50a0a1d02d1b8dc2955d9fe44d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:09:05 +0900 Subject: [PATCH 02/11] test(workspace): cover leftover return cohorts --- .../firstLeftoverReturn.selected-role.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts index b3f46315f..003c3a225 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.selected-role.test.ts @@ -51,6 +51,52 @@ describe("firstLeftoverReturn selected-role search", () => { }); }); + it("does not show another cohort's leftover return to a continuously active selected role", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverReturn(song, "lead-vocal")).toBeNull(); + }); + + it("returns the first eligible leftover even when a later graph entry returns first", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["keys-right"]) + ] + }; + + expect(firstLeftoverReturn(song)).toEqual({ + sectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + leftoverRoleId: "lead-vocal", + leftoverRoleName: "Lead Vocal" + }); + }); + it("does not tell a new dropout to come back from an earlier leftover sit-out", () => { const seed = createDemoRehearsalSong(); const template = seed.sections[0]!; From 779363dc499d5de30f412e8c8901f9ebbbaa7951 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:10:20 +0900 Subject: [PATCH 03/11] fix(workspace): preserve leftover return cohorts --- .../features/workspace/firstLeftoverReturn.ts | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts index ac201c65d..cbb74f987 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverReturn.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts @@ -124,11 +124,15 @@ function namedGraphNodes( return seenRoleIds.size === namedRoles.size ? nodes : null; } +type PendingLeftoverRole = { + roleId: string; + roleName: string; +}; + type PendingLeftover = { leftoverSectionLabel: string; fromSectionLabel: string; - leftoverRoleId: string; - leftoverRoleName: string; + eligibleRoles: PendingLeftoverRole[]; }; /** @@ -137,13 +141,15 @@ type PendingLeftover = { * A leftover sit-out is the first later named section where at least one member * of the current reduced cohort has returned and at least one remains out. The * leftover return is the first named section after that leftover sit-out where - * the leftover part is own-property active. A tutti, come-in, continued sit-out, - * or a new dropout after a full original return is not a leftover return. + * any eligible leftover part is own-property active. A tutti, come-in, + * continued sit-out, or a new dropout after a full original return is not a + * leftover return. * * Inherited/missing activity, incomplete or contradictory graphs, unnamed * roles, and malformed runtime data fail closed. When a role is selected, a * leftover return is shown only after a leftover sit-out that includes that - * named part, so a silent new dropout is never told to come back. + * named part. If the selected part belongs to a later reduction instead, the + * search rebases to that reduction rather than attaching another cohort's cue. */ export function firstLeftoverReturn( song: RehearsalSong | unknown, @@ -177,18 +183,21 @@ export function firstLeftoverReturn( } if (pending) { - const leftoverNode = nodes.find((node) => node.roleId === pending.leftoverRoleId); - if (!leftoverNode) { - return null; - } - if (leftoverNode.active) { - return { - sectionLabel, - leftoverSectionLabel: pending.leftoverSectionLabel, - fromSectionLabel: pending.fromSectionLabel, - leftoverRoleId: pending.leftoverRoleId, - leftoverRoleName: pending.leftoverRoleName - }; + const currentPending = pending; + for (const candidate of currentPending.eligibleRoles) { + const candidateNode = nodes.find((node) => node.roleId === candidate.roleId); + if (!candidateNode) { + return null; + } + if (candidateNode.active) { + return { + sectionLabel, + leftoverSectionLabel: currentPending.leftoverSectionLabel, + fromSectionLabel: currentPending.fromSectionLabel, + leftoverRoleId: candidate.roleId, + leftoverRoleName: candidate.roleName + }; + } } continue; } @@ -210,25 +219,33 @@ export function firstLeftoverReturn( const leftovers = sittingOut.filter((node) => baselineIds.has(node.roleId)); if (returning.length > 0 && leftovers.length > 0) { - let leftover = leftovers[0]!; + let eligibleLeftovers = leftovers; if (activeRole) { const activeRoleNode = nodes.find((node) => node.roleId === activeRole); if (!activeRoleNode) { return null; } + if (!baselineIds.has(activeRole)) { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + continue; + } if (!activeRoleNode.active) { const selectedLeftover = leftovers.find((node) => node.roleId === activeRole); if (!selectedLeftover) { continue; } - leftover = selectedLeftover; + eligibleLeftovers = [selectedLeftover]; } } + pending = { leftoverSectionLabel: sectionLabel, fromSectionLabel: reducedFrom, - leftoverRoleId: leftover.roleId, - leftoverRoleName: namedRoles.get(leftover.roleId)! + eligibleRoles: eligibleLeftovers.map((leftover) => ({ + roleId: leftover.roleId, + roleName: namedRoles.get(leftover.roleId)! + })) }; continue; } From 18c93e7be7c432c54048253554effc119432a972 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:11:18 +0900 Subject: [PATCH 04/11] test(workspace): distinguish no leftover return --- .../Workspace.leftover-return.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.leftover-return.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.leftover-return.test.tsx b/apps/desktop/src/features/workspace/Workspace.leftover-return.test.tsx new file mode 100644 index 000000000..da635093b --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.leftover-return.test.tsx @@ -0,0 +1,30 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace leftover-return empty state", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("explains when a trustworthy all-active song needs no leftover-return cue", () => { + setNavigatorLanguage("en-US"); + render(); + + const callout = screen.getByTestId("first-leftover-return"); + expect(callout).toHaveTextContent( + "No leftover return is needed: every named part stays active. Rehearse from the first section without a count-back cue." + ); + expect(callout).not.toHaveTextContent("still needs a named leftover part"); + }); +}); From 4670497ae17982265ce68de4cfc1c6dce3661731 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:12:16 +0900 Subject: [PATCH 05/11] feat(workspace): classify all-active rehearsal maps --- .../features/workspace/firstLeftoverReturn.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts index cbb74f987..9306ba0cf 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverReturn.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.ts @@ -124,6 +124,40 @@ function namedGraphNodes( return seenRoleIds.size === namedRoles.size ? nodes : null; } +/** Return true only when every trustworthy named graph section keeps every role active. */ +export function hasTrustworthyAllActiveTimeline(song: RehearsalSong | unknown): boolean { + if (!isRuntimeObject(song) || !Array.isArray(song.sections)) { + return false; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles) { + return false; + } + + let sawNamedSection = false; + for (const sectionValue of song.sections) { + if (!isRuntimeObject(sectionValue)) { + return false; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + const nodes = namedGraphNodes(sectionValue, namedRoles); + if (!nodes) { + return false; + } + sawNamedSection = true; + if (nodes.some((node) => !node.active)) { + return false; + } + } + + return sawNamedSection; +} + type PendingLeftoverRole = { roleId: string; roleName: string; From 118daa7351eab463460baffc6ca2cd313c74ddf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:16:44 +0900 Subject: [PATCH 06/11] fix(workspace): distinguish absent leftover return --- apps/desktop/src/features/workspace/Workspace.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 74115a4db..34adbde8a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,7 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { firstLeftoverReturn } from "./firstLeftoverReturn"; +import { firstLeftoverReturn, hasTrustworthyAllActiveTimeline } from "./firstLeftoverReturn"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -168,6 +168,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp () => firstLeftoverReturn(song, activeRole), [activeRole, song] ); + const noLeftoverReturnNeeded = useMemo( + () => hasTrustworthyAllActiveTimeline(song), + [song] + ); const firstLeftoverReturnCopy = namedLeftoverReturn ? fillRangeCopy( t( @@ -181,7 +185,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp leftoverSectionLabel: namedLeftoverReturn.leftoverSectionLabel } ) - : t("workspaceFirstLeftoverReturnMissing"); + : t( + noLeftoverReturnNeeded + ? "workspaceFirstLeftoverReturnNone" + : "workspaceFirstLeftoverReturnMissing" + ); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { From 385cb3dbec21a357e020409479084b4d523da2f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:17:22 +0900 Subject: [PATCH 07/11] feat(i18n): explain absent leftover returns --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index e3e22a8b2..6f138dcc5 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -156,6 +156,7 @@ "workspaceFirstLeftoverReturnTitle": "Tonight's first leftover return", "workspaceFirstLeftoverReturnNamed": "{leftoverRoleName} comes back at {sectionLabel} after staying out of {leftoverSectionLabel}. Count {leftoverRoleName} in from the top of {sectionLabel}.", "workspaceFirstLeftoverReturnComeBack": "{leftoverRoleName} comes back at {sectionLabel} after staying out of {leftoverSectionLabel}. Play from the top of {sectionLabel}.", + "workspaceFirstLeftoverReturnNone": "No leftover return is needed: every named part stays active. Rehearse from the first section without a count-back cue.", "workspaceFirstLeftoverReturnMissing": "Tonight's first leftover return still needs a named leftover part that comes back after a leftover sit-out. Confirm where that leftover part returns before the first section.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." From 17a019a077137c9b4434472b4e2fd0fc30f3a2b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:18:03 +0900 Subject: [PATCH 08/11] feat(i18n): explain absent leftover returns in Korean --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index e0fc92275..8dd515c81 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -156,6 +156,7 @@ "workspaceFirstLeftoverReturnTitle": "오늘 먼저 남을 쉬는 자리의 복귀", "workspaceFirstLeftoverReturnNamed": "{leftoverRoleName}은 {leftoverSectionLabel}에서 쉰 뒤 {sectionLabel}에서 돌아옵니다. {sectionLabel} 처음부터 {leftoverRoleName}을 들어오게 하세요.", "workspaceFirstLeftoverReturnComeBack": "{leftoverRoleName}은 {leftoverSectionLabel}에서 쉰 뒤 {sectionLabel}부터 들어오세요.", + "workspaceFirstLeftoverReturnNone": "남은 파트 복귀 큐가 필요하지 않습니다. 이름이 확인된 모든 파트가 곡 전체에서 계속 연주합니다. 첫 구간부터 별도 복귀 카운트 없이 합주하세요.", "workspaceFirstLeftoverReturnMissing": "오늘 먼저 남을 쉬는 자리의 복귀는 아직 확인이 필요합니다. 남은 쉬는 파트가 언제 들어오는지 첫 구간 전에 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." From 9e24c1d90a5e3402db5586bd65c7480776e4fdb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:19:00 +0900 Subject: [PATCH 09/11] test(workspace): align all-active leftover copy --- apps/desktop/src/features/workspace/Workspace.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 36eeb58cf..3d83c4581 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -279,14 +279,14 @@ describe("Workspace", () => { ); }); - it("asks the player to confirm the leftover return when every part stays active", () => { + it("explains when no leftover return is needed because every part stays active", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); render(); expect(screen.getByTestId("first-leftover-return")).toHaveTextContent( - "Tonight's first leftover return still needs a named leftover part that comes back after a leftover sit-out. Confirm where that leftover part returns before the first section." + "No leftover return is needed: every named part stays active. Rehearse from the first section without a count-back cue." ); }); From 8d7a3c81814dc39ec56a39df96eaa74d37d434fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:20:12 +0900 Subject: [PATCH 10/11] docs(workspace): document leftover return cohort semantics --- docs/doctoring/first-leftover-return.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/first-leftover-return.md b/docs/doctoring/first-leftover-return.md index dca99d220..55806a145 100644 --- a/docs/doctoring/first-leftover-return.md +++ b/docs/doctoring/first-leftover-return.md @@ -1,18 +1,21 @@ # Tonight's first leftover return -The ready rehearsal map names the first leftover return from existing `partGraph` evidence: a named leftover sit-out, then a later named section where that leftover named part is own-property active. This is where the leftover part comes back after staying out while others returned. It is not a come-in, tacet, leftover sit-out, tutti, handoff, Fine, last-line breath, a continued sit-out with nobody returning, or a new dropout after every original sit-out returns. +The ready rehearsal map names the first leftover return from existing `partGraph` evidence: a named leftover sit-out, then a later named section where an eligible leftover named part is own-property active. This is where one of the parts still sitting out after a partial return comes back. It is not a come-in, tacet, leftover sit-out, tutti, handoff, Fine, last-line breath, a continued sit-out with nobody returning, or a new dropout after every original sit-out returns. ## Next action - Named leftover part: play from the top of the named return section after staying out of the leftover sit-out. -- Named returning or other included part: count the leftover part in from the top of the named return section. -- Missing: confirm where the leftover part returns before the first section. +- Named returning or other included part: count the first eligible leftover part in from the top of the named return section. +- Trustworthy all-active timeline: no leftover-return cue is needed; rehearse from the first section without a count-back cue. +- Missing or malformed evidence: confirm where the leftover part returns before the first section rather than inventing a cue. ## Security Notes - Untrusted inputs: `RehearsalSong` JSON, section labels, `partGraph` nodes, `is_active`, role ids, and role names from analysis or a reopened project. -- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits an own-property leftover sit-out followed by a later named section where the leftover part is own-property active. -- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover return. Inherited `is_active` is isolated. Same-section false-then-true nodes are not a return. All-active later sections after a full original return are tuttis, not leftover returns. Continued sit-outs with nobody returning are not leftover returns. A new dropout after every original sit-out returns is a dropout, not a leftover return. When a role is selected, only a leftover return after a leftover sit-out that includes that named part is shown. -- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without a later return, come-ins without a leftover, full-band returns, continued sit-outs, new dropouts after a full original return, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a leftover return. +- Trust boundary: this helper never opens files, URLs, IPC, WebView, subprocesses, model artifacts, or export paths. It only admits own-property activity evidence from complete named-role graphs. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover return. Inherited `is_active` is isolated. Same-section false-then-true nodes are not a return. All-active later sections after a full original return are tuttis, not leftover returns. Continued sit-outs with nobody returning are not leftover returns. A new dropout after every original sit-out returns is a dropout, not a leftover return. +- Cohort ordering: when multiple named parts remain out after a partial return, the pending cohort is preserved and the first one that actually returns in later timeline order wins; graph-array order does not suppress an earlier real return. +- Selected-role scoping: an active selected role may count in a leftover from its own current reduction. A selected role that was not part of an earlier reduction is not shown that cohort's cue; if it newly drops out, the search rebases to that later reduction so its own later return can still be found. +- Safe failure: inherited flags, blank labels, missing names, leftover sit-outs without a later return, come-ins without a leftover, full-band returns, continued sit-outs, new dropouts after a full original return, and malformed roots return `null`. The workspace distinguishes a trustworthy all-active timeline from malformed or missing evidence, so only the former gets an explicit “no leftover return needed” next action. - Logging/privacy: rejected or accepted leftover returns are not logged. Copy interpolation keeps rehearsal values literal. -- Tests: `firstLeftoverReturn.test.ts` and the Workspace callout cover the demo all-active case, an explicit keys return after a leftover sit-out, selected-role scoping, inherited flags, missing `is_active`, continued sit-outs, tuttis, come-ins, new dropouts after a full original return, unnamed roles, empty graphs, and literal copy filling. +- Tests: `firstLeftoverReturn.test.ts`, `firstLeftoverReturn.selected-role.test.ts`, and the Workspace callouts cover the trustworthy all-active case, explicit leftover returns, multiple pending leftovers with different return times, selected-role cohort isolation and rebasing, inherited flags, missing `is_active`, continued sit-outs, tuttis, come-ins, new dropouts after a full original return, unnamed roles, empty graphs, and literal copy filling. From 3013268451bd13174ecf42972cb91bc66804bdcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 12:20:48 +0900 Subject: [PATCH 11/11] test(workspace): cover all-active timeline classification --- .../firstLeftoverReturn.all-active.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstLeftoverReturn.all-active.test.ts diff --git a/apps/desktop/src/features/workspace/firstLeftoverReturn.all-active.test.ts b/apps/desktop/src/features/workspace/firstLeftoverReturn.all-active.test.ts new file mode 100644 index 000000000..f7293b3a2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverReturn.all-active.test.ts @@ -0,0 +1,50 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { hasTrustworthyAllActiveTimeline } from "./firstLeftoverReturn"; + +describe("hasTrustworthyAllActiveTimeline", () => { + it("accepts a complete named timeline when every graph role stays active", () => { + expect(hasTrustworthyAllActiveTimeline(createDemoRehearsalSong())).toBe(true); + }); + + it("rejects non-song roots and empty timelines", () => { + expect(hasTrustworthyAllActiveTimeline(null)).toBe(false); + + const empty = createDemoRehearsalSong(); + empty.sections = []; + expect(hasTrustworthyAllActiveTimeline(empty)).toBe(false); + }); + + it("rejects timelines without a named section", () => { + const song = createDemoRehearsalSong(); + song.sections = song.sections.map((section) => ({ ...section, label: " " })); + + expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); + }); + + it("rejects an inactive named role", () => { + const song = createDemoRehearsalSong(); + const firstSection = song.sections[0]!; + firstSection.partGraph = firstSection.partGraph.map((node, index) => + index === 0 ? { ...node, is_active: false } : node + ); + + expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); + }); + + it("fails closed on malformed section and graph evidence", () => { + expect( + hasTrustworthyAllActiveTimeline({ sections: [null] }) + ).toBe(false); + + const song = createDemoRehearsalSong(); + const malformed = song as unknown as { + sections: Array<{ + partGraph: Array>; + }>; + }; + delete malformed.sections[0]!.partGraph[0]!.is_active; + + expect(hasTrustworthyAllActiveTimeline(song)).toBe(false); + }); +});