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")}
>
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..a500eb908
--- /dev/null
+++ b/apps/desktop/src/features/workspace/Workspace.first-unlogged.test.tsx
@@ -0,0 +1,94 @@
+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."
+ );
+ });
+
+ 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."
+ );
+ });
+
+ 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/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();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..7da9c3bd5 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -16,6 +16,20 @@ function setNavigatorLanguage(language: string) {
});
}
+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("Workspace", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
@@ -196,6 +210,83 @@ describe("Workspace", () => {
);
});
+ it("names the start step after the unstarted bass part is selected", () => {
+ setNavigatorLanguage("en-US");
+
+ 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."
+ );
+ });
+
+ it("names the continue step while the selected bass part is still below ready", () => {
+ setNavigatorLanguage("en-US");
+ const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 50 });
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("practice-progress-next-action")).toHaveTextContent(
+ "Keep practicing Bass Guitar until this part is ready for the room."
+ );
+ });
+
+ it("names the next unready part after bass is marked ready", () => {
+ setNavigatorLanguage("en-US");
+ const song = withProgress(createDemoRehearsalSong(), { "bass-guitar": 100 });
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("practice-progress-next-action")).toHaveTextContent(
+ "Bass Guitar is ready. Switch to Keyboard 1 Right Hand and check that part's range."
+ );
+ });
+
+ it("names the cue-sheet send when every named part is marked ready", () => {
+ setNavigatorLanguage("en-US");
+ const song = withProgress(createDemoRehearsalSong(), {
+ "bass-guitar": 100,
+ "keys-right": 100,
+ "lead-vocal": 100
+ });
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.getByTestId("practice-progress-next-action")).toHaveTextContent(
+ "Every named part is marked ready. Download tonight's cue sheet and send it to the group."
+ );
+ });
+
+ it("hides practice next-action copy when section copies disagree", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections = [
+ {
+ ...song.sections[0]!,
+ id: "verse-1",
+ roles: song.sections[0]!.roles.map((role) =>
+ role.id === "bass-guitar" ? { ...role, practiceProgress: 100 } : role
+ )
+ },
+ {
+ ...song.sections[0]!,
+ id: "chorus-1",
+ roles: song.sections[0]!.roles.map((role) =>
+ role.id === "bass-guitar" ? { ...role, practiceProgress: 40 } : role
+ )
+ }
+ ];
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));
+
+ expect(screen.queryByTestId("practice-progress-next-action")).toBeNull();
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..e62e998c2 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,7 +5,12 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
-import { createTranslator, detectPreferredLocale } from "../../i18n";
+import { fillUnloggedPracticeCopy, firstUnloggedPractice } from "./firstUnloggedPractice";
+import {
+ practiceProgressNextAction,
+ type PracticeProgressNextAction
+} from "./practiceProgressNextAction";
+import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
@@ -52,6 +57,20 @@ function formatStatusLabel(status: string): string {
return status.replaceAll("_", " ");
}
+/** Map a practice-progress step onto bilingual workspace copy. */
+function practiceProgressCopyKey(kind: PracticeProgressNextAction["kind"]): TranslationKey {
+ if (kind === "start") {
+ return "workspacePracticeProgressStart";
+ }
+ if (kind === "continue") {
+ return "workspacePracticeProgressContinue";
+ }
+ if (kind === "ready-next") {
+ return "workspacePracticeProgressReadyNext";
+ }
+ return "workspacePracticeProgressReadyDone";
+}
+
/** Documented. */
function nonBlankText(value: string | undefined): string | undefined {
const trimmed = value?.trim();
@@ -163,6 +182,26 @@ 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 === "all-logged"
+ ? t("workspaceFirstUnloggedPracticeMissing")
+ : t("workspaceFirstUnloggedPracticeUnavailable");
+ const practiceNext = useMemo(
+ () => practiceProgressNextAction(song, activeRole),
+ [activeRole, song]
+ );
+ const practiceNextCopy = practiceNext
+ ? fillRangeCopy(t(practiceProgressCopyKey(practiceNext.kind)), {
+ roleName: practiceNext.roleName,
+ nextRoleName: practiceNext.nextRoleName ?? ""
+ })
+ : undefined;
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -310,6 +349,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)}
-
+
)}
@@ -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
+```