From 90b802397404e7685b43ee5835ec8541b904b7b2 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 04:37:15 +0000 Subject: [PATCH 01/20] feat(workspace): name tonight's first leftover last-return on the map Name the first leftover that comes back last after remaining leftover from existing partGraph evidence so the leftover part comes in last and the band counts that leftover in. Keep customer-facing copy on the next rehearsal action without mixing #811, #828, #897, or other first-X map PRs. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 126 +++++ .../src/features/workspace/Workspace.tsx | 27 + ...stLeftoverLastReturn.selected-role.test.ts | 124 +++++ .../workspace/firstLeftoverLastReturn.test.ts | 479 ++++++++++++++++++ .../workspace/firstLeftoverLastReturn.ts | 349 +++++++++++++ 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-last-return.md | 18 + 14 files changed, 1137 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastReturn.test.ts create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts create mode 100644 docs/doctoring/first-leftover-last-return.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..ccf6d6010 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 last-return cues after remaining leftover, 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..a4086811e 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 last-return after remaining leftover - 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..2126f2e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first leftover last-return on the ready rehearsal map and tell the leftover part to come in last, or the band to count that leftover part in, after remaining leftover. - 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..c89f16030 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 last-return after remaining leftover. `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..7d69df67e 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -140,6 +140,132 @@ describe("Workspace", () => { expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); + it("names tonight's first leftover last-return after remaining leftover", () => { + 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.role_id === "lead-vocal" + ? { ...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.role_id === "lead-vocal" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "outro-1", + label: "outro", + timeRange: { + start: verse.timeRange.end + 40, + end: verse.timeRange.end + 60 + } + } + ]; + + render(); + + const callout = screen.getByTestId("first-leftover-last-return"); + expect(callout).toHaveTextContent("Tonight's first leftover last-return"); + expect(callout).toHaveTextContent( + "Lead Vocal comes back last at outro after staying leftover from bridge. Count Lead Vocal in from the top of outro." + ); + }); + + it("tells the leftover last-return to come in from the leftover last-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.role_id === "lead-vocal" + ? { ...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.role_id === "lead-vocal" + ? { ...node, is_active: false } + : node + ) + }, + { + ...verse, + id: "bridge-1", + label: "bridge", + timeRange: { + start: verse.timeRange.end + 20, + end: verse.timeRange.end + 40 + }, + partGraph: verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ) + }, + { + ...verse, + id: "outro-1", + label: "outro", + timeRange: { + start: verse.timeRange.end + 40, + end: verse.timeRange.end + 60 + } + } + ]; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-leftover-last-return")).toHaveTextContent( + "Lead Vocal comes back last at outro after staying leftover from bridge. Come in from the top of outro." + ); + }); + + it("asks the player to confirm the leftover last-return when every part stays active", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("first-leftover-last-return")).toHaveTextContent( + "Tonight's first leftover last-return still needs a named leftover part that comes back last after remaining leftover. Confirm who comes in last at that return before the first section." + ); + }); + it("names tonight's first playable range and the next instrument check", () => { 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..a5734cf14 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 { firstLeftoverLastReturn } from "./firstLeftoverLastReturn"; 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 namedLeftoverLastReturn = useMemo( + () => firstLeftoverLastReturn(song, activeRole), + [activeRole, song] + ); + const firstLeftoverLastReturnCopy = namedLeftoverLastReturn + ? fillRangeCopy( + t( + activeRole && activeRole === namedLeftoverLastReturn.lastRoleId + ? "workspaceFirstLeftoverLastReturnComeIn" + : "workspaceFirstLeftoverLastReturnNamed" + ), + { + lastRoleName: namedLeftoverLastReturn.lastRoleName, + sectionLabel: namedLeftoverLastReturn.sectionLabel, + remainingSectionLabel: namedLeftoverLastReturn.remainingSectionLabel + } + ) + : t("workspaceFirstLeftoverLastReturnMissing"); /** 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("workspaceFirstLeftoverLastReturnTitle")}

+

{firstLeftoverLastReturnCopy}

