From 36733f06d048e2155436234b8fb0b52d85f57569 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 05:24:14 +0000 Subject: [PATCH 01/35] feat(workspace): name the next practice action after a part is selected The selected-part tracker now tells the player to start, keep practicing, switch to the next unready named part, or download tonight's cue sheet. Own-property progress admission fails closed on inherited, non-finite, unnamed, duplicated, or conflicting section copies. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/PracticeProgress.test.tsx | 15 ++ .../features/workspace/PracticeProgress.tsx | 11 +- .../src/features/workspace/Workspace.test.tsx | 91 ++++++++ .../src/features/workspace/Workspace.tsx | 36 +++- .../practiceProgressNextAction.test.ts | 150 +++++++++++++ .../workspace/practiceProgressNextAction.ts | 202 ++++++++++++++++++ apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + docs/architecture/overview.md | 1 + docs/architecture/rehearsal-domain-model.md | 8 + docs/design-system/component-contract.md | 1 + .../practice-progress-next-action.md | 66 ++++++ 16 files changed, 589 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts create mode 100644 apps/desktop/src/features/workspace/practiceProgressNextAction.ts create mode 100644 docs/doctoring/practice-progress-next-action.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..97d1656f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Customer-facing copy must enable the next rehearsal action, not only describe current state. After a named part is selected, the practice tracker names start, continue, switch-to-next-unready-part, or cue-sheet send. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..876c39266 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - 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 + - selected-part practice progress that names the next start, continue, switch-to-next-unready-part, or cue-sheet send - 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..f2484844d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. +- After a named part is selected, name the next practice action: start, keep practicing, switch to the next unready part, or download tonight's cue sheet. - 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..e3dad4c4e 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 the next instrument check. After a named part is selected, the practice tracker names the next start, continue, switch, or cue-sheet send. `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/PracticeProgress.test.tsx b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx index 2da2f3855..6d51d4d2a 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx @@ -29,6 +29,21 @@ describe("PracticeProgress", () => { expect(screen.getByText("50%")).toBeTruthy(); }); + it("renders the next-action copy when a practice step is named", () => { + const handleChange = vi.fn(); + render( + + ); + + expect(screen.getByTestId("practice-progress-next-action")).toHaveTextContent( + "Check Bass Guitar's first range, then mark this part started." + ); + }); + it("calls onChange with increased value when increase button is clicked", () => { const handleChange = vi.fn(); render(); diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx index d10ca94c0..eb512e295 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -2,14 +2,15 @@ import { memo, useCallback } from "react"; import { Minus, Plus } from "lucide-react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ +/** Selected-part practice tracker with an optional named next rehearsal step. */ interface PracticeProgressProps { progress?: number; onChange: (newProgress: number) => void; + nextActionCopy?: string; } /** Documented. */ -function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressProps) { +function PracticeProgressComponent({ progress = 0, onChange, nextActionCopy }: PracticeProgressProps) { const t = createTranslator(detectPreferredLocale()); const handleDecrease = useCallback((e: React.MouseEvent) => { @@ -48,6 +49,12 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP {progress}% + {nextActionCopy ? ( +

+ {nextActionCopy} +

+ ) : null} +
)} - + )} diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts new file mode 100644 index 000000000..787f60245 --- /dev/null +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts @@ -0,0 +1,150 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { admitPracticeProgress, practiceProgressNextAction } from "./practiceProgressNextAction"; + +/** Documented. */ +function withProgress(song: RehearsalSong, progressByRoleId: Record): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => + Object.prototype.hasOwnProperty.call(progressByRoleId, role.id) + ? { ...role, practiceProgress: progressByRoleId[role.id] } + : role + ) + })) + }; +} + +describe("admitPracticeProgress", () => { + it("treats missing progress as not started", () => { + expect(admitPracticeProgress({ id: "bass-guitar", name: "Bass Guitar" })).toBe(0); + }); + + it("admits a finite percentage in 0–100", () => { + expect(admitPracticeProgress({ practiceProgress: 0 })).toBe(0); + expect(admitPracticeProgress({ practiceProgress: 50 })).toBe(50); + expect(admitPracticeProgress({ practiceProgress: 100 })).toBe(100); + }); + + it("fails closed on inherited, non-finite, or out-of-range progress", () => { + const inherited = Object.create({ practiceProgress: 40 }) as Record; + expect(admitPracticeProgress(inherited)).toBe(0); + expect(admitPracticeProgress({ practiceProgress: Number.NaN })).toBeNull(); + expect(admitPracticeProgress({ practiceProgress: Number.POSITIVE_INFINITY })).toBeNull(); + expect(admitPracticeProgress({ practiceProgress: -1 })).toBeNull(); + expect(admitPracticeProgress({ practiceProgress: 101 })).toBeNull(); + expect(admitPracticeProgress({ practiceProgress: "50" })).toBeNull(); + }); +}); + +describe("practiceProgressNextAction", () => { + it("names the start step when the selected part has not been marked started", () => { + const action = practiceProgressNextAction(createDemoRehearsalSong(), "bass-guitar"); + + expect(action).toEqual({ + kind: "start", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 0 + }); + }); + + it("names the continue step while the selected part is still below ready", () => { + const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 50 }); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "continue", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 50 + }); + }); + + it("names the next unready part after the selected part is marked ready", () => { + const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 100 }); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "ready-next", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 100, + nextRoleId: "keys-right", + nextRoleName: "Keyboard 1 Right Hand" + }); + }); + + it("names the cue-sheet send when every named part is marked ready", () => { + const song = withProgress(createDemoRehearsalSong(), { + "bass-guitar": 100, + "keys-right": 100, + "lead-vocal": 100 + }); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "ready-done", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 100 + }); + }); + + it("skips later ready parts until the next unready named part", () => { + const song = withProgress(createDemoRehearsalSong(), { + "bass-guitar": 100, + "keys-right": 100 + }); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "ready-next", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 100, + nextRoleId: "lead-vocal", + nextRoleName: "Lead Vocal" + }); + }); + + it("fails closed without a selected part, unknown part, or malformed root", () => { + expect(practiceProgressNextAction(createDemoRehearsalSong(), null)).toBeNull(); + expect(practiceProgressNextAction(createDemoRehearsalSong(), "missing-role")).toBeNull(); + expect(practiceProgressNextAction(null, "bass-guitar")).toBeNull(); + expect(practiceProgressNextAction({ title: "Late Night Set" }, "bass-guitar")).toBeNull(); + }); + + it("fails closed when section copies disagree or progress is corrupt", () => { + const conflicting = createDemoRehearsalSong(); + conflicting.sections = [ + { + ...conflicting.sections[0]!, + id: "verse-1", + roles: conflicting.sections[0]!.roles.map((role) => + role.id === "bass-guitar" ? { ...role, practiceProgress: 100 } : role + ) + }, + { + ...conflicting.sections[0]!, + id: "chorus-1", + roles: conflicting.sections[0]!.roles.map((role) => + role.id === "bass-guitar" ? { ...role, practiceProgress: 40 } : role + ) + } + ]; + expect(practiceProgressNextAction(conflicting, "bass-guitar")).toBeNull(); + + const corrupt = withProgress(createDemoRehearsalSong(), { "bass-guitar": 50 }); + (corrupt.sections[0]!.roles[0] as { practiceProgress: unknown }).practiceProgress = "ready"; + expect(practiceProgressNextAction(corrupt, "bass-guitar")).toBeNull(); + }); + + it("fails closed when a role is unnamed or duplicated in one section", () => { + const unnamed = createDemoRehearsalSong(); + unnamed.sections[0]!.roles[0] = { ...unnamed.sections[0]!.roles[0]!, name: " " }; + expect(practiceProgressNextAction(unnamed, "bass-guitar")).toBeNull(); + + const duplicated = createDemoRehearsalSong(); + duplicated.sections[0]!.roles.push({ ...duplicated.sections[0]!.roles[0]! }); + expect(practiceProgressNextAction(duplicated, "bass-guitar")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts new file mode 100644 index 000000000..33fdb98f5 --- /dev/null +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -0,0 +1,202 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's next practice step after a named part is selected. */ +export type PracticeProgressNextAction = { + kind: "start" | "continue" | "ready-next" | "ready-done"; + roleId: string; + roleName: string; + progress: number; + nextRoleId?: string; + nextRoleName?: 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 practice-progress percentage. + * + * Missing progress means the part has not been marked started. Inherited, + * non-finite, or out-of-range values fail closed so a prototype member or + * corrupt project field cannot become rehearsal authority. + */ +export function admitPracticeProgress(roleValue: Record): number | null { + if (!Object.prototype.hasOwnProperty.call(roleValue, "practiceProgress")) { + return 0; + } + const value = roleValue.practiceProgress; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) { + return null; + } + return value; +} + +type NamedRoleCatalog = Map; + +/** + * Build trustworthy role identity evidence across the whole song. + * + * Duplicate ids, blank names, or the same id with two display names fail + * closed so a practice handoff cannot name the wrong part. + */ +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; +} + +/** + * Return one consistent progress value for a named part, or fail closed. + * + * Workspace writes the same percentage onto every section copy of a role. + * Conflicting copies are not rehearsal authority. + */ +function consistentRoleProgress( + songValue: Record, + roleId: string +): number | null { + if (!Array.isArray(songValue.sections)) { + return null; + } + + let admitted: number | null = null; + let seen = false; + + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return null; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { + return null; + } + if (meaningfulRangeText(roleValue.id) !== roleId) { + continue; + } + const progress = admitPracticeProgress(roleValue); + if (progress === null) { + return null; + } + if (!seen) { + admitted = progress; + seen = true; + continue; + } + if (admitted !== progress) { + return null; + } + } + } + + return seen ? admitted : 0; +} + +/** + * Pick tonight's next practice step after a named part is selected. + * + * A part that has not been marked started is told to check its first range + * and then mark the part started. A part still below 100% is told to keep + * practicing until it is ready for the room. A part marked ready names the + * next named part that is not ready and tells the player to switch and check + * that part's range. When every named part is ready, the next action is to + * download tonight's cue sheet and send it to the group. This is not a + * leftover, come-in, tacet, or MIR product. + * + * Inherited or out-of-range progress, unnamed roles, conflicting section + * copies, and malformed roots fail closed. + */ +export function practiceProgressNextAction( + song: RehearsalSong | unknown, + activeRole: string | null +): PracticeProgressNextAction | null { + if (!activeRole || !isRuntimeObject(song)) { + return null; + } + + const namedRoles = namedSongRoles(song); + if (!namedRoles || !namedRoles.has(activeRole)) { + return null; + } + + const progress = consistentRoleProgress(song, activeRole); + if (progress === null) { + return null; + } + + const roleName = namedRoles.get(activeRole); + if (!roleName) { + return null; + } + + if (progress < 100) { + return { + kind: progress <= 0 ? "start" : "continue", + roleId: activeRole, + roleName, + progress + }; + } + + for (const [roleId, nextRoleName] of namedRoles) { + if (roleId === activeRole) { + continue; + } + const nextProgress = consistentRoleProgress(song, roleId); + if (nextProgress === null) { + return null; + } + if (nextProgress < 100) { + return { + kind: "ready-next", + roleId: activeRole, + roleName, + progress, + nextRoleId: roleId, + nextRoleName + }; + } + } + + return { + kind: "ready-done", + roleId: activeRole, + roleName, + progress + }; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..7b13b4023 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.", + "workspacePracticeProgressStart": "Check {roleName}'s first range, then mark this part started.", + "workspacePracticeProgressContinue": "Keep practicing {roleName} until this part is ready for the room.", + "workspacePracticeProgressReadyNext": "{roleName} is ready. Switch to {nextRoleName} and check that part's range.", + "workspacePracticeProgressReadyDone": "Every named part is marked ready. Download tonight's cue sheet and send it to the group.", "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..1bf6f22ff 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": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspacePracticeProgressStart": "{roleName}의 첫 음역을 확인한 다음, 이 파트를 시작했다고 표시하세요.", + "workspacePracticeProgressContinue": "{roleName}을 합주실에서 바로 쓸 수 있을 때까지 계속 연습하세요.", + "workspacePracticeProgressReadyNext": "{roleName}은 준비됐습니다. {nextRoleName}으로 바꿔 그 파트의 음역을 확인하세요.", + "workspacePracticeProgressReadyDone": "이름이 있는 파트가 모두 준비됐습니다. 오늘 큐시트를 내려받아 그룹에 보내세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..7d75b96d2 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -12,6 +12,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - section roadmap with entries, dropouts, pickups, stops, and handoffs - groove and timing cues - role ranges, overlap warnings, and simplification guidance +- selected-part practice progress that names the next start, continue, switch, or cue-sheet send - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority diff --git a/docs/architecture/rehearsal-domain-model.md b/docs/architecture/rehearsal-domain-model.md index 4b177dbf1..6dc1d84a5 100644 --- a/docs/architecture/rehearsal-domain-model.md +++ b/docs/architecture/rehearsal-domain-model.md @@ -47,6 +47,14 @@ BandScope models a song as rehearsal-facing roles, not only as a single global h - Exports should be compact rehearsal artifacts rather than DAW sessions or engraved notation. - Acceptable examples include cue sheets, section roadmaps, role notes, lyric-linked anchors, and chart-style summaries. - Export formats must stay aligned with `docs/security/app-security.md` export safety rules. +- When every named part is marked ready, the selected-part practice tracker names downloading tonight's cue sheet and sending it to the group as the next action. + +## Practice progress + +- Each named role may record a 0–100 `practiceProgress` percentage for tonight's prep. +- Missing progress means the part has not been marked started. +- After a named part is selected, the workspace must name the next action: start, continue, switch to the next unready named part, or send the cue sheet. +- Inherited, non-finite, out-of-range, unnamed, duplicated, or conflicting section copies fail closed and must not become rehearsal authority. ## Rehearsal prioritization diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c31a7a788 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| Practice Progress | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=18-602 | `apps/desktop/src/features/workspace/PracticeProgress.tsx` | Selected-part 0–100 tracker. Workspace supplies `nextActionCopy` from `practiceProgressNextAction` so the control names start, continue, switch, or cue-sheet send. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/practice-progress-next-action.md b/docs/doctoring/practice-progress-next-action.md new file mode 100644 index 000000000..04244147c --- /dev/null +++ b/docs/doctoring/practice-progress-next-action.md @@ -0,0 +1,66 @@ +# Practice-progress next action + +## Decision + +The ready rehearsal workspace already records a 0–100 `practiceProgress` value per named part. A percentage alone does not tell the player what to do next. After a named part is selected, BandScope now names one rehearsal step: + +- **start** when the part has not been marked started: check that part's first range, then mark the part started; +- **continue** while the part is still below 100%: keep practicing until it is ready for the room; +- **ready-next** when the selected part is ready and another named part is not: switch to that next unready part and check its range; +- **ready-done** when every named part is ready: download tonight's cue sheet and send it to the group. + +This is not a leftover, come-in, tacet, tutti, or MIR product. Canonical MIR ownership remains ContextualWisdomLab/bandscope#828 for ContextualWisdomLab/bandscope#770. + +## Own-property admission + +`practiceProgress` is a local project field. Workspace copy may only name a next action from own-property evidence: + +- a missing `practiceProgress` own property means the part has not been marked started (progress `0`); +- inherited prototype members, non-finite numbers, non-numbers, and values outside `0`–`100` fail closed; +- unnamed roles, duplicate role ids in one section, or the same id with two display names fail closed; +- conflicting section copies of the same named part fail closed so a handoff cannot name the wrong next step. + +The helper never logs role names, project paths, or progress values. + +## Security Notes + +### Attack surface + +Untrusted local project JSON can carry `sections[].roles[]` objects. A prototype-inherited `practiceProgress`, a string `"100"`, `NaN`, or disagreeing section copies must not become rehearsal authority or be interpolated into bilingual next-action copy. + +### Trust boundary + +Admission is lexical and own-property only. The helper reads in-memory song objects already loaded by the desktop shell. It does not open files, resolve paths, call IPC, or export bytes. Display names interpolated into copy are the same named-role strings already shown in the Role Switcher. + +### Mitigations + +- `Object.prototype.hasOwnProperty.call` before reading `practiceProgress`, `id`, and `name`. +- Finite numeric range `0`–`100` only; missing own property admits `0`. +- `meaningfulRangeText` rejects blank and `none` sentinel names. +- Conflicting section copies return `null`; Workspace omits next-action copy rather than guessing. +- Locale templates keep `{roleName}` / `{nextRoleName}` placeholders; `fillRangeCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source. + +### Test points + +- Helper: missing vs own-property `0`/`50`/`100`; inherited/non-finite/out-of-range fail closed; start/continue/ready-next/ready-done; skip later ready parts; unnamed or duplicated roles fail closed. +- `PracticeProgress` renders supplied next-action copy through `data-testid="practice-progress-next-action"`. +- Workspace English copy: select bass at 0 → start; bass at 50 → continue; bass at 100 → Keyboard 1 Right Hand; every part at 100 → cue-sheet send; conflicting section copies hide the copy. + +### Realistic threats + +A crafted project that puts `practiceProgress: 100` on `Object.prototype` or disagrees across sections could otherwise tell a player a part is ready, skip a still-unready named part, or interpolate unexpected text into the tracker. + +### Remaining risk + +The tracker still writes the same percentage onto every section copy of the selected role through the existing Workspace updater. That write path is unchanged. Next-action copy is derived, not persisted, and is omitted when admission fails. + +## Verification + +Run: + +```bash +npm --workspace @bandscope/desktop exec vitest run \ + src/features/workspace/practiceProgressNextAction.test.ts \ + src/features/workspace/PracticeProgress.test.tsx \ + src/features/workspace/Workspace.test.tsx +``` From 1f77c4f8b09b36a66c527ae087026a07bb2e98de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:17:00 +0900 Subject: [PATCH 02/35] test(workspace): reject impossible range practice actions --- .../practiceProgressNextAction.range.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/desktop/src/features/workspace/practiceProgressNextAction.range.test.ts diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.range.test.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.range.test.ts new file mode 100644 index 000000000..38111a372 --- /dev/null +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.range.test.ts @@ -0,0 +1,58 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { practiceProgressNextAction } from "./practiceProgressNextAction"; + +/** Return a copy with one role's range removed everywhere it appears. */ +function withoutPlayableRange(song: RehearsalSong, roleId: string): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => + role.id === roleId + ? { ...role, range: { lowestNote: "", highestNote: "" } } + : role + ) + })) + }; +} + +/** Return a copy with an admitted practice percentage for one role. */ +function withProgress(song: RehearsalSong, roleId: string, progress: number): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => + role.id === roleId ? { ...role, practiceProgress: progress } : role + ) + })) + }; +} + +describe("practiceProgressNextAction playable-range admission", () => { + it("does not tell an unstarted selected part to check a range that is unavailable", () => { + const song = withoutPlayableRange(createDemoRehearsalSong(), "bass-guitar"); + + expect(practiceProgressNextAction(song, "bass-guitar")).toBeNull(); + }); + + it("does not route a ready part to an unready next part whose playable range is unavailable", () => { + let song = withProgress(createDemoRehearsalSong(), "bass-guitar", 100); + song = withoutPlayableRange(song, "keys-right"); + + expect(practiceProgressNextAction(song, "bass-guitar")).toBeNull(); + }); + + it("keeps the range-independent continue action for an already-started part", () => { + let song = withProgress(createDemoRehearsalSong(), "bass-guitar", 50); + song = withoutPlayableRange(song, "bass-guitar"); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "continue", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 50 + }); + }); +}); From 33f6754e0c95be3c79f15d71614d0e63ab0c436d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:18:21 +0900 Subject: [PATCH 03/35] fix(workspace): fail closed on range-less practice actions --- .../workspace/practiceProgressNextAction.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts index 33fdb98f5..6875f0ad1 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -1,5 +1,5 @@ import type { RehearsalSong } from "@bandscope/shared-types"; -import { meaningfulRangeText } from "./firstRangeSqueeze"; +import { firstRangeSqueeze, meaningfulRangeText } from "./firstRangeSqueeze"; /** Tonight's next practice step after a named part is selected. */ export type PracticeProgressNextAction = { @@ -127,19 +127,25 @@ function consistentRoleProgress( return seen ? admitted : 0; } +/** Return whether a named part has at least one admitted playable range. */ +function hasPlayableRange(song: RehearsalSong, roleId: string): boolean { + return firstRangeSqueeze(song, roleId) !== null; +} + /** * Pick tonight's next practice step after a named part is selected. * * A part that has not been marked started is told to check its first range - * and then mark the part started. A part still below 100% is told to keep - * practicing until it is ready for the room. A part marked ready names the - * next named part that is not ready and tells the player to switch and check - * that part's range. When every named part is ready, the next action is to + * only when a playable range is actually admitted. A part still below 100% + * is told to keep practicing until it is ready for the room. A part marked + * ready names the next named part that is not ready only when that part also + * has a playable range. When every named part is ready, the next action is to * download tonight's cue sheet and send it to the group. This is not a * leftover, come-in, tacet, or MIR product. * * Inherited or out-of-range progress, unnamed roles, conflicting section - * copies, and malformed roots fail closed. + * copies, malformed roots, and actions that depend on unavailable ranges fail + * closed instead of presenting an impossible rehearsal instruction. */ export function practiceProgressNextAction( song: RehearsalSong | unknown, @@ -165,6 +171,9 @@ export function practiceProgressNextAction( } if (progress < 100) { + if (progress <= 0 && !hasPlayableRange(song as RehearsalSong, activeRole)) { + return null; + } return { kind: progress <= 0 ? "start" : "continue", roleId: activeRole, @@ -182,6 +191,9 @@ export function practiceProgressNextAction( return null; } if (nextProgress < 100) { + if (!hasPlayableRange(song as RehearsalSong, roleId)) { + return null; + } return { kind: "ready-next", roleId: activeRole, From d3a87f5916847833da13b88e59521c0c669996c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:19:58 +0900 Subject: [PATCH 04/35] fix(i18n): make practice handoff natural in Korean --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 1bf6f22ff..7c4934239 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -155,7 +155,7 @@ "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "workspacePracticeProgressStart": "{roleName}의 첫 음역을 확인한 다음, 이 파트를 시작했다고 표시하세요.", "workspacePracticeProgressContinue": "{roleName}을 합주실에서 바로 쓸 수 있을 때까지 계속 연습하세요.", - "workspacePracticeProgressReadyNext": "{roleName}은 준비됐습니다. {nextRoleName}으로 바꿔 그 파트의 음역을 확인하세요.", + "workspacePracticeProgressReadyNext": "{roleName}은 준비됐습니다. {nextRoleName} 파트로 바꿔 그 파트의 음역을 확인하세요.", "workspacePracticeProgressReadyDone": "이름이 있는 파트가 모두 준비됐습니다. 오늘 큐시트를 내려받아 그룹에 보내세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." From c2eb2bbc15bd941e16a10151a3f67b6cf1e4e144 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:20:53 +0900 Subject: [PATCH 05/35] docs(changelog): match completed practice handoff --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2484844d..87ffbb180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. -- After a named part is selected, name the next practice action: start, keep practicing, switch to the next unready part, or download tonight's cue sheet. +- After a named part is selected, name the next practice action: start, keep practicing, switch to the next unready part, or download tonight's cue sheet and send it to the group. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. From 8718184f3384ebf3412976c87558ffbbb325adc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:21:41 +0900 Subject: [PATCH 06/35] test(workspace): cover range-less guidance at UI boundary --- .../Workspace.practice-range.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.practice-range.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.practice-range.test.tsx b/apps/desktop/src/features/workspace/Workspace.practice-range.test.tsx new file mode 100644 index 000000000..f195c8c67 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.practice-range.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +describe("Workspace practice guidance with unavailable ranges", () => { + it("shows the range-recovery copy without an impossible start instruction", () => { + const song = createDemoRehearsalSong(); + song.sections = song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => + role.id === "bass-guitar" + ? { ...role, range: { lowestNote: "", highestNote: "" } } + : role + ) + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect( + screen.getByText( + "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." + ) + ).toBeInTheDocument(); + expect( + screen.queryByText("Check Bass Guitar's first range, then mark this part started.") + ).not.toBeInTheDocument(); + }); +}); From 939692cf17ab8bd2f13bebf082cb7eb9cd5a30cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:44:38 +0900 Subject: [PATCH 07/35] feat(workspace): consolidate unlogged practice authority --- .../workspace/firstUnloggedPractice.ts | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstUnloggedPractice.ts diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts new file mode 100644 index 000000000..37c6e47fc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -0,0 +1,205 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Trustworthy state of tonight's first unlogged-practice decision. */ +export type FirstUnloggedPractice = + | { + kind: "unlogged"; + sectionLabel: string; + roleName: string; + } + | { kind: "selected-logged" } + | { kind: "all-logged" } + | { kind: "unavailable" }; + +type PracticeMark = + | { kind: "unlogged" } + | { kind: "logged"; value: number } + | { kind: "invalid" }; + +type RoleEvidence = { + roleName: string; + firstSectionLabel: string; + marks: PracticeMark[]; +}; + +/** 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); +} + +/** Return whether a record owns a field rather than inheriting it. */ +function owns(record: Record, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +/** Return whether a role already owns a 0–100 integer practice mark. */ +export function hasLoggedPracticeProgress(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; +} + +/** Admit one role-copy practice mark without granting inherited values authority. */ +function practiceMark(roleValue: Record): PracticeMark { + if (!owns(roleValue, "practiceProgress")) { + return { kind: "unlogged" }; + } + const value = roleValue.practiceProgress; + if (!hasLoggedPracticeProgress(value)) { + return { kind: "invalid" }; + } + return { kind: "logged", value }; +} + +/** Return whether every section copy agrees that the named part is still unlogged. */ +function isConsistentlyUnlogged(marks: PracticeMark[]): boolean { + return marks.length > 0 && marks.every((mark) => mark.kind === "unlogged"); +} + +/** Return whether every section copy owns the same trustworthy practice mark. */ +function isConsistentlyLogged(marks: PracticeMark[]): boolean { + if (marks.length === 0 || marks.some((mark) => mark.kind !== "logged")) { + return false; + } + const expected = (marks[0] as Extract).value; + return marks.every( + (mark) => mark.kind === "logged" && mark.value === expected + ); +} + +/** + * Resolve tonight's first trustworthy unlogged-practice state. + * + * The same role id may legitimately appear in several song sections. Those + * copies are one rehearsal part only when their display name agrees and their + * practice evidence is role-wide consistent. Duplicate ids inside one + * section, conflicting names, mixed logged/unlogged copies, malformed marks, + * inherited identity, or malformed collection evidence never become proof + * that a part—or the whole rehearsal—has already been logged. + */ +export function firstUnloggedPractice( + song: RehearsalSong, + activeRole: string | null = null +): FirstUnloggedPractice { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !owns(runtimeSong, "sections") || !Array.isArray(runtimeSong.sections)) { + return { kind: "unavailable" }; + } + + const evidenceByRole = new Map(); + const roleOrder: string[] = []; + const invalidRoleIds = new Set(); + let hasInvalidEvidence = false; + + for (const sectionValue of runtimeSong.sections) { + if ( + !isRuntimeObject(sectionValue) || + !owns(sectionValue, "label") || + !owns(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return { kind: "unavailable" }; + } + + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + return { kind: "unavailable" }; + } + + const sectionRoleIds = new Set(); + for (const roleValue of sectionValue.roles) { + if ( + !isRuntimeObject(roleValue) || + !owns(roleValue, "id") || + !owns(roleValue, "name") + ) { + hasInvalidEvidence = true; + continue; + } + + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName) { + hasInvalidEvidence = true; + continue; + } + + if (sectionRoleIds.has(roleId)) { + invalidRoleIds.add(roleId); + hasInvalidEvidence = true; + continue; + } + sectionRoleIds.add(roleId); + + const mark = practiceMark(roleValue); + if (mark.kind === "invalid") { + hasInvalidEvidence = true; + } + + const existing = evidenceByRole.get(roleId); + if (!existing) { + evidenceByRole.set(roleId, { + roleName, + firstSectionLabel: sectionLabel, + marks: [mark] + }); + roleOrder.push(roleId); + continue; + } + + if (existing.roleName !== roleName) { + invalidRoleIds.add(roleId); + hasInvalidEvidence = true; + } + existing.marks.push(mark); + } + } + + if (activeRole) { + const evidence = evidenceByRole.get(activeRole); + if (!evidence || invalidRoleIds.has(activeRole)) { + return { kind: "unavailable" }; + } + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (isConsistentlyLogged(evidence.marks)) { + return { kind: "selected-logged" }; + } + return { kind: "unavailable" }; + } + + for (const roleId of roleOrder) { + if (invalidRoleIds.has(roleId)) { + continue; + } + const evidence = evidenceByRole.get(roleId); + if (!evidence) { + hasInvalidEvidence = true; + continue; + } + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (!isConsistentlyLogged(evidence.marks)) { + hasInvalidEvidence = true; + } + } + + if (roleOrder.length === 0 || invalidRoleIds.size > 0 || hasInvalidEvidence) { + return { kind: "unavailable" }; + } + return { kind: "all-logged" }; +} + +/** Fill trusted `{token}` placeholders for unlogged-practice copy. */ +export function fillUnloggedPracticeCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} From 57cf9a89f1a8109fc8f8a2f1b8a9d4beff0d8dea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:45:03 +0900 Subject: [PATCH 08/35] test(workspace): consolidate unlogged practice regressions --- .../workspace/firstUnloggedPractice.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts new file mode 100644 index 000000000..f97e306a2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -0,0 +1,181 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillUnloggedPracticeCopy, + firstUnloggedPractice, + hasLoggedPracticeProgress +} from "./firstUnloggedPractice"; + +describe("hasLoggedPracticeProgress", () => { + it("admits only 0–100 integers", () => { + expect(hasLoggedPracticeProgress(0)).toBe(true); + expect(hasLoggedPracticeProgress(100)).toBe(true); + expect(hasLoggedPracticeProgress(40)).toBe(true); + expect(hasLoggedPracticeProgress(undefined)).toBe(false); + expect(hasLoggedPracticeProgress(40.5)).toBe(false); + expect(hasLoggedPracticeProgress(-1)).toBe(false); + expect(hasLoggedPracticeProgress(101)).toBe(false); + expect(hasLoggedPracticeProgress("40")).toBe(false); + }); +}); + +describe("firstUnloggedPractice", () => { + it("names the first demo part that still has no practice mark", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong())).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("skips parts that already own a 0–100 mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 40 + }; + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand" + }); + }); + + it("limits the callout to the selected unlogged part", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("distinguishes a selected part with a trustworthy practice mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 0 + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" }); + }); + + it("skips malformed marks and duplicate role ids inside one section", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 150 as unknown as number + }; + song.sections[0]!.roles[1] = { + ...song.sections[0]!.roles[1]!, + id: "bass-guitar" + }; + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("treats the same named role across sections as one part", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections.push({ + ...structuredClone(verse), + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }); + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("reports unavailable when repeated section copies disagree about selected practice state", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + chorus.roles[0] = { + ...chorus.roles[0]!, + practiceProgress: 40 + }; + song.sections.push(chorus); + + expect(firstUnloggedPractice(song, "bass-guitar")).toEqual({ kind: "unavailable" }); + }); + + it("reports unavailable for a selected malformed practice mark instead of claiming it is logged", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 150 as unknown as number + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "unavailable" }); + }); + + it("returns all-logged only when every named role has trustworthy consistent marks", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "all-logged" }); + }); + + it("reports unavailable rather than all-logged when the remaining evidence is malformed", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index === 2 ? (150 as unknown as number) : index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); + }); + + it("rejects inherited identity and practice evidence", () => { + const inheritedRole = Object.create({ + id: "ghost-role", + name: "Ghost Role", + practiceProgress: 40 + }) as Record; + const song = { + sections: [ + { + label: "verse", + roles: [inheritedRole] + } + ] + } as unknown as RehearsalSong; + + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); + }); + + it("fails closed on malformed runtime roots and collections", () => { + for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { + expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toEqual({ kind: "unavailable" }); + } + }); +}); + +describe("fillUnloggedPracticeCopy", () => { + it("replaces tokens without inheriting object members", () => { + expect( + fillUnloggedPracticeCopy("{roleName} in {sectionLabel} before {sectionLabel}.", { + roleName: "Bass Guitar", + sectionLabel: "verse" + }) + ).toBe("Bass Guitar in verse before verse."); + expect(fillUnloggedPracticeCopy("Check {toString} before {missingToken}.", { roleName: "Bass Guitar" })).toBe( + "Check {toString} before {missingToken}." + ); + }); +}); From bf1170db4a62e6f77f9e4dd30bbebbb40a6d8557 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:45:16 +0900 Subject: [PATCH 09/35] docs(workspace): consolidate unlogged practice contract --- docs/doctoring/first-unlogged-practice.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/doctoring/first-unlogged-practice.md diff --git a/docs/doctoring/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md new file mode 100644 index 000000000..e864173b3 --- /dev/null +++ b/docs/doctoring/first-unlogged-practice.md @@ -0,0 +1,15 @@ +# First unlogged practice pass + +The ready workspace names the first part that still has no stored `practiceProgress` so a player can select it and record tonight's first pass. + +## Authority + +`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires owned role/section identity, allows the same role id to recur across sections only when its display name remains consistent, treats a role as unlogged only when every admitted section copy omits `practiceProgress`, and rejects duplicate ids inside one section or malformed/conflicting practice evidence instead of inventing a pass. + +PR `#1107` owns both the selected-part next-action flow and this unlogged-pass follow-on after consolidation from `#1148`. This slice does not start playback (`#961`) or change MIR (`#828` / `#770`). + +## Security notes + +- Untrusted input: `practiceProgress` and role/section identity inside a loaded project payload. +- Trust boundary: project JSON → lexical admission → React copy. +- Safe failure: inherited identity, malformed collections, conflicting section copies, and malformed marks never become rehearsal authority; no filesystem, URL, subprocess, IPC, or network dereference is added. From c9f25da46c632e30b785360c039c42382dd61d06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:45:55 +0900 Subject: [PATCH 10/35] feat(i18n): consolidate unlogged practice copy --- apps/desktop/src/locales/en/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 7b13b4023..f6337ac12 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,11 @@ "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.", + "workspaceFirstUnloggedPracticeTitle": "Tonight's first unlogged pass", + "workspaceFirstUnloggedPracticeCheck": "{roleName} in {sectionLabel} has no practice logged yet. Select that part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeMissing": "Every named part already has a practice mark. Keep going until the room is ready.", + "workspaceFirstUnloggedPracticeSelectedReady": "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeUnavailable": "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged.", "workspacePracticeProgressStart": "Check {roleName}'s first range, then mark this part started.", "workspacePracticeProgressContinue": "Keep practicing {roleName} until this part is ready for the room.", "workspacePracticeProgressReadyNext": "{roleName} is ready. Switch to {nextRoleName} and check that part's range.", From d52713033ed22c6418bfbc75c4ccc120d8e25785 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:46:44 +0900 Subject: [PATCH 11/35] feat(i18n): consolidate unlogged practice Korean copy --- apps/desktop/src/locales/ko/common.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 7c4934239..9e89303be 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,11 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstUnloggedPracticeTitle": "오늘 아직 기록 안 된 첫 연습", + "workspaceFirstUnloggedPracticeCheck": "{sectionLabel}의 {roleName}은 아직 연습 기록이 없습니다. 그 파트를 선택하고 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeMissing": "이름 있는 파트는 모두 연습 기록이 있습니다. 방이 준비될 때까지 이어서 연습하세요.", + "workspaceFirstUnloggedPracticeSelectedReady": "이 파트는 이미 연습 기록이 있습니다. 아직 기록 안 된 다음 파트로 바꿔 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeUnavailable": "이 파트의 연습 진행 정보가 서로 맞지 않거나 유효하지 않습니다. 기록된 것으로 보기 전에 연습 표시를 확인하세요.", "workspacePracticeProgressStart": "{roleName}의 첫 음역을 확인한 다음, 이 파트를 시작했다고 표시하세요.", "workspacePracticeProgressContinue": "{roleName}을 합주실에서 바로 쓸 수 있을 때까지 계속 연습하세요.", "workspacePracticeProgressReadyNext": "{roleName}은 준비됐습니다. {nextRoleName} 파트로 바꿔 그 파트의 음역을 확인하세요.", From d2f63f61a0611aa6140683fc8e388b258d567fcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:48:32 +0900 Subject: [PATCH 12/35] feat(workspace): consolidate first unlogged practice callout --- .../src/features/workspace/Workspace.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 560aa7f94..8c4441598 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 { fillUnloggedPracticeCopy, firstUnloggedPractice } from "./firstUnloggedPractice"; import { practiceProgressNextAction, type PracticeProgressNextAction @@ -181,6 +182,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstUnlogged = useMemo(() => firstUnloggedPractice(song, activeRole), [activeRole, song]); + const firstUnloggedCopy = + firstUnlogged.kind === "unlogged" + ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), { + roleName: firstUnlogged.roleName, + sectionLabel: firstUnlogged.sectionLabel + }) + : firstUnlogged.kind === "selected-logged" + ? t("workspaceFirstUnloggedPracticeSelectedReady") + : firstUnlogged.kind === "all-logged" + ? t("workspaceFirstUnloggedPracticeMissing") + : t("workspaceFirstUnloggedPracticeUnavailable"); const practiceNext = useMemo( () => practiceProgressNextAction(song, activeRole), [activeRole, song] @@ -338,6 +351,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstUnloggedPracticeTitle")}

