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 evidence-backed setup notes, simplification actions, and overlap warnings in the active rehearsal Workspace, with English/Korean action labels and no inference from blank or legacy `none` sentinel values.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
77 changes: 76 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 @@ -165,6 +165,81 @@ describe("Workspace", () => {
expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0);
});

it("shows normalized setup, simplification, and ordered overlap actions in the active workspace", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
name: "Bass Guitar",
setupNote: " Lower the keyboard stand before the count-in. ",
simplification: " Hold roots on beats one and three. ",
overlapWarnings: [
" ",
" Leave the pickup to the lead vocal. ",
"Leave the pickup to the lead vocal.",
"NONE",
"Double only after the chorus entrance."
]
};

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const guidance = screen.getByRole("region", { name: "Actionable rehearsal guidance" });
expect(within(guidance).getByText("Set up before the take")).toBeTruthy();
expect(within(guidance).getByText("Lower the keyboard stand before the count-in.")).toBeTruthy();
expect(within(guidance).getByText("Simplify if the pass breaks down")).toBeTruthy();
expect(within(guidance).getByText("Hold roots on beats one and three.")).toBeTruthy();
expect(within(guidance).getByText("Resolve these overlaps")).toBeTruthy();

const warnings = within(guidance).getByRole("list", { name: "Resolve these overlaps" });
expect(within(warnings).getAllByRole("listitem").map((item) => item.textContent)).toEqual([
"Leave the pickup to the lead vocal.",
"Double only after the chorus entrance."
]);
expect(within(guidance).queryByText(/^none$/i)).toBeNull();
});

it("does not infer rehearsal guidance from blank or legacy sentinel evidence", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
name: "Bass Guitar",
transpositionPlan: " none ",
setupNote: " NONE ",
simplification: " ",
overlapWarnings: ["", " none ", " "]
};

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.queryByRole("region", { name: "Actionable rehearsal guidance" })).toBeNull();
});

it("localizes actionable rehearsal guidance in Korean", () => {
setNavigatorLanguage("ko-KR");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
name: "Bass Guitar",
setupNote: "앰프 게인을 먼저 낮추세요.",
simplification: "첫 박의 근음만 유지하세요.",
overlapWarnings: ["보컬 픽업과 겹치지 않게 쉬세요."]
};

render(<Workspace song={song} />);
fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

const guidance = screen.getByRole("region", { name: "실행 가능한 합주 가이드" });
expect(within(guidance).getByText("연주 전에 준비하세요")).toBeTruthy();
expect(within(guidance).getByText("합주가 흔들리면 이렇게 단순화하세요")).toBeTruthy();
expect(within(guidance).getByText("이 겹침을 먼저 해결하세요")).toBeTruthy();
const warnings = within(guidance).getByRole("list", { name: "이 겹침을 먼저 해결하세요" });
expect(within(warnings).getByText("보컬 픽업과 겹치지 않게 쉬세요.")).toBeTruthy();
});

