Skip to content
Merged
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
86 changes: 85 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createDemoRehearsalSong, type ProjectBootstrapSummary } from "@bandscope/shared-types";
import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Workspace } from "./Workspace";
import { EmptyState, LoadingState } from "./WorkspaceStates";
import { generateMetadataHandoffJson } from "../../lib/export";

const originalLanguage = navigator.language;
const originalCreateObjectUrl = URL.createObjectURL;
Expand Down Expand Up @@ -91,6 +92,49 @@ describe("Workspace", () => {
expect(screen.getByText(/2 notes mapped for rehearsal/i)).toBeTruthy();
});

it("renders collaboration summaries and role-specific rehearsal planning details", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();

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

Comment thread
seonghobae marked this conversation as resolved.
expect(screen.getByText("Collaboration")).toBeTruthy();
expect(screen.getByText(/2 Assignments/i)).toBeTruthy();
expect(screen.getByText(/Keep assignments local for now/i)).toBeTruthy();

fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
expect(screen.getByText(/whole step lower/i)).toBeTruthy();
expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy();
expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
});

it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
song.sections[0]!.roles[0] = {
...song.sections[0]!.roles[0]!,
harmonicExplanation: " ",
transpositionPlan: ""
};
song.collaboration = {
syncMode: "local_only",
syncNote: "Local-only draft"
} as RehearsalSong["collaboration"];

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

expect(screen.getByText(/0 Assignments/i)).toBeTruthy();
expect(screen.getByText(/0 Comments/i)).toBeTruthy();
expect(screen.getByText(/0 Approvals/i)).toBeTruthy();

fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" }));

expect(screen.getByText("vi pedal anchor")).toBeTruthy();
expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0);
});

it("exports a metadata-only handoff artifact from the workspace", async () => {
const song = createDemoRehearsalSong();
const sourceBootstrap: ProjectBootstrapSummary = {
Expand Down Expand Up @@ -130,6 +174,45 @@ describe("Workspace", () => {
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff");
});

it("exports metadata-only handoff when source bootstrap is invalid", async () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
projectId: "project-1"
} as ProjectBootstrapSummary;
const createObjectUrl = vi.fn(() => "blob:handoff");
const revokeObjectUrl = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectUrl
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectUrl
});

render(<Workspace song={song} sourceBootstrap={invalidSourceBootstrap} />);
fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));

const blob = createObjectUrl.mock.calls[0]?.[0] as Blob;
const payload = JSON.parse(await blob.text());
expect(payload.artifactKind).toBe("bandscope.metadata-handoff");
expect(payload.sourceAssets).toEqual([]);
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:handoff");
});

it("validates source bootstrap before generating metadata handoff", () => {
const song = createDemoRehearsalSong();
const invalidSourceBootstrap = {
projectId: "project-1"
} as ProjectBootstrapSummary;

expect(() => {
generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap });
}).toThrow("sourceMode");
});