+

{firstUnloggedCopy}

+
+

{t("workspaceSongTimelineLabel")}

From 7e684bcc69079fb0cf006aa38711b7e319e23922 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:59:02 +0900 Subject: [PATCH 13/35] fix(workspace): stop inventing a next unlogged pass --- .../workspace/firstUnloggedPractice.test.ts | 22 ++++++++++++++- .../workspace/firstUnloggedPractice.ts | 27 ++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts index f97e306a2..34540b539 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -50,7 +50,7 @@ describe("firstUnloggedPractice", () => { }); }); - it("distinguishes a selected part with a trustworthy practice mark", () => { + it("distinguishes a selected part with a trustworthy practice mark while another unlogged part remains", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[2] = { ...song.sections[0]!.roles[2]!, @@ -60,6 +60,26 @@ describe("firstUnloggedPractice", () => { expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" }); }); + it("reports all logged when the selected part is logged and no trustworthy unlogged part remains", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index * 40 + })); + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "all-logged" }); + }); + + it("does not claim all logged when selected-mode remainder evidence is malformed", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index === 1 ? (150 as unknown as number) : index * 40 + })); + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "unavailable" }); + }); + it("skips malformed marks and duplicate role ids inside one section", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index 37c6e47fc..2fea1fc30 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -166,10 +166,31 @@ export function firstUnloggedPractice( roleName: evidence.roleName }; } - if (isConsistentlyLogged(evidence.marks)) { - return { kind: "selected-logged" }; + if (!isConsistentlyLogged(evidence.marks)) { + return { kind: "unavailable" }; } - return { kind: "unavailable" }; + + for (const roleId of roleOrder) { + if (roleId === activeRole || invalidRoleIds.has(roleId)) { + continue; + } + const nextEvidence = evidenceByRole.get(roleId); + if (!nextEvidence) { + hasInvalidEvidence = true; + continue; + } + if (isConsistentlyUnlogged(nextEvidence.marks)) { + return { kind: "selected-logged" }; + } + if (!isConsistentlyLogged(nextEvidence.marks)) { + hasInvalidEvidence = true; + } + } + + if (roleOrder.length === 0 || invalidRoleIds.size > 0 || hasInvalidEvidence) { + return { kind: "unavailable" }; + } + return { kind: "all-logged" }; } for (const roleId of roleOrder) { From 6de6e6bf2bb8b3016d10135fb10e3ccf4c3ca31f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:02:41 +0900 Subject: [PATCH 14/35] test(workspace): reject false practice completion states --- .../Workspace.first-unlogged.test.tsx | 60 +++++++++++++++++++ .../workspace/firstUnloggedPractice.test.ts | 24 ++++++++ 2 files changed, 84 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx new file mode 100644 index 000000000..23f70d172 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +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 first-unlogged practice admission", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("shows completion instead of inventing another pass after a logged part is selected", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + practiceProgress: 100 + })); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("first-unlogged-practice"); + expect(callout).toHaveTextContent("Every named part already has a practice mark."); + expect(callout).not.toHaveTextContent("Switch to the next unlogged part"); + }); + + it("keeps an owned undefined optional practice mark in the unlogged path", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + Object.defineProperty(song.sections[0]!.roles[0]!, "practiceProgress", { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + song.sections[0]!.roles[1] = { + ...song.sections[0]!.roles[1]!, + practiceProgress: 100 + }; + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 100 + }; + + render(); + + expect(screen.getByTestId("first-unlogged-practice")).toHaveTextContent( + "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts index 34540b539..abb81786a 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -28,6 +28,30 @@ describe("firstUnloggedPractice", () => { }); }); + it("treats an owned undefined optional mark as unlogged", () => { + const song = createDemoRehearsalSong(); + Object.defineProperty(song.sections[0]!.roles[0]!, "practiceProgress", { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + song.sections[0]!.roles[1] = { + ...song.sections[0]!.roles[1]!, + practiceProgress: 40 + }; + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 40 + }; + + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + it("skips parts that already own a 0–100 mark", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { From 50f2a2bf5b3da5780e2602d70e647f27c7e7db4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:03:45 +0900 Subject: [PATCH 15/35] fix(workspace): admit undefined optional practice marks --- apps/desktop/src/features/workspace/firstUnloggedPractice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index 2fea1fc30..a78f42af9 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -40,7 +40,7 @@ export function hasLoggedPracticeProgress(value: unknown): boolean { /** Admit one role-copy practice mark without granting inherited values authority. */ function practiceMark(roleValue: Record): PracticeMark { - if (!owns(roleValue, "practiceProgress")) { + if (!owns(roleValue, "practiceProgress") || roleValue.practiceProgress === undefined) { return { kind: "unlogged" }; } const value = roleValue.practiceProgress; From d83dabcedc8714069d0a7682c96da245e52eb0aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:07:17 +0900 Subject: [PATCH 16/35] test(workspace): preserve explicit optional practice marks --- .../Workspace.first-unlogged.test.tsx | 18 ++++++++++++++++ .../practiceProgressNextAction.test.ts | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx index 23f70d172..51374ee10 100644 --- a/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx @@ -57,4 +57,22 @@ describe("Workspace first-unlogged practice admission", () => { "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass." ); }); + + it("keeps the selected next action when the optional mark is explicitly undefined", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + Object.defineProperty(song.sections[0]!.roles[0]!, "practiceProgress", { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("practice-progress-next-action")).toHaveTextContent( + "Check Bass Guitar's first range, then mark this part started." + ); + }); }); diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts index 787f60245..cb6843675 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts @@ -22,6 +22,10 @@ describe("admitPracticeProgress", () => { expect(admitPracticeProgress({ id: "bass-guitar", name: "Bass Guitar" })).toBe(0); }); + it("treats an explicit undefined optional progress mark as not started", () => { + expect(admitPracticeProgress({ practiceProgress: undefined })).toBe(0); + }); + it("admits a finite percentage in 0–100", () => { expect(admitPracticeProgress({ practiceProgress: 0 })).toBe(0); expect(admitPracticeProgress({ practiceProgress: 50 })).toBe(50); @@ -51,6 +55,23 @@ describe("practiceProgressNextAction", () => { }); }); + it("keeps an explicitly undefined optional mark on the selected start path", () => { + const song = createDemoRehearsalSong(); + Object.defineProperty(song.sections[0]!.roles[0]!, "practiceProgress", { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + + expect(practiceProgressNextAction(song, "bass-guitar")).toEqual({ + kind: "start", + roleId: "bass-guitar", + roleName: "Bass Guitar", + progress: 0 + }); + }); + it("names the continue step while the selected part is still below ready", () => { const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 50 }); From 1a4208d55b924fca7ade82766d4f638f69e80127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:07:41 +0900 Subject: [PATCH 17/35] fix(workspace): align optional practice admission --- .../features/workspace/practiceProgressNextAction.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts index 6875f0ad1..b4f18e37d 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -19,12 +19,16 @@ function isRuntimeObject(value: unknown): value is Record { /** * Admit an own-property practice-progress percentage. * - * Missing progress means the part has not been marked started. Inherited, - * non-finite, or out-of-range values fail closed so a prototype member or - * corrupt project field cannot become rehearsal authority. + * Missing or explicitly undefined optional progress means the part has not + * been marked started. Inherited, non-finite, or out-of-range values fail + * closed so a prototype member or corrupt project field cannot become + * rehearsal authority. */ export function admitPracticeProgress(roleValue: Record): number | null { - if (!Object.prototype.hasOwnProperty.call(roleValue, "practiceProgress")) { + if ( + !Object.prototype.hasOwnProperty.call(roleValue, "practiceProgress") || + roleValue.practiceProgress === undefined + ) { return 0; } const value = roleValue.practiceProgress; From d2483acdba9a185f009b5ade229985b87ce8528f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:11:00 +0900 Subject: [PATCH 18/35] test(workspace): keep the next unlogged part named --- .../workspace/Workspace.first-unlogged.test.tsx | 16 ++++++++++++++++ .../workspace/firstUnloggedPractice.test.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx index 51374ee10..a500eb908 100644 --- a/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx @@ -75,4 +75,20 @@ describe("Workspace first-unlogged practice admission", () => { "Check Bass Guitar's first range, then mark this part started." ); }); + + it("names the next unlogged part instead of sending a player back to role hunting", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 0 + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-unlogged-practice")).toHaveTextContent( + "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass." + ); + }); }); diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts index abb81786a..c82171c76 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -74,14 +74,18 @@ describe("firstUnloggedPractice", () => { }); }); - it("distinguishes a selected part with a trustworthy practice mark while another unlogged part remains", () => { + it("names the next trustworthy unlogged part when the selected part has a mark", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[2] = { ...song.sections[0]!.roles[2]!, practiceProgress: 0 }; - expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" }); + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); }); it("reports all logged when the selected part is logged and no trustworthy unlogged part remains", () => { From f5c56052291a38623a8de2346d42f69d97b55ccf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:11:34 +0900 Subject: [PATCH 19/35] fix(workspace): keep the next unlogged part named --- .../desktop/src/features/workspace/firstUnloggedPractice.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index a78f42af9..bef058712 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -180,7 +180,11 @@ export function firstUnloggedPractice( continue; } if (isConsistentlyUnlogged(nextEvidence.marks)) { - return { kind: "selected-logged" }; + return { + kind: "unlogged", + sectionLabel: nextEvidence.firstSectionLabel, + roleName: nextEvidence.roleName + }; } if (!isConsistentlyLogged(nextEvidence.marks)) { hasInvalidEvidence = true; From 594ea4a01054372ed289374a47c9d2ac284c937c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:04:29 +0900 Subject: [PATCH 20/35] refactor(workspace): remove unreachable first-unlogged state --- apps/desktop/src/features/workspace/firstUnloggedPractice.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index bef058712..d252c2899 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -8,7 +8,6 @@ export type FirstUnloggedPractice = sectionLabel: string; roleName: string; } - | { kind: "selected-logged" } | { kind: "all-logged" } | { kind: "unavailable" }; From 95012f6eab9734b0fb3773f69b5d97b12c1476dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:05:38 +0900 Subject: [PATCH 21/35] refactor(workspace): remove unreachable first-unlogged render branch --- apps/desktop/src/features/workspace/Workspace.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 8c4441598..e62e998c2 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -189,11 +189,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp roleName: firstUnlogged.roleName, sectionLabel: firstUnlogged.sectionLabel }) - : firstUnlogged.kind === "selected-logged" - ? t("workspaceFirstUnloggedPracticeSelectedReady") - : firstUnlogged.kind === "all-logged" - ? t("workspaceFirstUnloggedPracticeMissing") - : t("workspaceFirstUnloggedPracticeUnavailable"); + : firstUnlogged.kind === "all-logged" + ? t("workspaceFirstUnloggedPracticeMissing") + : t("workspaceFirstUnloggedPracticeUnavailable"); const practiceNext = useMemo( () => practiceProgressNextAction(song, activeRole), [activeRole, song] @@ -566,4 +564,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file From 55cb3c7495d62aada4e0340d1298a0c78be81a90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:08:31 +0900 Subject: [PATCH 22/35] test(workspace): reject inherited practice-progress authority --- .../workspace/practiceProgressNextAction.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts index cb6843675..c7a3932c1 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts @@ -32,9 +32,17 @@ describe("admitPracticeProgress", () => { expect(admitPracticeProgress({ practiceProgress: 100 })).toBe(100); }); - it("fails closed on inherited, non-finite, or out-of-range progress", () => { + it("fails closed on inherited, trapped, non-finite, or out-of-range progress", () => { const inherited = Object.create({ practiceProgress: 40 }) as Record; - expect(admitPracticeProgress(inherited)).toBe(0); + expect(admitPracticeProgress(inherited)).toBeNull(); + + const trapped = new Proxy>({}, { + has() { + throw new Error("untrusted has trap"); + } + }); + expect(admitPracticeProgress(trapped)).toBeNull(); + expect(admitPracticeProgress({ practiceProgress: Number.NaN })).toBeNull(); expect(admitPracticeProgress({ practiceProgress: Number.POSITIVE_INFINITY })).toBeNull(); expect(admitPracticeProgress({ practiceProgress: -1 })).toBeNull(); From ad5b4504b571e056cf8d31b0661fe7a4516f1308 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:08:55 +0900 Subject: [PATCH 23/35] fix(workspace): fail closed on inherited practice progress --- .../workspace/practiceProgressNextAction.ts | 174 +++++++++--------- 1 file changed, 88 insertions(+), 86 deletions(-) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts index b4f18e37d..c10699a93 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -25,10 +25,14 @@ function isRuntimeObject(value: unknown): value is Record { * rehearsal authority. */ export function admitPracticeProgress(roleValue: Record): number | null { - if ( - !Object.prototype.hasOwnProperty.call(roleValue, "practiceProgress") || - roleValue.practiceProgress === undefined - ) { + if (!Object.prototype.hasOwnProperty.call(roleValue, "practiceProgress")) { + try { + return "practiceProgress" in roleValue ? null : 0; + } catch { + return null; + } + } + if (roleValue.practiceProgress === undefined) { return 0; } const value = roleValue.practiceProgress; @@ -74,85 +78,83 @@ function namedSongRoles(songValue: Record): NamedRoleCatalog | } sectionRoleIds.add(roleId); - const knownName = namedRoles.get(roleId); - if (knownName && knownName !== roleName) { + const existingName = namedRoles.get(roleId); + if (existingName && existingName !== roleName) { return null; } namedRoles.set(roleId, roleName); } } - return namedRoles.size > 0 ? namedRoles : null; + return namedRoles; } -/** - * Return one consistent progress value for a named part, or fail closed. - * - * Workspace writes the same percentage onto every section copy of a role. - * Conflicting copies are not rehearsal authority. - */ -function consistentRoleProgress( +type RoleProgress = { + roleId: string; + roleName: string; + progress: number; +}; + +/** Return trustworthy role-wide progress evidence for each named part. */ +function roleProgressCatalog( songValue: Record, - roleId: string -): number | null { + namedRoles: NamedRoleCatalog +): Map | null { if (!Array.isArray(songValue.sections)) { return null; } - let admitted: number | null = null; - let seen = false; + const catalog = new Map(); + for (const [roleId, roleName] of namedRoles) { + let roleProgress: number | undefined; + let sawRole = false; - for (const sectionValue of songValue.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { - return null; - } - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { return null; } - if (meaningfulRangeText(roleValue.id) !== roleId) { - continue; - } - const progress = admitPracticeProgress(roleValue); - if (progress === null) { - return null; - } - if (!seen) { - admitted = progress; - seen = true; - continue; - } - if (admitted !== progress) { - return null; + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || roleValue.id !== roleId) { + continue; + } + sawRole = true; + const progress = admitPracticeProgress(roleValue); + if (progress === null) { + return null; + } + if (roleProgress === undefined) { + roleProgress = progress; + continue; + } + if (roleProgress !== progress) { + return null; + } } } + + if (!sawRole || roleProgress === undefined) { + return null; + } + catalog.set(roleId, { roleId, roleName, progress: roleProgress }); } - return seen ? admitted : 0; + return catalog; } -/** Return whether a named part has at least one admitted playable range. */ +/** Return whether a named part has at least one trustworthy playable range. */ function hasPlayableRange(song: RehearsalSong, roleId: string): boolean { - return firstRangeSqueeze(song, roleId) !== null; + return firstRangeSqueeze(song, roleId)?.roleId === roleId; } /** - * Pick tonight's next practice step after a named part is selected. - * - * A part that has not been marked started is told to check its first range - * only when a playable range is actually admitted. A part still below 100% - * is told to keep practicing until it is ready for the room. A part marked - * ready names the next named part that is not ready only when that part also - * has a playable range. When every named part is ready, the next action is to - * download tonight's cue sheet and send it to the group. This is not a - * leftover, come-in, tacet, or MIR product. + * Resolve the next concrete rehearsal action from current named-part progress. * - * Inherited or out-of-range progress, unnamed roles, conflicting section - * copies, malformed roots, and actions that depend on unavailable ranges fail - * closed instead of presenting an impossible rehearsal instruction. + * Action copy only references a playable range when that range is available. + * Corrupt, ambiguous, inherited, or contradictory project evidence returns + * `null` instead of manufacturing a rehearsal instruction. */ export function practiceProgressNextAction( - song: RehearsalSong | unknown, + song: unknown, activeRole: string | null ): PracticeProgressNextAction | null { if (!activeRole || !isRuntimeObject(song)) { @@ -163,56 +165,56 @@ export function practiceProgressNextAction( if (!namedRoles || !namedRoles.has(activeRole)) { return null; } - - const progress = consistentRoleProgress(song, activeRole); - if (progress === null) { + const progressCatalog = roleProgressCatalog(song, namedRoles); + if (!progressCatalog) { return null; } - const roleName = namedRoles.get(activeRole); - if (!roleName) { + const selected = progressCatalog.get(activeRole); + if (!selected) { return null; } - - if (progress < 100) { - if (progress <= 0 && !hasPlayableRange(song as RehearsalSong, activeRole)) { + if (selected.progress === 0) { + if (!hasPlayableRange(song as RehearsalSong, activeRole)) { return null; } return { - kind: progress <= 0 ? "start" : "continue", - roleId: activeRole, - roleName, - progress + kind: "start", + roleId: selected.roleId, + roleName: selected.roleName, + progress: selected.progress + }; + } + if (selected.progress < 100) { + return { + kind: "continue", + roleId: selected.roleId, + roleName: selected.roleName, + progress: selected.progress }; } - for (const [roleId, nextRoleName] of namedRoles) { - if (roleId === activeRole) { + for (const nextRole of progressCatalog.values()) { + if (nextRole.roleId === activeRole || nextRole.progress >= 100) { continue; } - const nextProgress = consistentRoleProgress(song, roleId); - if (nextProgress === null) { + if (!hasPlayableRange(song as RehearsalSong, nextRole.roleId)) { return null; } - if (nextProgress < 100) { - if (!hasPlayableRange(song as RehearsalSong, roleId)) { - return null; - } - return { - kind: "ready-next", - roleId: activeRole, - roleName, - progress, - nextRoleId: roleId, - nextRoleName - }; - } + return { + kind: "ready-next", + roleId: selected.roleId, + roleName: selected.roleName, + progress: selected.progress, + nextRoleId: nextRole.roleId, + nextRoleName: nextRole.roleName + }; } return { kind: "ready-done", - roleId: activeRole, - roleName, - progress + roleId: selected.roleId, + roleName: selected.roleName, + progress: selected.progress }; } From af8142c952ad8dce9d11997a277ce8ec2077a64e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:09:32 +0900 Subject: [PATCH 24/35] repair(workspace): keep inherited-progress fix minimal --- .../workspace/practiceProgressNextAction.ts | 162 +++++++++--------- 1 file changed, 82 insertions(+), 80 deletions(-) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts index c10699a93..d34fed241 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -78,83 +78,85 @@ function namedSongRoles(songValue: Record): NamedRoleCatalog | } sectionRoleIds.add(roleId); - const existingName = namedRoles.get(roleId); - if (existingName && existingName !== roleName) { + const knownName = namedRoles.get(roleId); + if (knownName && knownName !== roleName) { return null; } namedRoles.set(roleId, roleName); } } - return namedRoles; + return namedRoles.size > 0 ? namedRoles : null; } -type RoleProgress = { - roleId: string; - roleName: string; - progress: number; -}; - -/** Return trustworthy role-wide progress evidence for each named part. */ -function roleProgressCatalog( +/** + * Return one consistent progress value for a named part, or fail closed. + * + * Workspace writes the same percentage onto every section copy of a role. + * Conflicting copies are not rehearsal authority. + */ +function consistentRoleProgress( songValue: Record, - namedRoles: NamedRoleCatalog -): Map | null { + roleId: string +): number | null { if (!Array.isArray(songValue.sections)) { return null; } - const catalog = new Map(); - for (const [roleId, roleName] of namedRoles) { - let roleProgress: number | undefined; - let sawRole = false; + let admitted: number | null = null; + let seen = false; - for (const sectionValue of songValue.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + for (const sectionValue of songValue.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + return null; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue) || !Object.prototype.hasOwnProperty.call(roleValue, "id")) { return null; } - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue) || roleValue.id !== roleId) { - continue; - } - sawRole = true; - const progress = admitPracticeProgress(roleValue); - if (progress === null) { - return null; - } - if (roleProgress === undefined) { - roleProgress = progress; - continue; - } - if (roleProgress !== progress) { - return null; - } + if (meaningfulRangeText(roleValue.id) !== roleId) { + continue; + } + const progress = admitPracticeProgress(roleValue); + if (progress === null) { + return null; + } + if (!seen) { + admitted = progress; + seen = true; + continue; + } + if (admitted !== progress) { + return null; } } - - if (!sawRole || roleProgress === undefined) { - return null; - } - catalog.set(roleId, { roleId, roleName, progress: roleProgress }); } - return catalog; + return seen ? admitted : 0; } -/** Return whether a named part has at least one trustworthy playable range. */ +/** Return whether a named part has at least one admitted playable range. */ function hasPlayableRange(song: RehearsalSong, roleId: string): boolean { - return firstRangeSqueeze(song, roleId)?.roleId === roleId; + return firstRangeSqueeze(song, roleId) !== null; } /** - * Resolve the next concrete rehearsal action from current named-part progress. + * Pick tonight's next practice step after a named part is selected. + * + * A part that has not been marked started is told to check its first range + * only when a playable range is actually admitted. A part still below 100% + * is told to keep practicing until it is ready for the room. A part marked + * ready names the next named part that is not ready only when that part also + * has a playable range. When every named part is ready, the next action is to + * download tonight's cue sheet and send it to the group. This is not a + * leftover, come-in, tacet, or MIR product. * - * Action copy only references a playable range when that range is available. - * Corrupt, ambiguous, inherited, or contradictory project evidence returns - * `null` instead of manufacturing a rehearsal instruction. + * Inherited or out-of-range progress, unnamed roles, conflicting section + * copies, malformed roots, and actions that depend on unavailable ranges fail + * closed instead of presenting an impossible rehearsal instruction. */ export function practiceProgressNextAction( - song: unknown, + song: RehearsalSong | unknown, activeRole: string | null ): PracticeProgressNextAction | null { if (!activeRole || !isRuntimeObject(song)) { @@ -165,56 +167,56 @@ export function practiceProgressNextAction( if (!namedRoles || !namedRoles.has(activeRole)) { return null; } - const progressCatalog = roleProgressCatalog(song, namedRoles); - if (!progressCatalog) { + + const progress = consistentRoleProgress(song, activeRole); + if (progress === null) { return null; } - const selected = progressCatalog.get(activeRole); - if (!selected) { + const roleName = namedRoles.get(activeRole); + if (!roleName) { return null; } - if (selected.progress === 0) { - if (!hasPlayableRange(song as RehearsalSong, activeRole)) { + + if (progress < 100) { + if (progress <= 0 && !hasPlayableRange(song as RehearsalSong, activeRole)) { return null; } return { - kind: "start", - roleId: selected.roleId, - roleName: selected.roleName, - progress: selected.progress - }; - } - if (selected.progress < 100) { - return { - kind: "continue", - roleId: selected.roleId, - roleName: selected.roleName, - progress: selected.progress + kind: progress <= 0 ? "start" : "continue", + roleId: activeRole, + roleName, + progress }; } - for (const nextRole of progressCatalog.values()) { - if (nextRole.roleId === activeRole || nextRole.progress >= 100) { + for (const [roleId, nextRoleName] of namedRoles) { + if (roleId === activeRole) { continue; } - if (!hasPlayableRange(song as RehearsalSong, nextRole.roleId)) { + const nextProgress = consistentRoleProgress(song, roleId); + if (nextProgress === null) { return null; } - return { - kind: "ready-next", - roleId: selected.roleId, - roleName: selected.roleName, - progress: selected.progress, - nextRoleId: nextRole.roleId, - nextRoleName: nextRole.roleName - }; + if (nextProgress < 100) { + if (!hasPlayableRange(song as RehearsalSong, roleId)) { + return null; + } + return { + kind: "ready-next", + roleId: activeRole, + roleName, + progress, + nextRoleId: roleId, + nextRoleName + }; + } } return { kind: "ready-done", - roleId: selected.roleId, - roleName: selected.roleName, - progress: selected.progress + roleId: activeRole, + roleName, + progress }; } From 955ddd4c75a954d47df348c7d80b861616e4257e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 09:10:20 +0900 Subject: [PATCH 25/35] test(workspace): cover inherited progress at action boundary --- .../workspace/practiceProgressNextAction.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts index c7a3932c1..296dee453 100644 --- a/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts @@ -80,6 +80,16 @@ describe("practiceProgressNextAction", () => { }); }); + it("does not turn inherited selected-part progress into a rehearsal action", () => { + const song = createDemoRehearsalSong(); + const bassRole = song.sections[0]!.roles[0]!; + const inheritedProgressRole = Object.create({ practiceProgress: 40 }) as typeof bassRole; + Object.assign(inheritedProgressRole, bassRole); + song.sections[0]!.roles[0] = inheritedProgressRole; + + expect(practiceProgressNextAction(song, "bass-guitar")).toBeNull(); + }); + it("names the continue step while the selected part is still below ready", () => { const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 50 }); From e4e2a77170063e0d8e10a664da7f94be434bbac3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 13:47:50 +0900 Subject: [PATCH 26/35] test(workspace): preserve progress boundary explanations --- .../src/features/workspace/PracticeProgress.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx index 6d51d4d2a..8b5d8ad3f 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx @@ -14,8 +14,9 @@ describe("PracticeProgress", () => { render(); expect(screen.getByText("0%")).toBeTruthy(); - const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }) as HTMLButtonElement; + const decreaseBtn = screen.getByRole("button", { name: "practiceProgressAtMin" }) as HTMLButtonElement; expect(decreaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(decreaseBtn).toHaveAttribute("title", "practiceProgressAtMin"); const clickEvent = createEvent.click(decreaseBtn); fireEvent(decreaseBtn, clickEvent); @@ -116,8 +117,9 @@ describe("PracticeProgress", () => { const handleChange = vi.fn(); render(); - const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }) as HTMLButtonElement; + const increaseBtn = screen.getByRole("button", { name: "practiceProgressAtMax" }) as HTMLButtonElement; expect(increaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(increaseBtn).toHaveAttribute("title", "practiceProgressAtMax"); const clickEvent = createEvent.click(increaseBtn); fireEvent(increaseBtn, clickEvent); From d633fc87ca9737d369e2c599854355c4cabfa35f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 13:48:13 +0900 Subject: [PATCH 27/35] fix(workspace): explain disabled progress boundaries --- apps/desktop/src/features/workspace/PracticeProgress.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx index eb512e295..1ad0e24be 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -61,8 +61,8 @@ function PracticeProgressComponent({ progress = 0, onChange, nextActionCopy }: P onClick={handleDecrease} aria-disabled={progress <= 0 ? "true" : undefined} className="flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50" - aria-label={t("decreasePracticeProgressLabel")} - title={t("decreasePracticeProgressLabel")} + aria-label={progress <= 0 ? t("practiceProgressAtMin") : t("decreasePracticeProgressLabel")} + title={progress <= 0 ? t("practiceProgressAtMin") : t("decreasePracticeProgressLabel")} >