Skip to content
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@

### 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.
- Scroll the named roadmap card into view when a lock-in pair or focus label is opened.
- 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.
- Omit unmatched focus-section labels so a missing bridge cannot be sold as a roadmap action or clear verse focus.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { SectionRoadmap } from "./SectionRoadmap";

const originalLanguage = window.navigator.language;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

function setNavigatorLanguage(language: string) {
Object.defineProperty(window.navigator, "language", {
Expand All @@ -16,6 +17,7 @@ describe("SectionRoadmap", () => {
afterEach(() => {
setNavigatorLanguage(originalLanguage);
vi.restoreAllMocks();
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});

it("localizes roadmap controls and provenance badges", () => {
Expand Down Expand Up @@ -48,6 +50,19 @@ describe("SectionRoadmap", () => {
expect(onSongUpdate).toHaveBeenCalledTimes(1);
});

it("marks the focused section for the lock-in handoff", () => {
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;

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

const focusedCard = screen.getByTestId("section-roadmap-verse-1");
expect(focusedCard).toHaveAttribute("data-focused-section", "true");
expect(focusedCard).toHaveAttribute("aria-current", "true");
expect(scrollIntoView).toHaveBeenCalled();
});

it("does not update when the trimmed chord is unchanged", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
32 changes: 29 additions & 3 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { useId, useMemo } from "react";
import { useEffect, useId, useMemo, useRef } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
Expand All @@ -10,15 +10,33 @@ 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) {
/**
* Render the horizontal section roadmap and keep the focused card in view.
*/
export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSongUpdate }: SectionRoadmapProps) {
const sectionRoadmapTitleId = useId();
const focusedCardRef = useRef<HTMLDivElement | null>(null);
const locale = useMemo(() => detectPreferredLocale(), []);
const t = useMemo(() => createTranslator(locale), [locale]);

useEffect(() => {
if (!focusedSectionId) {
return;
}
const focusedCard = focusedCardRef.current;
if (typeof focusedCard?.scrollIntoView === "function") {
focusedCard.scrollIntoView({
behavior: "smooth",
inline: "start",
block: "nearest"
});
}
}, [focusedSectionId]);

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
return t("chordEditAriaLabel")
Expand Down Expand Up @@ -106,7 +124,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
{song.sections.map((section) => (
<Card
key={section.id}
ref={section.id === focusedSectionId ? focusedCardRef : undefined}
data-testid={`section-roadmap-${section.id}`}
data-focused-section={section.id === focusedSectionId ? "true" : undefined}
aria-current={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,54 @@
import { render, screen, within } from "@testing-library/react";
import { createDemoRehearsalSong, type RehearsalSong } 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 = createLateNightSetWithChorus();
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" });
const buttons = within(priorities).getAllByRole("button");
expect(buttons.map((button) => button.textContent)).toEqual(["verse", "chorus"]);
expect(within(priorities).queryByText("bridge")).toBeNull();
});
});

/**
* Build a Late Night Set with verse and chorus so unmatched bridge labels can
* be dropped while still proving first-occurrence order for real sections.
*/
function createLateNightSetWithChorus(): RehearsalSong {
const song = createDemoRehearsalSong();
const chorus = structuredClone(song.sections[0]!);
chorus.id = "chorus-1";
chorus.label = "chorus";
chorus.timeRange = { start: 30, end: 50 };
song.sections = [song.sections[0]!, chorus];
return song;
}
86 changes: 86 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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() }
};

/**
* Build a low-priority Late Night Set whose focus list names a missing
* bridge so Storybook can show only the matching verse action.
*/
function createUnmatchedFocusSong(): RehearsalSong {
const song = createLateNightSetWithRepeatedVerse();
for (const section of song.sections) {
for (const role of section.roles) {
role.rehearsalPriority = "low";
}
}
song.exportSummary = {
...song.exportSummary,
focusSections: ["verse", "bridge"]
};
return song;
}

/** Unmatched bridge is omitted; verse remains the only clickable focus. */
export const ActionableFocusLabelsOnly: Story = {
args: { song: createUnmatchedFocusSong() }
};
Loading
Loading