it("localizes empty and loading state titles", () => {
setNavigatorLanguage("ko-KR");
render(<EmptyState />);
Expand All @@ -152,6 +235,7 @@ describe("Workspace", () => {
expect(screen.getByText("오늘의 합주 지도")).toBeTruthy();
expect(screen.getByText("합주 작업 공간")).toBeTruthy();
expect(screen.getByText("곡 타임라인")).toBeTruthy();
expect(screen.getByText("협업")).toBeTruthy();
expect(screen.getByText("스템")).toBeTruthy();
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
Expand Down
161 changes: 156 additions & 5 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useState, useMemo, memo } from "react";
import type { ProjectBootstrapSummary, RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card";
import { Download } from "lucide-react";
import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4 } from "lucide-react";

interface WorkspaceProps {
song: RehearsalSong;
Expand Down Expand Up @@ -40,6 +40,30 @@ function downloadTextFile(contents: string, type: string, filename: string): voi

type Translator = ReturnType<typeof createTranslator>;

/** Documented. */
function formatStatusLabel(status: string): string {
return status.replaceAll("_", " ");
}

/** Documented. */
function nonBlankText(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/** Documented. */
function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null {
if (!value) {
return null;
}

try {
return parseProjectBootstrapSummary(value);
} catch {
return null;
}
}

/** Documented. */
const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) {
return (
Expand Down Expand Up @@ -115,6 +139,41 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
return roleMap.get(activeRole);
}, [activeRole, roleMap]);
const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false;
const collaborationAssignments = useMemo(
() => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []),
[song.collaboration]
);
const collaborationComments = useMemo(
() => (Array.isArray(song.collaboration?.comments) ? song.collaboration.comments : []),
[song.collaboration]
);
const collaborationApprovals = useMemo(
() => (Array.isArray(song.collaboration?.approvals) ? song.collaboration.approvals : []),
[song.collaboration]
);
const collaborationSummary = useMemo(
() => ({
assignments: collaborationAssignments.length,
comments: collaborationComments.length,
approvals: collaborationApprovals.length
}),
[collaborationApprovals.length, collaborationAssignments.length, collaborationComments.length]
);
const activeRoleAssignments = useMemo(
() => collaborationAssignments.filter(assignment => assignment.roleId === undefined || assignment.roleId === activeRole),
[activeRole, collaborationAssignments]
);
const activeRoleComments = useMemo(
() => collaborationComments.filter(comment => comment.roleId === undefined || comment.roleId === activeRole),
[activeRole, collaborationComments]
);
const roleHarmonicExplanation =
nonBlankText(activeRoleDetails?.harmonicExplanation) ??
nonBlankText(activeRoleDetails?.harmony.functionLabel) ??
t("workspaceHarmonyExplainFallback");
const roleTranspositionPlan =
nonBlankText(activeRoleDetails?.transpositionPlan) ??
nonBlankText(activeRoleDetails?.simplification);

/** Documented. */
const handleExportCueSheet = () => {
Expand All @@ -130,8 +189,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

/** Documented. */
const handleExportHandoff = () => {
const parsedSourceBootstrap = safeProjectBootstrapSummary(sourceBootstrap);
const json = generateMetadataHandoffJson(song, {
sourceBootstrap,
sourceBootstrap: parsedSourceBootstrap,
workspaceId: song.id,
Comment thread
seonghobae marked this conversation as resolved.
workspaceTitle: song.title
});
Expand Down Expand Up @@ -183,14 +243,36 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
</CardHeader>

<CardContent className="space-y-6 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(2,6,23,0.86))] p-5 md:p-7">
<div className="grid gap-4 lg:grid-cols-4">
<section className="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4 lg:col-span-2">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<section className="rounded-2xl border border-cyan-300/20 bg-cyan-300/[0.06] p-4 md:col-span-2">
<p className="text-xs font-black uppercase tracking-[0.24em] text-cyan-300">{t("workspaceSongTimelineLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">
{song.sections.length} section{song.sections.length === 1 ? "" : "s"} mapped with groove, role cues, and chord confidence notes.
</p>
</section>

<section className="rounded-2xl border border-emerald-300/20 bg-emerald-300/[0.07] p-4 md:col-span-2 xl:col-span-1">
<p className="text-xs font-black uppercase tracking-[0.24em] text-emerald-200">{t("workspaceCollaborationLabel")}</p>
{song.collaboration ? (
<div className="mt-2 space-y-3 text-sm leading-6 text-slate-300">
<div className="flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">{collaborationSummary.assignments} {t("workspaceAssignmentsLabel")}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">{collaborationSummary.comments} {t("workspaceCommentsLabel")}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1">{collaborationSummary.approvals} {t("workspaceApprovalsLabel")}</span>
</div>
<div className="flex items-start gap-2 rounded-xl border border-white/10 bg-white/5 p-3">
<CloudOff className="mt-0.5 size-4 shrink-0 text-emerald-200" aria-hidden="true" />
<div>
<p className="text-[0.65rem] font-black uppercase tracking-[0.22em] text-emerald-200">{t("workspaceSyncStatusLabel")}</p>
<p>{song.collaboration.syncNote}</p>
</div>
</div>
</div>
) : (
<p className="mt-2 text-sm leading-6 text-slate-300">{t("workspaceCollaborationEmpty")}</p>
)}
</section>

<section className="rounded-2xl border border-violet-300/20 bg-violet-300/[0.06] p-4">
<p className="text-xs font-black uppercase tracking-[0.24em] text-violet-200">{t("workspaceStemsLabel")}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">Stem lanes will appear when separation results are available.</p>
Expand Down Expand Up @@ -237,6 +319,75 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
Transcribe Bass
</Button>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<div className="rounded-xl border border-cyan-300/20 bg-cyan-300/[0.06] p-3">
<div className="flex items-center gap-2 text-cyan-100">
<Music4 className="size-4" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceHarmonyExplainLabel")}</p>
</div>
<p className="mt-2 text-sm leading-6 text-slate-200">
{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>
</div>
<p className="mt-2 text-sm leading-6 text-slate-200">
{roleTranspositionPlan}
</p>
</div>
</div>
{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">
<div className="flex items-center gap-2 text-slate-100">
<ClipboardList className="size-4 text-cyan-200" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceAssignmentsLabel")}</p>
</div>
<div className="mt-3 space-y-2">
{activeRoleAssignments.map((assignment) => (
<div key={assignment.id} className="rounded-lg border border-cyan-300/15 bg-cyan-300/[0.06] p-2">
<p className="text-xs font-black uppercase tracking-[0.16em] text-cyan-100">{assignment.assignee}</p>
<p className="mt-1 text-sm text-slate-100">{assignment.summary}</p>
<p className="mt-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-slate-400">{formatStatusLabel(assignment.status)}</p>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center gap-2 text-slate-100">
<MessageSquareMore className="size-4 text-amber-200" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceCommentsLabel")}</p>
</div>
<div className="mt-3 space-y-2">
{activeRoleComments.map((comment) => (
<div key={comment.id} className="rounded-lg border border-amber-300/15 bg-amber-300/[0.07] p-2">
<p className="text-xs font-black uppercase tracking-[0.16em] text-amber-100">{comment.author}</p>
<p className="mt-1 text-sm text-slate-100">{comment.body}</p>
<p className="mt-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-slate-400">{formatStatusLabel(comment.status)}</p>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center gap-2 text-slate-100">
<CheckCheck className="size-4 text-emerald-200" aria-hidden="true" />
<p className="text-[0.7rem] font-black uppercase tracking-[0.22em]">{t("workspaceApprovalsLabel")}</p>
</div>
<div className="mt-3 space-y-2">
{collaborationApprovals.map((approval) => (
<div key={approval.id} className="rounded-lg border border-emerald-300/15 bg-emerald-300/[0.07] p-2">
<p className="text-xs font-black uppercase tracking-[0.16em] text-emerald-100">{approval.scope}</p>
<p className="mt-1 text-sm text-slate-100">{approval.owner}</p>
<p className="mt-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-slate-400">{formatStatusLabel(approval.status)}</p>
</div>
))}
</div>
</div>
</div>
)}
<GrooveMap notes={activeRoleDetails?.transcription} isLoading={false} />
</div>
)}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@
"workspaceSongStructureLabel": "Song Structure",
"workspaceRehearsalTimelineLabel": "Rehearsal timeline",
"workspaceSongTimelineLabel": "Song Timeline",
"workspaceCollaborationLabel": "Collaboration",
"workspaceCollaborationEmpty": "Assignments, comments, and approvals will show up here as the room aligns.",
"workspaceSyncStatusLabel": "Sync",
"workspaceAssignmentsLabel": "Assignments",
"workspaceCommentsLabel": "Comments",
"workspaceApprovalsLabel": "Approvals",
"workspaceHarmonyExplainLabel": "Why it works",
"workspaceHarmonyExplainFallback": "The role-specific harmonic reason will appear here after the room confirms it.",
"workspaceTranspositionLabel": "Transpose / simplify",
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@
"workspaceSongStructureLabel": "곡 구조",
"workspaceRehearsalTimelineLabel": "합주 타임라인",
"workspaceSongTimelineLabel": "곡 타임라인",
"workspaceCollaborationLabel": "협업",
"workspaceCollaborationEmpty": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.",
"workspaceSyncStatusLabel": "동기화",
"workspaceAssignmentsLabel": "담당",
"workspaceCommentsLabel": "코멘트",
"workspaceApprovalsLabel": "승인",
"workspaceHarmonyExplainLabel": "이 화성이 먹히는 이유",
"workspaceHarmonyExplainFallback": "역할별 화성 이유는 합주실에서 확인되면 여기에 정리됩니다.",
"workspaceTranspositionLabel": "전조 / 단순화",
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
"workspaceRolesHarmonyLabel": "역할과 화성",
Expand Down
Loading