+
diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts new file mode 100644 index 000000000..d0dde53b5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts @@ -0,0 +1,124 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstLeftoverLastReturn } from "./firstLeftoverLastReturn"; + +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("firstLeftoverLastReturn selected-role search", () => { + it("keeps searching after the selected part newly drops out during remaining leftover", () => { + 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, [ + "bass-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "tag-1", "tag", 60, ["bass-guitar", "lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 80, []) + ] + }; + + expect(firstLeftoverLastReturn(song, "bass-guitar")).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + }); + + it("does not tell a new dropout to come in last 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", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar"]) + ] + }; + + expect(firstLeftoverLastReturn(song, "bass-guitar")).toBeNull(); + }); + + it("does not show another part's leftover last-return to a selected part that stayed active", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const selectedRole = { + ...template.roles[0]!, + id: "always-active", + name: "Always Active" + }; + const selectedTemplate: RehearsalSong["sections"][number] = { + ...template, + roles: [...template.roles, selectedRole], + partGraph: [ + ...template.partGraph, + { + ...template.partGraph[0]!, + role_id: "always-active", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(selectedTemplate, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(selectedTemplate, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(selectedTemplate, "bridge-1", "bridge", 40, ["lead-vocal"]), + sectionWithInactiveRoles(selectedTemplate, "outro-1", "outro", 60, []) + ] + }; + + expect(firstLeftoverLastReturn(song, "always-active")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.test.ts new file mode 100644 index 000000000..569d2cf44 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.test.ts @@ -0,0 +1,479 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { fillRangeCopy } from "./firstRangeSqueeze"; +import { firstLeftoverLastReturn } from "./firstLeftoverLastReturn"; + +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 leftoverThenLastReturn( + lastRoleId = "lead-vocal", + returningLeftoverId = "keys-right", + originalSitOutRoleId = "bass-guitar" +): RehearsalSong { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + return { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + originalSitOutRoleId, + returningLeftoverId, + lastRoleId + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + returningLeftoverId, + lastRoleId + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [lastRoleId]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; +} + +describe("firstLeftoverLastReturn", () => { + it("returns null on the demo song where every graph node is active", () => { + expect(firstLeftoverLastReturn(createDemoRehearsalSong())).toBeNull(); + }); + + it("names the leftover last-return after remaining leftover", () => { + expect(firstLeftoverLastReturn(leftoverThenLastReturn())).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + }); + + 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", "lead-vocal"], + true + ), + sectionWithInactiveRoles( + template, + "chorus-1", + "chorus", + 40, + ["keys-right", "lead-vocal"], + true + ), + sectionWithInactiveRoles(template, "tag-1", "tag", 60, ["lead-vocal"], true), + sectionWithInactiveRoles(template, "outro-1", "outro", 80, [], true) + ] + }; + + expect(firstLeftoverLastReturn(song)).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "tag", + leftoverSectionLabel: "chorus", + fromSectionLabel: "bridge", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + }); + + it("treats repeated form labels as distinct timeline sections", () => { + const song = leftoverThenLastReturn(); + song.sections[3] = { + ...song.sections[3]!, + label: song.sections[2]!.label + }; + + expect(firstLeftoverLastReturn(song)).toEqual({ + sectionLabel: "bridge", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + }); + + it("shrinks remaining leftover until the leftover last-return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const backing = { + ...template.roles[0]!, + id: "backing-vocal", + name: "Backing Vocal" + }; + const withBacking: RehearsalSong["sections"][number] = { + ...template, + roles: [...template.roles, backing], + partGraph: [ + ...template.partGraph, + { + ...template.partGraph[0]!, + role_id: "backing-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(withBacking, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal", + "backing-vocal" + ]), + sectionWithInactiveRoles(withBacking, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal", + "backing-vocal" + ]), + sectionWithInactiveRoles(withBacking, "bridge-1", "bridge", 40, [ + "lead-vocal", + "backing-vocal" + ]), + sectionWithInactiveRoles(withBacking, "tag-1", "tag", 60, ["backing-vocal"]), + sectionWithInactiveRoles(withBacking, "outro-1", "outro", 80, []) + ] + }; + + expect(firstLeftoverLastReturn(song)).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "tag", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "backing-vocal", + lastRoleName: "Backing Vocal" + }); + }); + + it("skips a continued remaining leftover until the leftover last-return", () => { + 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, ["lead-vocal"]), + sectionWithInactiveRoles(template, "tag-1", "tag", 60, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 80, []) + ] + }; + + expect(firstLeftoverLastReturn(song)).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + }); + + it("fails closed when remaining leftover never last-returns", () => { + 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, ["lead-vocal"]) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("keeps the selected leftover last-return on tonight's first leftover last-return", () => { + const song = leftoverThenLastReturn(); + expect(firstLeftoverLastReturn(song, "lead-vocal")).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + expect(firstLeftoverLastReturn(song, "keys-right")).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + expect(firstLeftoverLastReturn(song, "bass-guitar")).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "lead-vocal", + lastRoleName: "Lead Vocal" + }); + expect(firstLeftoverLastReturn(song, "missing-role")).toBeNull(); + }); + + it("does not treat a leftover sit-out without leftover return as a leftover last-return", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + expect( + firstLeftoverLastReturn({ + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "lead-vocal" + ]) + ] + }) + ).toBeNull(); + }); + + it("does not treat a leftover return with nobody still out as a leftover last-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, ["keys-right"]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, []) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("does not treat a come-in without a leftover sit-out as a leftover last-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(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("does not treat a tutti after a full original return as a leftover last-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(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("does not treat a new dropout after remaining leftover as a leftover last-return", () => { + 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, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar", "lead-vocal"]) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("does not treat a continued sit-out with nobody returning as a leftover last-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(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("ignores inherited is_active evidence", () => { + const song = leftoverThenLastReturn(); + 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(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("does not treat a missing is_active flag as leftover-last-return evidence", () => { + const song = leftoverThenLastReturn(); + 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(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("fails closed on contradictory duplicate graph identities", () => { + const song = leftoverThenLastReturn(); + const section = song.sections[3]!; + const leadNode = section.partGraph.find((node) => node.role_id === "lead-vocal")!; + const withoutLead = section.partGraph.filter((node) => node.role_id !== "lead-vocal"); + song.sections[3] = { + ...section, + partGraph: [...withoutLead, { ...leadNode, is_active: true }, { ...leadNode, is_active: false }] + }; + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("skips blank leftover-last-return labels until a named leftover last-return exists", () => { + const song = leftoverThenLastReturn(); + song.sections[3] = { + ...song.sections[3]!, + label: "none" as RehearsalSong["sections"][number]["label"] + }; + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("fails closed when the leftover last-return has no named leftover role", () => { + const song = leftoverThenLastReturn(); + song.sections[0] = { + ...song.sections[0]!, + roles: song.sections[0]!.roles.map((role) => ({ ...role, name: " " })) + }; + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("fails closed when a later section has no named graph", () => { + const song = leftoverThenLastReturn(); + song.sections[3] = { + ...song.sections[3]!, + partGraph: [] + }; + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("fails closed on malformed runtime roots", () => { + for (const malformed of [null, {}, { sections: {} }, { sections: [null] }]) { + expect(firstLeftoverLastReturn(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); + + it("isolates blank role ids, non-boolean flags, and unnamed graph members", () => { + const song = leftoverThenLastReturn(); + 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(firstLeftoverLastReturn(song)).toBeNull(); + }); +}); + +describe("leftover-last-return copy filling", () => { + it("keeps rehearsal values literal", () => { + expect( + fillRangeCopy( + "{lastRoleName} comes back last at {sectionLabel} after staying leftover from {remainingSectionLabel}.", + { + lastRoleName: "Lead Vocal {sectionLabel}", + sectionLabel: "outro", + remainingSectionLabel: "bridge" + } + ) + ).toBe( + "Lead Vocal {sectionLabel} comes back last at outro after staying leftover from bridge." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts new file mode 100644 index 000000000..caeeab369 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts @@ -0,0 +1,349 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named leftover last-return after remaining leftover. */ +export type FirstLeftoverLastReturn = { + sectionLabel: string; + remainingSectionLabel: string; + leftoverSectionLabel: string; + fromSectionLabel: string; + lastRoleId: string; + lastRoleName: 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 leftover sit-out, remaining + * leftover, and leftover last-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 remaining and last-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 PendingLeftoverSitOut = { + leftoverSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + originalSitOutIds: string[]; +}; + +type PendingRemainingLeftover = { + leftoverSectionLabel: string; + remainingSectionLabel: string; + fromSectionLabel: string; + leftoverIds: string[]; + remainingIds: string[]; + originalSitOutIds: string[]; +}; + +/** + * Return whether the selected part belongs to this leftover last-return. + * + * A leftover last-return is shown only after a leftover sit-out whose original + * sit-out, leftover, remaining leftover, or last leftover includes that named + * part, so a silent always-active part is never told to count someone in. + */ +function selectedPartBelongs( + pending: PendingRemainingLeftover, + lastRoleId: string, + activeRole: string | null +): boolean { + if (!activeRole) { + return true; + } + return ( + pending.originalSitOutIds.includes(activeRole) || + pending.leftoverIds.includes(activeRole) || + pending.remainingIds.includes(activeRole) || + lastRoleId === activeRole + ); +} + +/** + * Pick the first leftover last-return a player should honor after remaining leftover. + * + * 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. A + * leftover return with remaining leftover is the first named section after that + * leftover sit-out where at least one leftover part is own-property active and + * at least one leftover remains own-property tacet. A leftover last-return is + * the first later named section where every remaining leftover is own-property + * active. A leftover return with nobody still out, a remaining leftover with + * nobody coming back last, a come-in, tacet, leftover sit-out, leftover return, + * tutti, continued remaining leftover, or a new dropout after remaining leftover + * is not a leftover last-return. + * + * Inherited/missing activity, incomplete or contradictory graphs, unnamed + * roles, and malformed runtime data fail closed. When a role is selected, a + * leftover last-return is shown only after a leftover sit-out that includes + * that named part, so a silent new dropout is never told to come in last. + */ +export function firstLeftoverLastReturn( + song: RehearsalSong | unknown, + activeRole: string | null = null +): FirstLeftoverLastReturn | 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 pendingSitOut: PendingLeftoverSitOut | null = null; + let pendingRemaining: PendingRemainingLeftover | 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; + } + + const sittingOut = nodes.filter((node) => node.active === false); + + if (pendingRemaining) { + const remainingNodes: NamedGraphNode[] = []; + for (const remainingId of pendingRemaining.remainingIds) { + const remainingNode = nodes.find((node) => node.roleId === remainingId); + if (!remainingNode) { + return null; + } + remainingNodes.push(remainingNode); + } + + const returningLast = remainingNodes.filter((node) => node.active); + const stillRemaining = remainingNodes.filter((node) => node.active === false); + + if (returningLast.length > 0 && stillRemaining.length === 0) { + let last = returningLast[0]!; + if (activeRole) { + const activeRoleNode = nodes.find((node) => node.roleId === activeRole); + if (!activeRoleNode) { + return null; + } + if (!activeRoleNode.active) { + const selectedLast = returningLast.find((node) => node.roleId === activeRole); + if (!selectedLast) { + continue; + } + last = selectedLast; + } + if (!selectedPartBelongs(pendingRemaining, last.roleId, activeRole)) { + continue; + } + } + return { + sectionLabel, + remainingSectionLabel: pendingRemaining.remainingSectionLabel, + leftoverSectionLabel: pendingRemaining.leftoverSectionLabel, + fromSectionLabel: pendingRemaining.fromSectionLabel, + lastRoleId: last.roleId, + lastRoleName: namedRoles.get(last.roleId)! + }; + } + + if (returningLast.length > 0 && stillRemaining.length > 0) { + pendingRemaining = { + leftoverSectionLabel: pendingRemaining.leftoverSectionLabel, + remainingSectionLabel: sectionLabel, + fromSectionLabel: pendingRemaining.fromSectionLabel, + leftoverIds: pendingRemaining.leftoverIds, + remainingIds: stillRemaining.map((node) => node.roleId), + originalSitOutIds: pendingRemaining.originalSitOutIds + }; + } + continue; + } + + if (pendingSitOut) { + const leftoverNodes = pendingSitOut.leftoverIds.map((leftoverId) => + nodes.find((node) => node.roleId === leftoverId) + ); + if (leftoverNodes.some((node) => !node)) { + return null; + } + + const returningLeftovers = leftoverNodes.filter((node) => node!.active); + const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false); + + if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) { + pendingRemaining = { + leftoverSectionLabel: pendingSitOut.leftoverSectionLabel, + remainingSectionLabel: sectionLabel, + fromSectionLabel: pendingSitOut.fromSectionLabel, + leftoverIds: pendingSitOut.leftoverIds, + remainingIds: remainingLeftovers.map((node) => node!.roleId), + originalSitOutIds: pendingSitOut.originalSitOutIds + }; + pendingSitOut = null; + continue; + } + + if (returningLeftovers.length > 0 && remainingLeftovers.length === 0) { + pendingSitOut = null; + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + } else { + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + } + } + continue; + } + + 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) { + pendingSitOut = { + leftoverSectionLabel: sectionLabel, + fromSectionLabel: reducedFrom, + leftoverIds: leftovers.map((node) => node.roleId), + originalSitOutIds: [...baselineIds] + }; + 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..02c69251c 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("workspaceFirstLeftoverLastReturnTitle")).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..cf69848d5 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.", + "workspaceFirstLeftoverLastReturnTitle": "Tonight's first leftover last-return", + "workspaceFirstLeftoverLastReturnNamed": "{lastRoleName} comes back last at {sectionLabel} after staying leftover from {remainingSectionLabel}. Count {lastRoleName} in from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastReturnComeIn": "{lastRoleName} comes back last at {sectionLabel} after staying leftover from {remainingSectionLabel}. Come in from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastReturnMissing": "Tonight's first leftover last-return still needs a named leftover part that comes back last after remaining leftover. Confirm who comes in last at that return 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..56fda9345 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": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstLeftoverLastReturnTitle": "오늘 먼저 마지막 복귀", + "workspaceFirstLeftoverLastReturnNamed": "{lastRoleName}은 {remainingSectionLabel}에서 남은 뒤 {sectionLabel}에서 마지막에 들어옵니다. {sectionLabel} 처음부터 {lastRoleName}을 들여 보내세요.", + "workspaceFirstLeftoverLastReturnComeIn": "{lastRoleName}은 {remainingSectionLabel}에서 남은 뒤 {sectionLabel}에서 마지막에 들어옵니다. {sectionLabel} 처음부터 들어오세요.", + "workspaceFirstLeftoverLastReturnMissing": "오늘 먼저 마지막 복귀는 아직 확인이 필요합니다. 남은 쉬는 자리 다음 누가 마지막에 들어오는지 첫 구간 전에 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..fc9f420c1 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 last-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-last-return.md b/docs/doctoring/first-leftover-last-return.md new file mode 100644 index 000000000..98c8aeead --- /dev/null +++ b/docs/doctoring/first-leftover-last-return.md @@ -0,0 +1,18 @@ +# Tonight's first leftover last-return + +The ready rehearsal map names the first leftover last-return from existing `partGraph` evidence: a named leftover sit-out, then a later named leftover return where at least one leftover named part is own-property active and at least one leftover remains own-property tacet, then a later named section where every remaining leftover is own-property active. This is who comes back last after remaining leftover. It is not a come-in, tacet, leftover sit-out, leftover return, remaining leftover, tutti, handoff, Fine, last-line breath, a leftover return with nobody still out, a continued remaining leftover, or a new dropout after remaining leftover. + +## Next action + +- Named leftover last-return: come in from the top of the named leftover last-return after staying leftover from the leftover return. +- Named returning or other included part: count the leftover last-return in from the top of that return. +- Missing: confirm who comes in last after remaining leftover 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, leftover return with remaining leftover, and a later named leftover last-return where every remaining leftover is own-property active. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover last-return. Inherited `is_active` is isolated. A leftover return with nobody still out is a leftover return, not a leftover last-return. A remaining leftover with nobody coming back last is remaining leftover, not a leftover last-return. All-active later sections after a full original return are tuttis, not leftover last-returns. Continued remaining leftovers are not leftover last-returns. A new dropout after remaining leftover is a dropout, not a leftover last-return. When a role is selected, only a leftover last-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 leftover return, leftover returns with nobody still out, remaining leftovers without a later last-return, come-ins without a leftover, full-band returns, continued remaining leftovers, new dropouts after remaining leftover, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a leftover last-return. +- Logging/privacy: rejected or accepted leftover last-returns are not logged. Copy interpolation keeps rehearsal values literal. +- Tests: `firstLeftoverLastReturn.test.ts` and the Workspace callout cover the demo all-active case, an explicit lead leftover last-return after remaining leftover, selected-role scoping, inherited flags, missing `is_active`, continued remaining leftovers, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and literal copy filling. From 50438afd5619630db2ba348df9e7fdbfa4bc6a98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:20:50 +0900 Subject: [PATCH 02/20] chore: write-scope probe (delete after) --- .cwl-write-probe | 1 + 1 file changed, 1 insertion(+) create mode 100644 .cwl-write-probe diff --git a/.cwl-write-probe b/.cwl-write-probe new file mode 100644 index 000000000..1fa2073e8 --- /dev/null +++ b/.cwl-write-probe @@ -0,0 +1 @@ +probe 2026-08-31T05:20Z write-scope retry From 79b02eccb022605f311d197e9bfd2fd969807568 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 05:21:32 +0000 Subject: [PATCH 03/20] chore: remove write-scope probe --- .cwl-write-probe | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .cwl-write-probe diff --git a/.cwl-write-probe b/.cwl-write-probe deleted file mode 100644 index 1fa2073e8..000000000 --- a/.cwl-write-probe +++ /dev/null @@ -1 +0,0 @@ -probe 2026-08-31T05:20Z write-scope retry From 681b07141adb915bb6e3c4d7dcf8c2f5e781ec64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:34:00 +0900 Subject: [PATCH 04/20] chore: write-probe (delete immediately) --- .cwl-write-probe-dropout | 1 + 1 file changed, 1 insertion(+) create mode 100644 .cwl-write-probe-dropout diff --git a/.cwl-write-probe-dropout b/.cwl-write-probe-dropout new file mode 100644 index 000000000..be21c9869 --- /dev/null +++ b/.cwl-write-probe-dropout @@ -0,0 +1 @@ +write-probe leftover-last-dropout cycle From 538139e930e2b555b7e411b0b2bb2c93f9506171 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:34:20 +0900 Subject: [PATCH 05/20] chore: remove write-probe (no product change) --- .cwl-write-probe-dropout | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .cwl-write-probe-dropout diff --git a/.cwl-write-probe-dropout b/.cwl-write-probe-dropout deleted file mode 100644 index be21c9869..000000000 --- a/.cwl-write-probe-dropout +++ /dev/null @@ -1 +0,0 @@ -write-probe leftover-last-dropout cycle From 4b8dd2ecc0f7ba97287a960f679b5aff2cecb8f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:39:23 +0900 Subject: [PATCH 06/20] test(workspace): cover leftover last-return ambiguity --- ...toverLastReturn.review-regressions.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts new file mode 100644 index 000000000..b6f409ce5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts @@ -0,0 +1,117 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { firstLeftoverLastReturn } from "./firstLeftoverLastReturn"; + +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("firstLeftoverLastReturn review regressions", () => { + it("fails closed when several remaining parts return together", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(template, "verse-1", "verse", 0, [ + "bass-guitar", + "keys-right", + "acoustic-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "chorus-1", "chorus", 20, [ + "keys-right", + "acoustic-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "bridge-1", "bridge", 40, [ + "acoustic-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, []) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("keeps searching after an unrelated complete return for the selected part", () => { + const seed = createDemoRehearsalSong(); + const template = seed.sections[0]!; + const selectedRole = { + ...template.roles[0]!, + id: "selected-part", + name: "Selected Part" + }; + const selectedTemplate: RehearsalSong["sections"][number] = { + ...template, + roles: [...template.roles, selectedRole], + partGraph: [ + ...template.partGraph, + { + ...template.partGraph[0]!, + role_id: "selected-part", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }; + const song: RehearsalSong = { + ...seed, + sections: [ + sectionWithInactiveRoles(selectedTemplate, "verse-1", "verse", 0, [ + "keys-right", + "acoustic-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(selectedTemplate, "chorus-1", "chorus", 20, [ + "acoustic-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(selectedTemplate, "bridge-1", "bridge", 40, [ + "lead-vocal" + ]), + sectionWithInactiveRoles(selectedTemplate, "outro-1", "outro", 60, []), + sectionWithInactiveRoles(selectedTemplate, "verse-2", "verse", 80, [ + "selected-part", + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(selectedTemplate, "chorus-2", "chorus", 100, [ + "bass-guitar", + "keys-right" + ]), + sectionWithInactiveRoles(selectedTemplate, "bridge-2", "bridge", 120, [ + "keys-right" + ]), + sectionWithInactiveRoles(selectedTemplate, "outro-2", "outro", 140, []) + ] + }; + + expect(firstLeftoverLastReturn(song, "selected-part")).toEqual({ + sectionLabel: "outro", + remainingSectionLabel: "bridge", + leftoverSectionLabel: "chorus", + fromSectionLabel: "verse", + lastRoleId: "keys-right", + lastRoleName: "Keyboard 1 Right Hand" + }); + }); +}); From 657e078b355b0e06ef34f861afc896c51917c305 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:40:21 +0900 Subject: [PATCH 07/20] test(workspace): reject concurrent dropout on final return --- ...toverLastReturn.review-regressions.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts index b6f409ce5..a38c7f962 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts @@ -51,6 +51,29 @@ describe("firstLeftoverLastReturn review regressions", () => { expect(firstLeftoverLastReturn(song)).toBeNull(); }); + it("rejects a final return when another part newly drops out in the same section", () => { + 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, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 60, ["bass-guitar"]) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + it("keeps searching after an unrelated complete return for the selected part", () => { const seed = createDemoRehearsalSong(); const template = seed.sections[0]!; From dcc69fc303fbbc640434c1409757b245b7ea11cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:41:27 +0900 Subject: [PATCH 08/20] fix(workspace): disambiguate leftover last-return tracking --- .../workspace/firstLeftoverLastReturn.ts | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts index caeeab369..2f963bb03 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts @@ -202,6 +202,21 @@ export function firstLeftoverLastReturn( let pendingSitOut: PendingLeftoverSitOut | null = null; let pendingRemaining: PendingRemainingLeftover | null = null; + const restartTrackingFromCurrentSection = ( + sectionLabel: string, + sittingOut: NamedGraphNode[] + ): void => { + pendingSitOut = null; + pendingRemaining = null; + if (sittingOut.length === 0) { + reducedFrom = null; + sittingOutIds = null; + return; + } + reducedFrom = sectionLabel; + sittingOutIds = new Set(sittingOut.map((node) => node.roleId)); + }; + for (const sectionValue of song.sections) { if (!isRuntimeObject(sectionValue)) { return null; @@ -219,6 +234,12 @@ export function firstLeftoverLastReturn( const sittingOut = nodes.filter((node) => node.active === false); if (pendingRemaining) { + const trackedRemainingIds = new Set(pendingRemaining.remainingIds); + if (sittingOut.some((node) => !trackedRemainingIds.has(node.roleId))) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); + continue; + } + const remainingNodes: NamedGraphNode[] = []; for (const remainingId of pendingRemaining.remainingIds) { const remainingNode = nodes.find((node) => node.roleId === remainingId); @@ -232,20 +253,22 @@ export function firstLeftoverLastReturn( const stillRemaining = remainingNodes.filter((node) => node.active === false); if (returningLast.length > 0 && stillRemaining.length === 0) { - let last = returningLast[0]!; + if (returningLast.length !== 1) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); + continue; + } + + const last = returningLast[0]!; if (activeRole) { const activeRoleNode = nodes.find((node) => node.roleId === activeRole); if (!activeRoleNode) { return null; } - if (!activeRoleNode.active) { - const selectedLast = returningLast.find((node) => node.roleId === activeRole); - if (!selectedLast) { - continue; - } - last = selectedLast; - } - if (!selectedPartBelongs(pendingRemaining, last.roleId, activeRole)) { + if ( + !activeRoleNode.active || + !selectedPartBelongs(pendingRemaining, last.roleId, activeRole) + ) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); continue; } } @@ -284,12 +307,18 @@ export function firstLeftoverLastReturn( const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false); if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) { + const remainingIds = remainingLeftovers.map((node) => node!.roleId); + const remainingIdSet = new Set(remainingIds); + if (sittingOut.some((node) => !remainingIdSet.has(node.roleId))) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); + continue; + } pendingRemaining = { leftoverSectionLabel: pendingSitOut.leftoverSectionLabel, remainingSectionLabel: sectionLabel, fromSectionLabel: pendingSitOut.fromSectionLabel, leftoverIds: pendingSitOut.leftoverIds, - remainingIds: remainingLeftovers.map((node) => node!.roleId), + remainingIds, originalSitOutIds: pendingSitOut.originalSitOutIds }; pendingSitOut = null; From ae11170dfc923c1d47d0162ab250d7bad29d9163 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:43:29 +0900 Subject: [PATCH 09/20] fix(workspace): preserve later last-return search --- .../workspace/firstLeftoverLastReturn.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts index 2f963bb03..fe58b38ea 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts @@ -234,12 +234,6 @@ export function firstLeftoverLastReturn( const sittingOut = nodes.filter((node) => node.active === false); if (pendingRemaining) { - const trackedRemainingIds = new Set(pendingRemaining.remainingIds); - if (sittingOut.some((node) => !trackedRemainingIds.has(node.roleId))) { - restartTrackingFromCurrentSection(sectionLabel, sittingOut); - continue; - } - const remainingNodes: NamedGraphNode[] = []; for (const remainingId of pendingRemaining.remainingIds) { const remainingNode = nodes.find((node) => node.roleId === remainingId); @@ -253,7 +247,11 @@ export function firstLeftoverLastReturn( const stillRemaining = remainingNodes.filter((node) => node.active === false); if (returningLast.length > 0 && stillRemaining.length === 0) { - if (returningLast.length !== 1) { + const trackedRemainingIds = new Set(pendingRemaining.remainingIds); + const concurrentDropout = sittingOut.some( + (node) => !trackedRemainingIds.has(node.roleId) + ); + if (concurrentDropout || returningLast.length !== 1) { restartTrackingFromCurrentSection(sectionLabel, sittingOut); continue; } @@ -307,18 +305,12 @@ export function firstLeftoverLastReturn( const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false); if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) { - const remainingIds = remainingLeftovers.map((node) => node!.roleId); - const remainingIdSet = new Set(remainingIds); - if (sittingOut.some((node) => !remainingIdSet.has(node.roleId))) { - restartTrackingFromCurrentSection(sectionLabel, sittingOut); - continue; - } pendingRemaining = { leftoverSectionLabel: pendingSitOut.leftoverSectionLabel, remainingSectionLabel: sectionLabel, fromSectionLabel: pendingSitOut.fromSectionLabel, leftoverIds: pendingSitOut.leftoverIds, - remainingIds, + remainingIds: remainingLeftovers.map((node) => node!.roleId), originalSitOutIds: pendingSitOut.originalSitOutIds }; pendingSitOut = null; From 35a34df1b5165d9a0d85a2dee68b2a98d272603c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:52:37 +0900 Subject: [PATCH 10/20] test(workspace): reject internal last-return jargon --- .../workspaceLeftoverLastReturnCopy.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/desktop/src/i18n/workspaceLeftoverLastReturnCopy.test.ts diff --git a/apps/desktop/src/i18n/workspaceLeftoverLastReturnCopy.test.ts b/apps/desktop/src/i18n/workspaceLeftoverLastReturnCopy.test.ts new file mode 100644 index 000000000..c375d0b39 --- /dev/null +++ b/apps/desktop/src/i18n/workspaceLeftoverLastReturnCopy.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import enCommon from "../locales/en/common.json"; +import koCommon from "../locales/ko/common.json"; + +describe("workspace last re-entry copy", () => { + it("uses rehearsal language instead of internal leftover-state terminology", () => { + const english = [ + enCommon.workspaceFirstLeftoverLastReturnTitle, + enCommon.workspaceFirstLeftoverLastReturnNamed, + enCommon.workspaceFirstLeftoverLastReturnComeIn, + enCommon.workspaceFirstLeftoverLastReturnMissing + ]; + + expect(english.join(" ").toLowerCase()).not.toContain("leftover"); + expect(enCommon.workspaceFirstLeftoverLastReturnTitle).toBe( + "Tonight's first final re-entry" + ); + expect(enCommon.workspaceFirstLeftoverLastReturnNamed).toBe( + "{lastRoleName} is the last part back at {sectionLabel}; the others started returning at {remainingSectionLabel}. Count {lastRoleName} in from the top of {sectionLabel}." + ); + expect(enCommon.workspaceFirstLeftoverLastReturnComeIn).toBe( + "{lastRoleName} is the last part back at {sectionLabel}; the others started returning at {remainingSectionLabel}. Come in from the top of {sectionLabel}." + ); + expect(enCommon.workspaceFirstLeftoverLastReturnMissing).toBe( + "No final re-entry is confirmed yet. Before the first section, check which part returns last after the band begins coming back in." + ); + }); + + it("keeps the Korean cue concrete and action-oriented", () => { + expect(koCommon.workspaceFirstLeftoverLastReturnTitle).toBe( + "오늘 먼저 볼 마지막 재진입" + ); + expect(koCommon.workspaceFirstLeftoverLastReturnNamed).toBe( + "{remainingSectionLabel}에서 일부 파트가 먼저 돌아온 뒤 {lastRoleName}은 {sectionLabel}에서 마지막으로 합류합니다. {sectionLabel} 첫 박부터 {lastRoleName}을 넣으세요." + ); + expect(koCommon.workspaceFirstLeftoverLastReturnComeIn).toBe( + "{remainingSectionLabel}에서 일부 파트가 먼저 돌아온 뒤 {lastRoleName}은 {sectionLabel}에서 마지막으로 합류합니다. {sectionLabel} 첫 박부터 들어오세요." + ); + expect(koCommon.workspaceFirstLeftoverLastReturnMissing).toBe( + "아직 마지막 재진입을 확정하지 못했습니다. 첫 구간 전에 다른 파트들이 돌아오기 시작한 뒤 누가 마지막으로 합류하는지 확인하세요." + ); + }); +}); From ae4f9a260a8b259d770336d65d0444b1c88ced75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:10:56 +0900 Subject: [PATCH 11/20] test(workspace): reject transient dropouts during remaining return --- ...toverLastReturn.review-regressions.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts index a38c7f962..03e73c7eb 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts @@ -74,6 +74,61 @@ describe("firstLeftoverLastReturn review regressions", () => { expect(firstLeftoverLastReturn(song)).toBeNull(); }); + it("invalidates a remaining-leftover sequence when an untracked role drops and returns before the tracked final return", () => { + 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, ["lead-vocal"]), + sectionWithInactiveRoles(template, "break-1", "break", 60, [ + "bass-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "tag-1", "tag", 80, ["lead-vocal"]), + sectionWithInactiveRoles(template, "outro-1", "outro", 100, []) + ] + }; + + expect(firstLeftoverLastReturn(song)).toBeNull(); + }); + + it("invalidates a selected-role sequence when an untracked role returns alongside the tracked final role", () => { + 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, ["lead-vocal"]), + sectionWithInactiveRoles(template, "break-1", "break", 60, [ + "bass-guitar", + "lead-vocal" + ]), + sectionWithInactiveRoles(template, "outro-1", "outro", 80, []) + ] + }; + + expect(firstLeftoverLastReturn(song, "lead-vocal")).toBeNull(); + }); + it("keeps searching after an unrelated complete return for the selected part", () => { const seed = createDemoRehearsalSong(); const template = seed.sections[0]!; From 9e126dd1d35fe03b5041d262e86a0ec1a0eb9c45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:33:12 +0900 Subject: [PATCH 12/20] fix(workspace): invalidate transient dropouts during final re-entry --- .../workspace/firstLeftoverLastReturn.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts index fe58b38ea..5272c39df 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.ts @@ -202,6 +202,12 @@ export function firstLeftoverLastReturn( let pendingSitOut: PendingLeftoverSitOut | null = null; let pendingRemaining: PendingRemainingLeftover | null = null; + /** + * Reset partial-return tracking and make the current section the new reduction baseline. + * + * @param sectionLabel Named section that interrupted the tracked sequence. + * @param sittingOut Own-property inactive roles observed in that section. + */ const restartTrackingFromCurrentSection = ( sectionLabel: string, sittingOut: NamedGraphNode[] @@ -245,13 +251,17 @@ export function firstLeftoverLastReturn( const returningLast = remainingNodes.filter((node) => node.active); const stillRemaining = remainingNodes.filter((node) => node.active === false); + const trackedRemainingIds = new Set(pendingRemaining.remainingIds); + const untrackedDropout = sittingOut.some( + (node) => !trackedRemainingIds.has(node.roleId) + ); + if (untrackedDropout) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); + continue; + } if (returningLast.length > 0 && stillRemaining.length === 0) { - const trackedRemainingIds = new Set(pendingRemaining.remainingIds); - const concurrentDropout = sittingOut.some( - (node) => !trackedRemainingIds.has(node.roleId) - ); - if (concurrentDropout || returningLast.length !== 1) { + if (returningLast.length !== 1) { restartTrackingFromCurrentSection(sectionLabel, sittingOut); continue; } @@ -305,12 +315,22 @@ export function firstLeftoverLastReturn( const remainingLeftovers = leftoverNodes.filter((node) => node!.active === false); if (returningLeftovers.length > 0 && remainingLeftovers.length > 0) { + const remainingIds = remainingLeftovers.map((node) => node!.roleId); + const remainingIdSet = new Set(remainingIds); + const concurrentDropout = sittingOut.some( + (node) => !remainingIdSet.has(node.roleId) + ); + if (concurrentDropout) { + restartTrackingFromCurrentSection(sectionLabel, sittingOut); + continue; + } + pendingRemaining = { leftoverSectionLabel: pendingSitOut.leftoverSectionLabel, remainingSectionLabel: sectionLabel, fromSectionLabel: pendingSitOut.fromSectionLabel, leftoverIds: pendingSitOut.leftoverIds, - remainingIds: remainingLeftovers.map((node) => node!.roleId), + remainingIds, originalSitOutIds: pendingSitOut.originalSitOutIds }; pendingSitOut = null; From 7de99485ac2e5d0d28f5e94bac7c92f0f61d73a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:34:00 +0900 Subject: [PATCH 13/20] test(workspace): align selected-role transient-dropout contract --- .../firstLeftoverLastReturn.selected-role.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts index d0dde53b5..a2d209e03 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.selected-role.test.ts @@ -23,7 +23,7 @@ function sectionWithInactiveRoles( } describe("firstLeftoverLastReturn selected-role search", () => { - it("keeps searching after the selected part newly drops out during remaining leftover", () => { + it("invalidates the earlier sequence when the selected part newly drops out during remaining leftover", () => { const seed = createDemoRehearsalSong(); const template = seed.sections[0]!; const song: RehearsalSong = { @@ -47,14 +47,7 @@ describe("firstLeftoverLastReturn selected-role search", () => { ] }; - expect(firstLeftoverLastReturn(song, "bass-guitar")).toEqual({ - sectionLabel: "outro", - remainingSectionLabel: "bridge", - leftoverSectionLabel: "chorus", - fromSectionLabel: "verse", - lastRoleId: "lead-vocal", - lastRoleName: "Lead Vocal" - }); + expect(firstLeftoverLastReturn(song, "bass-guitar")).toBeNull(); }); it("does not tell a new dropout to come in last from an earlier leftover sit-out", () => { From 28dc156c5675d8ed3879164a61f9920d1078c317 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:34:38 +0900 Subject: [PATCH 14/20] docs(workspace): define transient-dropout final re-entry contract --- docs/doctoring/first-leftover-last-return.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/first-leftover-last-return.md b/docs/doctoring/first-leftover-last-return.md index 98c8aeead..a4bb168a6 100644 --- a/docs/doctoring/first-leftover-last-return.md +++ b/docs/doctoring/first-leftover-last-return.md @@ -12,7 +12,7 @@ The ready rehearsal map names the first leftover last-return from existing `part - 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, leftover return with remaining leftover, and a later named leftover last-return where every remaining leftover is own-property active. -- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover last-return. Inherited `is_active` is isolated. A leftover return with nobody still out is a leftover return, not a leftover last-return. A remaining leftover with nobody coming back last is remaining leftover, not a leftover last-return. All-active later sections after a full original return are tuttis, not leftover last-returns. Continued remaining leftovers are not leftover last-returns. A new dropout after remaining leftover is a dropout, not a leftover last-return. When a role is selected, only a leftover last-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 leftover return, leftover returns with nobody still out, remaining leftovers without a later last-return, come-ins without a leftover, full-band returns, continued remaining leftovers, new dropouts after remaining leftover, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a leftover last-return. +- Allowlist: section labels and role names must be meaningful text. A missing graph node is not a leftover last-return. Inherited `is_active` is isolated. A leftover return with nobody still out is a leftover return, not a leftover last-return. A remaining leftover with nobody coming back last is remaining leftover, not a leftover last-return. All-active later sections after a full original return are tuttis, not leftover last-returns. Continued remaining leftovers are not leftover last-returns. Once remaining-leftover tracking begins, any tacet role outside the tracked remaining cohort invalidates that sequence immediately; the interrupting section becomes a fresh reduction baseline so a later independent sequence can still be found. When a role is selected, only a leftover last-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 leftover return, leftover returns with nobody still out, remaining leftovers without a later last-return, come-ins without a leftover, full-band returns, continued remaining leftovers, transient or concurrent untracked dropouts during remaining-leftover tracking, and malformed roots return `null` so the workspace shows the missing-copy next action instead of crashing or inventing a leftover last-return. - Logging/privacy: rejected or accepted leftover last-returns are not logged. Copy interpolation keeps rehearsal values literal. -- Tests: `firstLeftoverLastReturn.test.ts` and the Workspace callout cover the demo all-active case, an explicit lead leftover last-return after remaining leftover, selected-role scoping, inherited flags, missing `is_active`, continued remaining leftovers, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, new dropouts after remaining leftover, unnamed roles, empty graphs, and literal copy filling. +- Tests: `firstLeftoverLastReturn.test.ts`, `firstLeftoverLastReturn.selected-role.test.ts`, `firstLeftoverLastReturn.review-regressions.test.ts`, and the Workspace callout cover the demo all-active case, an explicit lead leftover last-return after remaining leftover, selected-role scoping, inherited flags, missing `is_active`, continued remaining leftovers, tuttis, come-ins, leftover returns with nobody still out, remaining leftovers without last-return, transient and concurrent untracked dropouts during remaining-leftover tracking, unnamed roles, empty graphs, and literal copy filling. From 716d42bd3d46cbfd9f707062067d43f9a19a295d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:35:42 +0900 Subject: [PATCH 15/20] fix(i18n): use rehearsal-facing final re-entry copy --- apps/desktop/src/locales/en/common.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index cf69848d5..ded39393f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,10 +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.", - "workspaceFirstLeftoverLastReturnTitle": "Tonight's first leftover last-return", - "workspaceFirstLeftoverLastReturnNamed": "{lastRoleName} comes back last at {sectionLabel} after staying leftover from {remainingSectionLabel}. Count {lastRoleName} in from the top of {sectionLabel}.", - "workspaceFirstLeftoverLastReturnComeIn": "{lastRoleName} comes back last at {sectionLabel} after staying leftover from {remainingSectionLabel}. Come in from the top of {sectionLabel}.", - "workspaceFirstLeftoverLastReturnMissing": "Tonight's first leftover last-return still needs a named leftover part that comes back last after remaining leftover. Confirm who comes in last at that return before the first section.", + "workspaceFirstLeftoverLastReturnTitle": "Tonight's first final re-entry", + "workspaceFirstLeftoverLastReturnNamed": "{lastRoleName} is the last part back at {sectionLabel}; the others started returning at {remainingSectionLabel}. Count {lastRoleName} in from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastReturnComeIn": "{lastRoleName} is the last part back at {sectionLabel}; the others started returning at {remainingSectionLabel}. Come in from the top of {sectionLabel}.", + "workspaceFirstLeftoverLastReturnMissing": "No final re-entry is confirmed yet. Before the first section, check which part returns last after the band begins coming back in.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } From 0215a7fd3abcb3a362bf44d5f0e0e02bb32b80cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:38:09 +0900 Subject: [PATCH 16/20] fix(i18n): align Korean final re-entry action copy --- apps/desktop/src/locales/ko/common.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 56fda9345..c8988053a 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,10 +153,10 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", - "workspaceFirstLeftoverLastReturnTitle": "오늘 먼저 마지막 복귀", - "workspaceFirstLeftoverLastReturnNamed": "{lastRoleName}은 {remainingSectionLabel}에서 남은 뒤 {sectionLabel}에서 마지막에 들어옵니다. {sectionLabel} 처음부터 {lastRoleName}을 들여 보내세요.", - "workspaceFirstLeftoverLastReturnComeIn": "{lastRoleName}은 {remainingSectionLabel}에서 남은 뒤 {sectionLabel}에서 마지막에 들어옵니다. {sectionLabel} 처음부터 들어오세요.", - "workspaceFirstLeftoverLastReturnMissing": "오늘 먼저 마지막 복귀는 아직 확인이 필요합니다. 남은 쉬는 자리 다음 누가 마지막에 들어오는지 첫 구간 전에 확인하세요.", + "workspaceFirstLeftoverLastReturnTitle": "오늘 먼저 볼 마지막 재진입", + "workspaceFirstLeftoverLastReturnNamed": "{remainingSectionLabel}에서 일부 파트가 먼저 돌아온 뒤 {lastRoleName}은 {sectionLabel}에서 마지막으로 합류합니다. {sectionLabel} 첫 박부터 {lastRoleName}을 넣으세요.", + "workspaceFirstLeftoverLastReturnComeIn": "{remainingSectionLabel}에서 일부 파트가 먼저 돌아온 뒤 {lastRoleName}은 {sectionLabel}에서 마지막으로 합류합니다. {sectionLabel} 첫 박부터 들어오세요.", + "workspaceFirstLeftoverLastReturnMissing": "아직 마지막 재진입을 확정하지 못했습니다. 첫 구간 전에 다른 파트들이 돌아오기 시작한 뒤 누가 마지막으로 합류하는지 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From 4f2d999582e41bc504662c965cb1d706646228cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:38:35 +0900 Subject: [PATCH 17/20] test(i18n): align Korean final re-entry title --- apps/desktop/src/i18n/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index 02c69251c..e6c1d7cc9 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -60,7 +60,7 @@ describe("i18n", () => { const t = createTranslator("ko"); expect(t("appTitle")).toBe("BandScope"); expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); - expect(t("workspaceFirstLeftoverLastReturnTitle")).toBe("오늘 먼저 마지막 복귀"); + expect(t("workspaceFirstLeftoverLastReturnTitle")).toBe("오늘 먼저 볼 마지막 재진입"); }); it("falls back to English when a Korean translation is missing", () => { @@ -76,4 +76,4 @@ describe("i18n", () => { } }); }); -}); +}); \ No newline at end of file From 09b32bffbde5e5a9c25e7a789f0656d47704658b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:41:07 +0900 Subject: [PATCH 18/20] test(workspace): align final re-entry rehearsal copy --- apps/desktop/src/features/workspace/Workspace.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7d69df67e..58d1bb88d 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -192,9 +192,9 @@ describe("Workspace", () => { render(); const callout = screen.getByTestId("first-leftover-last-return"); - expect(callout).toHaveTextContent("Tonight's first leftover last-return"); + expect(callout).toHaveTextContent("Tonight's first final re-entry"); expect(callout).toHaveTextContent( - "Lead Vocal comes back last at outro after staying leftover from bridge. Count Lead Vocal in from the top of outro." + "Lead Vocal is the last part back at outro; the others started returning at bridge. Count Lead Vocal in from the top of outro." ); }); @@ -251,7 +251,7 @@ describe("Workspace", () => { fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); expect(screen.getByTestId("first-leftover-last-return")).toHaveTextContent( - "Lead Vocal comes back last at outro after staying leftover from bridge. Come in from the top of outro." + "Lead Vocal is the last part back at outro; the others started returning at bridge. Come in from the top of outro." ); }); @@ -262,7 +262,7 @@ describe("Workspace", () => { render(); expect(screen.getByTestId("first-leftover-last-return")).toHaveTextContent( - "Tonight's first leftover last-return still needs a named leftover part that comes back last after remaining leftover. Confirm who comes in last at that return before the first section." + "No final re-entry is confirmed yet. Before the first section, check which part returns last after the band begins coming back in." ); }); @@ -452,4 +452,4 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); -}); +}); \ No newline at end of file From 837204d232ec44d2565394dd1306aa0d5323a2ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:24:39 +0900 Subject: [PATCH 19/20] test(workspace): make tied-return fixture use real roles --- ...toverLastReturn.review-regressions.test.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts index 03e73c7eb..949be135c 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts @@ -9,6 +9,13 @@ function sectionWithInactiveRoles( start: number, inactiveRoleIds: readonly string[] ): RehearsalSong["sections"][number] { + const knownRoleIds = new Set(template.partGraph.map((node) => node.role_id)); + for (const inactiveRoleId of inactiveRoleIds) { + if (!knownRoleIds.has(inactiveRoleId)) { + throw new Error(`Unknown test role id: ${inactiveRoleId}`); + } + } + const inactive = new Set(inactiveRoleIds); return { ...template, @@ -22,10 +29,44 @@ function sectionWithInactiveRoles( }; } +function templateWithAdditionalRole( + template: RehearsalSong["sections"][number], + roleId: string, + roleName: string +): RehearsalSong["sections"][number] { + const sourceRole = template.roles[0]!; + const sourceNode = template.partGraph[0]!; + return { + ...template, + roles: [ + ...template.roles, + { + ...sourceRole, + id: roleId, + name: roleName, + overlapWarnings: [] + } + ], + partGraph: [ + ...template.partGraph, + { + ...sourceNode, + role_id: roleId, + handoff_to: [], + handoff_from: [] + } + ] + }; +} + describe("firstLeftoverLastReturn review regressions", () => { it("fails closed when several remaining parts return together", () => { const seed = createDemoRehearsalSong(); - const template = seed.sections[0]!; + const template = templateWithAdditionalRole( + seed.sections[0]!, + "acoustic-guitar", + "Acoustic Guitar" + ); const song: RehearsalSong = { ...seed, sections: [ @@ -156,11 +197,9 @@ describe("firstLeftoverLastReturn review regressions", () => { sections: [ sectionWithInactiveRoles(selectedTemplate, "verse-1", "verse", 0, [ "keys-right", - "acoustic-guitar", "lead-vocal" ]), sectionWithInactiveRoles(selectedTemplate, "chorus-1", "chorus", 20, [ - "acoustic-guitar", "lead-vocal" ]), sectionWithInactiveRoles(selectedTemplate, "bridge-1", "bridge", 40, [ From fcf201a904586733171d116d76e2295cb96013cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:25:24 +0900 Subject: [PATCH 20/20] test(workspace): preserve unrelated-return scenario with valid roles --- .../firstLeftoverLastReturn.review-regressions.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts index 949be135c..f11528cfb 100644 --- a/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts +++ b/apps/desktop/src/features/workspace/firstLeftoverLastReturn.review-regressions.test.ts @@ -196,10 +196,12 @@ describe("firstLeftoverLastReturn review regressions", () => { ...seed, sections: [ sectionWithInactiveRoles(selectedTemplate, "verse-1", "verse", 0, [ + "bass-guitar", "keys-right", "lead-vocal" ]), sectionWithInactiveRoles(selectedTemplate, "chorus-1", "chorus", 20, [ + "keys-right", "lead-vocal" ]), sectionWithInactiveRoles(selectedTemplate, "bridge-1", "bridge", 40, [