Skip to content
Draft
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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-16

## Brand source

Expand Down Expand Up @@ -84,7 +84,7 @@ Last updated: 2026-03-11
- groove and timing cues relevant to locking the band together
- playable ranges and density or overlap warnings
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- role-specific rehearsal priorities and confidence flags, including named lock-in pairs with first entrance times
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form

## Confidence, edits, and provenance
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,25 @@

### 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.
- Name the first entrance time on each lock-in pair and focus label so players know when to start.
- Mark the activated lock-in pair as the current rehearsal action.
- Highlight the same focused section on the song-structure timeline so the lock-in click has one visible destination.
- 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.
- Keep lock-in aria-labels from rewriting later tokens when a role name contains `{sectionLabel}`.
- Skip smooth roadmap scrolling when the player prefers reduced motion.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down
46 changes: 46 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,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { SectionRoadmap } from "./SectionRoadmap";

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

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

it("localizes roadmap controls and provenance badges", () => {
Expand Down Expand Up @@ -48,6 +52,48 @@ 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).toHaveBeenCalledWith({
behavior: "smooth",
inline: "start",
block: "nearest"
});
});

it("skips smooth scrolling when the player prefers reduced motion", () => {
const song = createDemoRehearsalSong();
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}));
window.matchMedia = matchMedia;

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

expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "auto",
inline: "start",
block: "nearest"
});
});

it("does not update when the trimmed chord is unchanged", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
Expand Down
56 changes: 48 additions & 8 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { useId, useMemo } from "react";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { useEffect, useId, useMemo, useRef } from "react";
import { createTranslator, detectPreferredLocale, interpolateTemplate } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
Expand All @@ -10,21 +10,53 @@ 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) {
/**
* Return whether the player asked the OS to reduce motion.
*
* Instant scroll keeps the named card in view without a horizontal animation
* that can hide the first entrance on a long Late Night Set roadmap.
*/
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/**
* 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: prefersReducedMotion() ? "auto" : "smooth",
inline: "start",
block: "nearest"
});
}
}, [focusedSectionId]);

/** Documented. */
const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => {
return t("chordEditAriaLabel")
.replace("{roleName}", role.name)
.replace("{sectionLabel}", sectionLabel)
.replace("{chord}", role.harmony.chord);
return interpolateTemplate(t("chordEditAriaLabel"), {
roleName: role.name,
sectionLabel,
chord: role.harmony.chord
});
};

/** Documented. */
Expand Down Expand Up @@ -106,7 +138,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}
Comment thread
seonghobae marked this conversation as resolved.
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 · 0:10", "chorus · 0:30"]);
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 at 0:10. */
export const LockInHighPriorityParts: Story = {
args: { song: createDemoRehearsalSong() }
};

/** Repeated verse plus chorus: the third slot is Lead Vocal · chorus · 0:30. */
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