Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Display actionable role-level setup notes, simplification guidance, and overlap warnings in the Chords view while suppressing empty and legacy `none` sentinel values.
- 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 apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"pdfjs-dist": "6.1.200",
"pdfjs-dist": "^6.2.108",
"react": "^19.2.4",
"react-dom": "^19.2.7",
"sonner": "^2.0.7",
Expand Down
39 changes: 31 additions & 8 deletions apps/desktop/src/features/chords/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ChordsFeature } from "./index";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { RehearsalSong } from "@bandscope/shared-types";
import { ChordsFeature } from "./index";

const mockSong: RehearsalSong = {
id: "song-1",
Expand All @@ -25,8 +25,8 @@ const mockSong: RehearsalSong = {
range: { lowestNote: "C4", highestNote: "C5" },
confidence: { level: "high", reason: "test" },
rehearsalPriority: "high",
simplification: "none",
setupNote: "none",
simplification: " none ",
setupNote: "NONE",
manualOverrides: [],
overlapWarnings: [],
},
Expand All @@ -39,10 +39,10 @@ const mockSong: RehearsalSong = {
range: { lowestNote: "D4", highestNote: "D5" },
confidence: { level: "high", reason: "test" },
rehearsalPriority: "high",
simplification: "none",
setupNote: "none",
simplification: " Simplify strumming pattern ",
setupNote: " Drop D tuning ",
manualOverrides: [],
overlapWarnings: [],
overlapWarnings: [" Density warning: competing with Bass ", " "],
transpositionPlan: "Capo 2nd fret",
},
],
Expand Down Expand Up @@ -74,4 +74,27 @@ describe("ChordsFeature", () => {
expect(screen.getByText(/Capo 2nd fret/)).toBeInTheDocument();
expect(screen.getByText(/Transpose:/)).toBeInTheDocument();
});

it("renders normalized rehearsal guidance for the intended role", () => {
render(<ChordsFeature title="Chords" song={mockSong} />);
const role = screen.getByRole("article", { name: "Transposed Role" });

expect(within(role).getByText("Drop D tuning")).toBeInTheDocument();
expect(within(role).getByText("Simplify strumming pattern")).toBeInTheDocument();
expect(within(role).getByText("Density warning: competing with Bass")).toBeInTheDocument();
expect(within(role).getByText("Setup:")).toBeInTheDocument();
expect(within(role).getByText("Simplification:")).toBeInTheDocument();
expect(within(role).getByText("Overlap warnings:")).toBeInTheDocument();
expect(within(role).getAllByRole("listitem")).toHaveLength(1);
});

it("does not render sentinel or whitespace-only role guidance", () => {
render(<ChordsFeature title="Chords" song={mockSong} />);
const role = screen.getByRole("article", { name: "Test Role" });

expect(within(role).queryByText("Setup:")).not.toBeInTheDocument();
expect(within(role).queryByText("Simplification:")).not.toBeInTheDocument();
expect(within(role).queryByText("Overlap warnings:")).not.toBeInTheDocument();
expect(screen.queryByText(/^none$/i)).not.toBeInTheDocument();
});
});
125 changes: 80 additions & 45 deletions apps/desktop/src/features/chords/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import type { RehearsalSong } from "@bandscope/shared-types";

/** Documented. */
/**
* Normalize an optional rehearsal instruction and reject producer sentinel text.
*
* Analysis producers historically used the string `none` when no guidance was
* available. Treating that sentinel as buyer-facing copy creates misleading
* setup cards, so the presentation layer maps it and whitespace-only input to
* an absent value while preserving meaningful source text after trimming.
*/
function normalizeRoleDetail(value: string | undefined): string | null {
const normalizedValue = value?.trim() ?? "";
if (normalizedValue === "" || normalizedValue.toLowerCase() === "none") {
return null;
}
return normalizedValue;
}

/** Return only meaningful overlap warnings without mutating analysis output. */
function normalizeOverlapWarnings(values: readonly string[]): string[] {
return values
.map((value) => normalizeRoleDetail(value))
.filter((value): value is string => value !== null);
}