it("exports a metadata-only handoff artifact from the workspace", async () => {
const song = createDemoRehearsalSong();
const sourceBootstrap: ProjectBootstrapSummary = {
Expand Down
103 changes: 90 additions & 13 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo, memo, type MouseEvent } from "react";
import { useState, useMemo, useId, memo, type MouseEvent } from "react";
import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
Expand Down Expand Up @@ -51,12 +51,38 @@ function formatStatusLabel(status: string): string {
return status.replaceAll("_", " ");
}

/** Documented. */
/** Return trimmed source text when the analysis supplied nonblank evidence. */
function nonBlankText(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/**
* Return buyer-visible guidance only when the producer supplied meaningful evidence.
*
* Historical analysis payloads used case-insensitive `none` text as an absence
* sentinel. The active Workspace must not turn that missing evidence into an
* instruction, warning, or transposition plan.
*/
function actionableGuidanceText(value: string | undefined): string | undefined {
const normalized = nonBlankText(value);
return normalized?.toLowerCase() === "none" ? undefined : normalized;
}

/** Preserve unique meaningful overlap-warning order without mutating analysis output. */
function actionableOverlapWarnings(values: readonly string[]): string[] {
const warnings: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const normalized = actionableGuidanceText(value);
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
warnings.push(normalized);
}
}
return warnings;
}

/** Documented. */
function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null {
if (!value) {
Expand Down Expand Up @@ -121,6 +147,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const overlapWarningsHeadingId = useId();

// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
Expand Down Expand Up @@ -209,9 +236,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
nonBlankText(activeRoleDetails?.harmonicExplanation) ??
nonBlankText(activeRoleDetails?.harmony.functionLabel) ??
t("workspaceHarmonyExplainFallback");
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);
const roleTranspositionPlan = actionableGuidanceText(activeRoleDetails?.transpositionPlan);
const roleSetupNote = actionableGuidanceText(activeRoleDetails?.setupNote);
const roleSimplification = actionableGuidanceText(activeRoleDetails?.simplification);
const roleOverlapWarnings = actionableOverlapWarnings(activeRoleDetails?.overlapWarnings ?? []);
const hasActionableGuidance = Boolean(
roleSetupNote || roleSimplification || roleOverlapWarnings.length > 0
);

/** Documented. */
const handleExportCueSheet = () => {
Expand Down Expand Up @@ -416,16 +447,62 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{roleHarmonicExplanation}
</p>
</div>
<div className="rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-3">
<div className="flex items-center gap-2 text-indigo-100">
<ClipboardList className="size-4" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceTranspositionLabel")}</p>
{roleTranspositionPlan && (
<div className="rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-3">
<div className="flex items-center gap-2 text-indigo-100">
<ClipboardList className="size-4" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceTranspositionLabel")}</p>
</div>
<p className="mt-2 text-sm leading-6 text-slate-200">
{roleTranspositionPlan}
</p>
</div>
<p className="mt-2 text-sm leading-6 text-slate-200">
{roleTranspositionPlan}
</p>
</div>
)}
</div>
{hasActionableGuidance && (
<section
role="region"
aria-label={t("workspaceGuidanceRegionLabel")}
className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/[0.05] p-3"
>
<div className="grid gap-3 lg:grid-cols-3">
{roleSetupNote && (
<article className="rounded-xl border border-teal-300/20 bg-teal-300/[0.07] p-3">
<h4 className="text-[0.7rem] font-black uppercase tracking-[0.2em] text-teal-100">
{t("workspaceSetupLabel")}
</h4>
<p className="mt-2 text-sm leading-6 text-slate-100">{roleSetupNote}</p>
</article>
)}
{roleSimplification && (
<article className="rounded-xl border border-violet-300/20 bg-violet-300/[0.07] p-3">
<h4 className="text-[0.7rem] font-black uppercase tracking-[0.2em] text-violet-100">
{t("workspaceSimplificationLabel")}
</h4>
<p className="mt-2 text-sm leading-6 text-slate-100">{roleSimplification}</p>
</article>
)}
{roleOverlapWarnings.length > 0 && (
<article className="rounded-xl border border-rose-300/20 bg-rose-300/[0.07] p-3">
<h4
id={overlapWarningsHeadingId}
className="text-[0.7rem] font-black uppercase tracking-[0.2em] text-rose-100"
>
{t("workspaceOverlapWarningsLabel")}
</h4>
<ul
aria-labelledby={overlapWarningsHeadingId}
className="mt-2 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-100"
>
Comment thread
seonghobae marked this conversation as resolved.
{roleOverlapWarnings.map((warning, warningIndex) => (
<li key={`${activeRole}-${warningIndex}-${warning}`}>{warning}</li>
))}
</ul>
</article>
)}
</div>
</section>
)}
{song.collaboration && (
<div className="mt-4 grid gap-3 xl:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
"workspaceApprovalsLabel": "Approvals",
"workspaceHarmonyExplainLabel": "Why it works",
"workspaceHarmonyExplainFallback": "The role-specific harmonic reason will appear here after the room confirms it.",
"workspaceTranspositionLabel": "Transpose / simplify",
"workspaceTranspositionLabel": "Transpose",
"workspaceGuidanceRegionLabel": "Actionable rehearsal guidance",
"workspaceSetupLabel": "Set up before the take",
"workspaceSimplificationLabel": "Simplify if the pass breaks down",
"workspaceOverlapWarningsLabel": "Resolve these overlaps",
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
"workspaceApprovalsLabel": "승인",
"workspaceHarmonyExplainLabel": "이 화성이 먹히는 이유",
"workspaceHarmonyExplainFallback": "역할별 화성 이유는 합주실에서 확인되면 여기에 정리됩니다.",
"workspaceTranspositionLabel": "전조 / 단순화",
"workspaceTranspositionLabel": "전조",
"workspaceGuidanceRegionLabel": "실행 가능한 합주 가이드",
"workspaceSetupLabel": "연주 전에 준비하세요",
"workspaceSimplificationLabel": "합주가 흔들리면 이렇게 단순화하세요",
"workspaceOverlapWarningsLabel": "이 겹침을 먼저 해결하세요",
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
Expand Down
Loading