Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@

### Added

- Surface high-priority role and section pairs in the workspace so players can see what to lock in first before rehearsal.
- Let players open a named lock-in pair to select that role and section on the roadmap.
- Let players open a fallback focus-section label to highlight that section on the roadmap.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

### Fixed

- Deduplicate normalized focus-section fallback labels so repeated analysis evidence does not consume all three rehearsal-priority slots or render duplicate list keys.
- Deduplicate lock-in role and section display pairs so a repeated verse label cannot consume the third rehearsal-priority slot.
- Replace empty rehearsal-priority copy that incorrectly told players a role click would name lock-in parts.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ describe("SectionRoadmap", () => {
expect(onSongUpdate).toHaveBeenCalledTimes(1);
});

it("marks the focused section for the lock-in handoff", () => {
const song = createDemoRehearsalSong();

render(<SectionRoadmap song={song} activeRole={null} focusedSectionId="verse-1" />);

expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true");
});

it("does not update when the trimmed chord is unchanged", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucid
interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
focusedSectionId?: string | null;
onSongUpdate?: (song: RehearsalSong) => void;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSongUpdate }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);
Expand Down Expand Up @@ -106,7 +107,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{song.sections.map((section) => (
<Card
key={section.id}
data-testid={`section-roadmap-${section.id}`}
data-focused-section={section.id === focusedSectionId ? "true" : undefined}
className={`w-80 flex-none shrink-0 snap-start overflow-hidden shadow-[0_18px_60px_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_24px_80px_rgba(0,0,0,0.32)] ${
section.id === focusedSectionId
? "ring-2 ring-cyan-300 ring-offset-2 ring-offset-slate-950"
: ""
} ${
section.confidence.level === "low" ? "border-rose-300/30 bg-rose-950/30" : "border-white/10 bg-slate-950/80"
}`}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen, within } 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): void {
Object.defineProperty(navigator, "language", {
configurable: true,
value: language
});
}

describe("Workspace rehearsal-priority focus fallback", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
});

it("deduplicates normalized focus labels while preserving first-occurrence order", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}
song.exportSummary = {
...song.exportSummary,
focusSections: [" verse ", "verse", "VERSE", "bridge", "chorus"]
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(within(priorities).getAllByText(/^verse$/i)).toHaveLength(1);
expect(within(priorities).getByText("bridge")).toBeTruthy();
expect(within(priorities).getByText("chorus")).toBeTruthy();
});
});
63 changes: 63 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
import { Workspace } from "./Workspace";

/**
* Build a Late Night Set with a repeated verse before the chorus so Storybook
* can show display-unique lock-in pairs instead of two identical verse lines.
*/
function createLateNightSetWithRepeatedVerse(): RehearsalSong {
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 = chorus.roles.map((role) => ({
...role,
rehearsalPriority: role.id === "lead-vocal" ? "high" : "low"
}));
const verseRepeat = structuredClone(verse);
verseRepeat.id = "verse-2";
verseRepeat.timeRange = { start: 50, end: 70 };
song.sections = [verse, verseRepeat, chorus];
return song;
}

/**
* Build a song with no priority roles and no focus sections so the empty
* rehearsal-priority card can be inspected in Storybook.
*/
function createEmptyPrioritySong(): RehearsalSong {
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: []
};
return song;
}

const meta = {
title: "Workspace/Rehearsal Priorities",
component: Workspace,
parameters: { layout: "fullscreen" }
} satisfies Meta<typeof Workspace>;

export default meta;
type Story = StoryObj<typeof meta>;

/** Demo song: names Bass Guitar and Keyboard 1 Right Hand on verse. */
export const LockInHighPriorityParts: Story = {
args: { song: createDemoRehearsalSong() }
};

/** Repeated verse plus chorus: the third slot is Lead Vocal · chorus. */
export const DedupedRepeatedVerse: Story = {
args: { song: createLateNightSetWithRepeatedVerse() }
};

/** No priority evidence: honest empty copy that points at the roadmap. */
export const EmptyPriorityCard: Story = {
args: { song: createEmptyPrioritySong() }
};
206 changes: 205 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
Expand Down Expand Up @@ -268,6 +268,210 @@ describe("Workspace", () => {
expect(screen.getByText("협업")).toBeTruthy();
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("먼저 맞춰 볼 것")).toBeTruthy();
expect(screen.getByRole("button", { name: "로드맵에서 verse의 Bass Guitar 보기" })).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});

