Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
36733f0
feat(workspace): name the next practice action after a part is selected
seonghobae Aug 31, 2026
1f77c4f
test(workspace): reject impossible range practice actions
seonghobae Sep 2, 2026
33f6754
fix(workspace): fail closed on range-less practice actions
seonghobae Sep 2, 2026
d3a87f5
fix(i18n): make practice handoff natural in Korean
seonghobae Sep 2, 2026
c2eb2bb
docs(changelog): match completed practice handoff
seonghobae Sep 2, 2026
8718184
test(workspace): cover range-less guidance at UI boundary
seonghobae Sep 2, 2026
939692c
feat(workspace): consolidate unlogged practice authority
seonghobae Sep 2, 2026
57cf9a8
test(workspace): consolidate unlogged practice regressions
seonghobae Sep 2, 2026
bf1170d
docs(workspace): consolidate unlogged practice contract
seonghobae Sep 2, 2026
c9f25da
feat(i18n): consolidate unlogged practice copy
seonghobae Sep 2, 2026
d527130
feat(i18n): consolidate unlogged practice Korean copy
seonghobae Sep 2, 2026
d2f63f6
feat(workspace): consolidate first unlogged practice callout
seonghobae Sep 2, 2026
7e684bc
fix(workspace): stop inventing a next unlogged pass
seonghobae Sep 2, 2026
6de6e6b
test(workspace): reject false practice completion states
seonghobae Sep 2, 2026
50f2a2b
fix(workspace): admit undefined optional practice marks
seonghobae Sep 2, 2026
d83dabc
test(workspace): preserve explicit optional practice marks
seonghobae Sep 2, 2026
1a4208d
fix(workspace): align optional practice admission
seonghobae Sep 2, 2026
d2483ac
test(workspace): keep the next unlogged part named
seonghobae Sep 2, 2026
f5c5605
fix(workspace): keep the next unlogged part named
seonghobae Sep 2, 2026
594ea4a
refactor(workspace): remove unreachable first-unlogged state
seonghobae Sep 3, 2026
95012f6
refactor(workspace): remove unreachable first-unlogged render branch
seonghobae Sep 3, 2026
55cb3c7
test(workspace): reject inherited practice-progress authority
seonghobae Sep 3, 2026
ad5b450
fix(workspace): fail closed on inherited practice progress
seonghobae Sep 3, 2026
af8142c
repair(workspace): keep inherited-progress fix minimal
seonghobae Sep 3, 2026
955ddd4
test(workspace): cover inherited progress at action boundary
seonghobae Sep 3, 2026
e4e2a77
test(workspace): preserve progress boundary explanations
seonghobae Sep 3, 2026
d633fc8
fix(workspace): explain disabled progress boundaries
seonghobae Sep 3, 2026
2525476
feat(i18n): add progress boundary copy in English
seonghobae Sep 3, 2026
9e04876
feat(i18n): add progress boundary copy in Korean
seonghobae Sep 3, 2026
270b96f
test(workspace): preserve progress action at boundaries
seonghobae Sep 3, 2026
31023f7
fix(workspace): preserve progress control purpose
seonghobae Sep 3, 2026
c22d3f8
fix(i18n): retain action in English boundary names
seonghobae Sep 3, 2026
6134658
fix(i18n): retain action in Korean boundary names
seonghobae Sep 3, 2026
687b0ea
docs(workspace): align unlogged undefined semantics
seonghobae Sep 3, 2026
23c8d21
Merge branch 'develop' into feat/workspace-practice-progress-next-action
opencode-agent[bot] Sep 3, 2026
d6112b7
fix(workspace): preserve practice progress type guard
seonghobae Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/features/workspace/PracticeProgress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ describe("PracticeProgress", () => {
render(<PracticeProgress onChange={handleChange} />);

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);
Expand All @@ -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(
<PracticeProgress
progress={0}
onChange={handleChange}
nextActionCopy="Check Bass Guitar's first range, then mark this part started."
/>
);

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(<PracticeProgress progress={50} onChange={handleChange} />);
Expand Down Expand Up @@ -101,8 +117,9 @@ describe("PracticeProgress", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={100} onChange={handleChange} />);

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);
Expand Down
19 changes: 13 additions & 6 deletions apps/desktop/src/features/workspace/PracticeProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>) => {
Expand Down Expand Up @@ -48,14 +49,20 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP
<span className="text-sm font-semibold text-slate-200">{progress}%</span>
</div>

{nextActionCopy ? (
<p className="mb-3 text-sm leading-6 text-slate-100" data-testid="practice-progress-next-action">
{nextActionCopy}
</p>
) : null}

<div className="flex items-center gap-4">
<button
type="button"
onClick={handleDecrease}
aria-disabled={progress <= 0 ? "true" : undefined}
className="flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
aria-label={t("decreasePracticeProgressLabel")}
title={t("decreasePracticeProgressLabel")}
aria-label={progress <= 0 ? t("decreasePracticeProgressAtMin") : t("decreasePracticeProgressLabel")}
title={progress <= 0 ? t("decreasePracticeProgressAtMin") : t("decreasePracticeProgressLabel")}
>
<Minus className="size-4" aria-hidden="true" />
</button>
Expand Down Expand Up @@ -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")}
>
<Plus className="size-4" aria-hidden="true" />
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<Workspace song={song} />);
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(<Workspace song={song} />);

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(<Workspace song={song} />);
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(<Workspace song={song} />);
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."
);
});
});
Original file line number Diff line number Diff line change
@@ -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(<Workspace song={song} />);
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();
});
});
91 changes: 91 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ function setNavigatorLanguage(language: string) {
});
}

function withProgress(song: RehearsalSong, progressByRoleId: Record<string, number>): 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);
Expand Down Expand Up @@ -196,6 +210,83 @@ describe("Workspace", () => {
);
});

it("names the start step after the unstarted bass part is selected", () => {
setNavigatorLanguage("en-US");

render(<Workspace song={createDemoRehearsalSong()} />);
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(<Workspace song={song} />);
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(<Workspace song={song} />);
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(<Workspace song={song} />);
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(<Workspace song={song} />);
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();
Expand Down
Loading
Loading