/** Render chord, transposition, setup, simplification, and overlap guidance. */
export function ChordsFeature(props: { title: string; song?: RehearsalSong | null }) {
const { title, song } = props;

Expand All @@ -13,22 +36,6 @@ export function ChordsFeature(props: { title: string; song?: RehearsalSong | nul
);
}

// Collect unique chords across all sections and roles
const chordsBySectionLabel = new Map<string, { chord: string; functionLabel: string; source: string; roleName: string; transpositionPlan?: string }[]>();
for (const section of song.sections) {
const entries: { chord: string; functionLabel: string; source: string; roleName: string; transpositionPlan?: string }[] = [];
for (const role of section.roles) {
entries.push({
chord: role.harmony.chord,
functionLabel: role.harmony.functionLabel,
source: role.harmony.source,
roleName: role.name,
transpositionPlan: role.transpositionPlan,
});
}
chordsBySectionLabel.set(section.label, entries);
}

return (
<section style={{ padding: "24px" }}>
<h2>{title}</h2>
Expand All @@ -48,35 +55,63 @@ export function ChordsFeature(props: { title: string; song?: RehearsalSong | nul
<h3 style={{ margin: "0 0 8px 0", textTransform: "capitalize" }}>
{section.label}
</h3>
{section.roles.map((role) => (
<div
key={role.id}
style={{
marginTop: "8px",
padding: "8px",
backgroundColor: role.harmony.source === "user" ? "#e6f7ff" : "#f9f9f9",
borderRadius: "4px",
}}
>
<div style={{ fontWeight: "bold", fontSize: "1.1em" }}>
{role.harmony.chord}
{role.harmony.source === "user" && (
<span style={{ fontSize: "0.7em", color: "#1890ff", marginLeft: "4px" }}>(User)</span>
)}
</div>
<div style={{ fontSize: "0.85em", color: "#666" }}>
{role.harmony.functionLabel}
</div>
<div style={{ fontSize: "0.8em", color: "#999" }}>
{role.name}
</div>
{role.transpositionPlan && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#d46b08", backgroundColor: "#fff7e6", padding: "4px", borderRadius: "2px" }}>
<strong>Transpose:</strong> {role.transpositionPlan}
{section.roles.map((role) => {
const transpositionPlan = normalizeRoleDetail(role.transpositionPlan);
const setupNote = normalizeRoleDetail(role.setupNote);
const simplification = normalizeRoleDetail(role.simplification);
const overlapWarnings = normalizeOverlapWarnings(role.overlapWarnings);

return (
<article
key={role.id}
aria-label={role.name}
style={{
marginTop: "8px",
padding: "8px",
backgroundColor: role.harmony.source === "user" ? "#e6f7ff" : "#f9f9f9",
borderRadius: "4px",
}}
>
<div style={{ fontWeight: "bold", fontSize: "1.1em" }}>
{role.harmony.chord}
{role.harmony.source === "user" && (
<span style={{ fontSize: "0.7em", color: "#1890ff", marginLeft: "4px" }}>(User)</span>
)}
</div>
<div style={{ fontSize: "0.85em", color: "#666" }}>
{role.harmony.functionLabel}
</div>
)}
</div>
))}
<div style={{ fontSize: "0.8em", color: "#999" }}>
{role.name}
</div>
{transpositionPlan && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#d46b08", backgroundColor: "#fff7e6", padding: "4px", borderRadius: "2px" }}>
<strong>Transpose:</strong> {transpositionPlan}
</div>
)}
{setupNote && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#08979c", backgroundColor: "#e6fffb", padding: "4px", borderRadius: "2px" }}>
<strong>Setup:</strong> {setupNote}
</div>
)}
{simplification && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#531dab", backgroundColor: "#f9f0ff", padding: "4px", borderRadius: "2px" }}>
<strong>Simplification:</strong> {simplification}
</div>
)}
{overlapWarnings.length > 0 && (
<div style={{ marginTop: "6px", fontSize: "0.8em", color: "#cf1322", backgroundColor: "#fff1f0", padding: "4px", borderRadius: "2px" }}>
<strong>Overlap warnings:</strong>
<ul aria-label={`${role.name} overlap warnings`} style={{ margin: "2px 0 0 16px", padding: 0 }}>
{overlapWarnings.map((warning, warningIndex) => (
<li key={`${role.id}-${warningIndex}-${warning}`}>{warning}</li>
))}
</ul>
</div>
)}
</article>
);
})}
</div>
))}
</div>
Expand Down
Loading
Loading