it("names high-priority role and section pairs to lock in first", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain("Lock in first");
expect(priorities.textContent).toContain("Bass Guitar · verse");
expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse");
expect(priorities.textContent).not.toContain("Lead Vocal · verse");
expect(priorities.textContent).not.toContain("Focus:");
});

it("falls back to medium-priority parts when no high-priority role exists", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = role.rehearsalPriority === "high" ? "low" : role.rehearsalPriority;
}
}

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain("Lock in first");
expect(priorities.textContent).toContain("Lead Vocal · verse");
expect(priorities.textContent).not.toContain("Bass Guitar · verse");
});

it("falls back to focus sections when every role is low priority", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain("Start with this section");
expect(priorities.textContent).toContain("verse");
expect(priorities.textContent).not.toContain("Lock in first");
});

it("does not turn blank or none sentinels into lock-in instructions", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: ["NONE", " ", "none"]
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain(
"No named parts to lock in yet. Pick the first entrance on the section roadmap."
);
expect(priorities.textContent).not.toContain("Open a role on the roadmap");
expect(priorities.textContent).not.toMatch(/NONE/i);
});

it("localizes the empty lock-in copy without promising a role click will fill the card", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();
song.sections = [];
song.exportSummary = {
...song.exportSummary,
focusSections: []
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "합주 우선순위" });
expect(priorities.textContent).toContain("아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.");
expect(priorities.textContent).not.toContain("로드맵에서 역할을 열면");
});

it("keeps repeated verse labels from consuming a third lock-in slot", () => {
setNavigatorLanguage("en-US");
const song = createLateNightSetWithRepeatedVerse();

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain("Lock in first");
expect(priorities.querySelectorAll("li")).toHaveLength(3);
expect(priorities.textContent).toContain("Bass Guitar · verse");
expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse");
expect(priorities.textContent).toContain("Lead Vocal · chorus");
expect(priorities.textContent?.match(/Bass Guitar · verse/g)).toHaveLength(1);
});

it("selects the named role and section when a lock-in pair is activated", () => {
setNavigatorLanguage("en-US");
const song = createLateNightSetWithRepeatedVerse();

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
fireEvent.click(
within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus on the roadmap" })
);

expect(screen.getByRole("tab", { name: "Lead Vocal" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true");
expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true");
});

it("focuses the matching section when a fallback focus label is activated", () => {
setNavigatorLanguage("en-US");
const song = createLateNightSetWithRepeatedVerse();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}
song.exportSummary = {
...song.exportSummary,
focusSections: ["chorus"]
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus on the roadmap" }));

expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true");
expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true");
});

it("does not focus a section when the fallback label has no matching roadmap card", () => {
setNavigatorLanguage("en-US");
const song = createLateNightSetWithRepeatedVerse();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}
song.exportSummary = {
...song.exportSummary,
focusSections: ["bridge"]
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
fireEvent.click(within(priorities).getByRole("button", { name: "Show bridge on the roadmap" }));

expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true");
expect(screen.getByTestId("section-roadmap-chorus-1")).not.toHaveAttribute("data-focused-section", "true");
});

it("falls back to the first section label when every role is low and focus sections are empty", () => {
setNavigatorLanguage("en-US");
const song = createLateNightSetWithRepeatedVerse();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}
song.exportSummary = {
...song.exportSummary,
focusSections: []
};

render(<Workspace song={song} />);

const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" });
expect(priorities.textContent).toContain("Start with this section");
expect(priorities.textContent).toContain("verse");
expect(priorities.textContent).not.toContain("chorus");
expect(priorities.textContent).not.toContain("Lock in first");
});
});

/**
* Build a Late Night Set with verse, chorus, and a second verse that reuses
* the same role names and section label the analysis engine emits for repeats.
*/
function createLateNightSetWithRepeatedVerse(): RehearsalSong {
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 = chorus.roles.map((role) => ({
...role,
rehearsalPriority: role.id === "lead-vocal" ? "high" : "low"
}));
const verseRepeat = structuredClone(verse);
verseRepeat.id = "verse-2";
verseRepeat.timeRange = { start: 50, end: 70 };
song.sections = [verse, verseRepeat, chorus];
return song;
}
Loading
Loading