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..87ffbb180 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 and send it to the group. - 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..a4aec746c 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: "decreasePracticeProgressAtMin" }) as HTMLButtonElement; expect(decreaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(decreaseBtn).toHaveAttribute("title", "decreasePracticeProgressAtMin"); const clickEvent = createEvent.click(decreaseBtn); fireEvent(decreaseBtn, clickEvent); @@ -29,6 +30,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(); @@ -101,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: "increasePracticeProgressAtMax" }) as HTMLButtonElement; expect(increaseBtn).toHaveAttribute("aria-disabled", "true"); + expect(increaseBtn).toHaveAttribute("title", "increasePracticeProgressAtMax"); const clickEvent = createEvent.click(increaseBtn); fireEvent(increaseBtn, clickEvent); diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx index d10ca94c0..84af7a49a 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,14 +49,20 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP {progress}% + {nextActionCopy ? ( +

+ {nextActionCopy} +

+ ) : null} +
@@ -85,8 +92,8 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP onClick={handleIncrease} aria-disabled={progress >= 100 ? "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("increasePracticeProgressLabel")} - title={t("increasePracticeProgressLabel")} + aria-label={progress >= 100 ? t("increasePracticeProgressAtMax") : t("increasePracticeProgressLabel")} + title={progress >= 100 ? t("increasePracticeProgressAtMax") : t("increasePracticeProgressLabel")} >
)} - + )} @@ -512,4 +564,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ); -} +} \ No newline at end of file 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..c82171c76 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -0,0 +1,229 @@ +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("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] = { + ...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("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: "unlogged", + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + 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] = { + ...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}." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts new file mode 100644 index 000000000..bba064768 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -0,0 +1,229 @@ +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: "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): value is number { + 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") || roleValue.practiceProgress === undefined) { + 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: "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: "unlogged", + sectionLabel: nextEvidence.firstSectionLabel, + roleName: nextEvidence.roleName + }; + } + 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) { + 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); +} 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 + }); + }); +}); 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..296dee453 --- /dev/null +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.test.ts @@ -0,0 +1,189 @@ +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("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); + expect(admitPracticeProgress({ practiceProgress: 100 })).toBe(100); + }); + + it("fails closed on inherited, trapped, non-finite, or out-of-range progress", () => { + const inherited = Object.create({ practiceProgress: 40 }) as Record; + 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(); + 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("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("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 }); + + 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..d34fed241 --- /dev/null +++ b/apps/desktop/src/features/workspace/practiceProgressNextAction.ts @@ -0,0 +1,222 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { firstRangeSqueeze, 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 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")) { + try { + return "practiceProgress" in roleValue ? null : 0; + } catch { + return null; + } + } + if (roleValue.practiceProgress === undefined) { + 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; +} + +/** 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 + * 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, malformed roots, and actions that depend on unavailable ranges fail + * closed instead of presenting an impossible rehearsal instruction. + */ +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) { + if (progress <= 0 && !hasPlayableRange(song as RehearsalSong, activeRole)) { + return null; + } + 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) { + if (!hasPlayableRange(song as RehearsalSong, roleId)) { + return null; + } + 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..f9a5cf1ac 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,10 +149,21 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "decreasePracticeProgressAtMin": "Decrease progress — already at 0%", + "increasePracticeProgressAtMax": "Increase progress — already at 100%", "workspaceFirstRangeTitle": "Tonight's first range", "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.", + "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..05fbdb961 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,10 +149,21 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "decreasePracticeProgressAtMin": "진척도 감소 — 이미 0%입니다", + "increasePracticeProgressAtMax": "진척도 증가 — 이미 100%입니다", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "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} 파트로 바꿔 그 파트의 음역을 확인하세요.", + "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/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md new file mode 100644 index 000000000..9008bbb60 --- /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 either omits `practiceProgress` or owns `practiceProgress: undefined`, 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. 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 +```