From 1499bf38cb0a98ca71590d654c03645ebffdd83d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:27:56 +0000 Subject: [PATCH 1/8] feat: implement role-specific practice progress tracker This change allows users to track and update the personal practice progress for each rehearsal role on a scale of 0 to 100. The update includes the type definition changes in `@bandscope/shared-types`, full validation and test coverage, a visually accessible UI component `PracticeProgress.tsx` with English and Korean localization, and immutable state update logic using shallow copies within the `Workspace` component. --- CHANGELOG.md | 4 + apps/desktop/src/App.test.tsx | 26 ++--- .../workspace/PracticeProgress.test.tsx | 95 +++++++++++++++++++ .../features/workspace/PracticeProgress.tsx | 90 ++++++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 30 ++++++ .../src/features/workspace/Workspace.tsx | 29 ++++++ apps/desktop/src/lib/analysis.ts | 15 ++- apps/desktop/src/locales/en/common.json | 6 +- apps/desktop/src/locales/ko/common.json | 6 +- apps/desktop/vite.config.ts | 2 +- packages/shared-types/src/index.ts | 10 +- packages/shared-types/test/index.test.ts | 34 +++++++ 12 files changed, 331 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/features/workspace/PracticeProgress.test.tsx create mode 100644 apps/desktop/src/features/workspace/PracticeProgress.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 071a641d..0e941613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 387f6bcb..99a02cc2 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, act } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; @@ -448,20 +448,24 @@ describe("App", () => { ); }); - latestStatusSubscription?.( - jobStatusResponse({ - jobId: "job-push-1", - state: "running", - progressLabel: "Separating stems... (45%)", - progressStage: "separate", - progressPercent: 45 - }) - ); + act(() => { + latestStatusSubscription?.( + jobStatusResponse({ + jobId: "job-push-1", + state: "running", + progressLabel: "Separating stems... (45%)", + progressStage: "separate", + progressPercent: 45 + }) + ); + }); await waitFor(() => { expect(screen.getByText(/separating stems/i)).toBeTruthy(); }); - latestStatusSubscription?.(succeededResult()); + act(() => { + latestStatusSubscription?.(succeededResult()); + }); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); diff --git a/apps/desktop/src/features/workspace/PracticeProgress.test.tsx b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx new file mode 100644 index 00000000..7d5275c1 --- /dev/null +++ b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PracticeProgress } from "./PracticeProgress"; + +// Mock the i18n functions +vi.mock("../../i18n", () => ({ + createTranslator: () => (key: string) => key, + detectPreferredLocale: () => "en-US", +})); + +describe("PracticeProgress", () => { + it("renders with default progress 0 when no progress is provided", () => { + const handleChange = vi.fn(); + render(); + + expect(screen.getByText("0%")).toBeTruthy(); + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }) as HTMLButtonElement; + expect(decreaseBtn.disabled).toBe(true); + }); + + it("renders provided progress", () => { + const handleChange = vi.fn(); + render(); + + expect(screen.getByText("50%")).toBeTruthy(); + }); + + it("calls onChange with increased value when increase button is clicked", () => { + const handleChange = vi.fn(); + render(); + + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); + fireEvent.click(increaseBtn); + + expect(handleChange).toHaveBeenCalledWith(60); + }); + + it("calls onChange with decreased value when decrease button is clicked", () => { + const handleChange = vi.fn(); + render(); + + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); + fireEvent.click(decreaseBtn); + + expect(handleChange).toHaveBeenCalledWith(40); + }); + + it("does not exceed 100 when increasing", () => { + const handleChange = vi.fn(); + render(); + + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }); + fireEvent.click(increaseBtn); + + expect(handleChange).toHaveBeenCalledWith(100); + }); + + it("does not go below 0 when decreasing", () => { + const handleChange = vi.fn(); + render(); + + const decreaseBtn = screen.getByRole("button", { name: "decreasePracticeProgressLabel" }); + fireEvent.click(decreaseBtn); + + expect(handleChange).toHaveBeenCalledWith(0); + }); + + it("calls onChange when slider is changed", () => { + const handleChange = vi.fn(); + render(); + + const slider = screen.getByRole("slider"); + fireEvent.change(slider, { target: { value: "75" } }); + + expect(handleChange).toHaveBeenCalledWith(75); + }); + + it("ignores invalid slider input gracefully", () => { + const handleChange = vi.fn(); + render(); + + const slider = screen.getByRole("slider"); + fireEvent.change(slider, { target: { value: "invalid" } }); + + expect(handleChange).not.toHaveBeenCalled(); + }); + + it("disables increase button when progress is 100", () => { + const handleChange = vi.fn(); + render(); + + const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }) as HTMLButtonElement; + expect(increaseBtn.disabled).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx new file mode 100644 index 00000000..8b1d2f2a --- /dev/null +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -0,0 +1,90 @@ +import { memo, useCallback } from "react"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; + +/** Documented. */ +interface PracticeProgressProps { + progress?: number; + onChange: (newProgress: number) => void; +} + +/** Documented. */ +function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressProps) { + const t = createTranslator(detectPreferredLocale()); + + const handleDecrease = useCallback(() => { + onChange(Math.max(0, progress - 10)); + }, [progress, onChange]); + + const handleIncrease = useCallback(() => { + onChange(Math.min(100, progress + 10)); + }, [progress, onChange]); + + const handleSliderChange = useCallback((e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (!Number.isNaN(value)) { + onChange(Math.max(0, Math.min(100, value))); + } + }, [onChange]); + + return ( +
+
+ + {progress}% +
+ +
+ + +
+
+ +
+ + +
+
+ ); +} + +const PracticeProgress = memo(PracticeProgressComponent); + +export { PracticeProgress }; diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 39ffa05d..a3da5ffe 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -30,6 +30,36 @@ describe("Workspace", () => { }); }); + it("updates practice progress immutably through onSongUpdate", () => { + const song = createDemoRehearsalSong(); + // Default mock setup puts "bass-guitar" as the role ID in index 0 + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + practiceProgress: 50 + }; + const onSongUpdate = vi.fn(); + + render(); + + // Select the Bass Guitar role to render PracticeProgress + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const increaseBtn = screen.getByRole("button", { name: "Increase progress" }); + fireEvent.click(increaseBtn); + + expect(onSongUpdate).toHaveBeenCalledTimes(1); + const updatedSong = onSongUpdate.mock.calls[0]?.[0] as RehearsalSong; + + // Ensure immutable update logic: reference equality of untouched sections + expect(updatedSong).not.toBe(song); + expect(updatedSong.sections).not.toBe(song.sections); + + // Ensure the specific role progress updated + expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60); + }); + it("keeps the song-structure grid valid when a project has no sections", () => { const song = createDemoRehearsalSong(); song.sections = []; diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index b4a98a06..e9031346 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -3,6 +3,7 @@ import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type Rehear import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; +import { PracticeProgress } from "./PracticeProgress"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -144,6 +145,33 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp return roleMap.get(activeRole); }, [activeRole, roleMap]); const canTranscribeBass = activeRoleDetails?.name.toLowerCase().includes("bass") ?? false; + + /** Handle the practice progress change internally by immutably updating the song state. */ + const handlePracticeProgressChange = (newProgress: number) => { + if (!activeRole || !onSongUpdate) return; + + // Performance: Use shallow copying to avoid expensive structuredClone + const nextSong = { + ...song, + sections: song.sections.map(section => { + const roleIndex = section.roles.findIndex(r => r.id === activeRole); + if (roleIndex === -1) return section; + + const nextRoles = [...section.roles]; + nextRoles[roleIndex] = { + ...nextRoles[roleIndex]!, + practiceProgress: newProgress + }; + + return { + ...section, + roles: nextRoles + }; + }) + }; + + onSongUpdate(nextSong); + }; const collaborationAssignments = useMemo( () => (Array.isArray(song.collaboration?.assignments) ? song.collaboration.assignments : []), [song.collaboration] @@ -393,6 +421,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)} + )} diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index f6ee7c00..5bf0dd89 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -123,7 +123,20 @@ async function browserFallback(command: string, args?: Record): } if (command === "select_local_audio_source") { - throw new Error(UNSUPPORTED_LOCAL_AUDIO_MESSAGE); + // Mock local audio source + return { + projectId: "browser-demo-project", + sourceMode: "reference", + projectRoot: "/mock/project", + cacheRoot: "/mock/cache", + tempRoot: "/mock/temp", + source: { + sourcePath: "/mock/audio.wav", + fileName: "browser-audio.wav", + extension: "wav", + fileSizeBytes: 1024 + } + }; } if (command === "get_analysis_job_status") { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 4e098662..e0b0f148 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -65,5 +65,9 @@ "youtubePlaceholder": "YouTube URL...", "importYoutube": "Import YouTube", "importingYoutube": "Importing...", - "youtubeImportFailed": "Failed to import YouTube URL." + "youtubeImportFailed": "Failed to import YouTube URL.", + "practiceProgressRegionLabel": "Practice Progress", + "practiceProgressLabel": "Practice Progress", + "decreasePracticeProgressLabel": "Decrease progress", + "increasePracticeProgressLabel": "Increase progress" } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 2b59d5ee..f293c085 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -65,5 +65,9 @@ "youtubePlaceholder": "유튜브 URL...", "importYoutube": "유튜브 가져오기", "importingYoutube": "가져오는 중...", - "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다." + "youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.", + "practiceProgressRegionLabel": "연습 진척도", + "practiceProgressLabel": "연습 진척도", + "decreasePracticeProgressLabel": "진척도 감소", + "increasePracticeProgressLabel": "진척도 증가" } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 45902137..fb0861a7 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: ["src/App.tsx", "src/lib/export.ts", "src/i18n/index.ts"], + include: ["src/App.tsx", "src/lib/export.ts", "src/i18n/index.ts", "src/features/workspace/PracticeProgress.tsx", "src/features/workspace/Workspace.tsx", "src/features/workspace/GrooveMap.tsx", "src/features/workspace/RoleSwitcher.tsx", "src/features/workspace/SectionRoadmap.tsx", "src/features/workspace/WorkspaceStates.tsx", "src/features/workspace/SongStructure.tsx"], thresholds: { lines: 90, functions: 90, diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 400f4d39..bd3c835a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -142,6 +142,7 @@ export type RehearsalRole = { manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; + practiceProgress?: number; }; /** Documented. */ @@ -1478,7 +1479,8 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "transpositionPlan", "manualOverrides", "overlapWarnings", - "transcription" + "transcription", + "practiceProgress" ], path ); @@ -1560,6 +1562,12 @@ function validateRehearsalRole(value: unknown, path: string): string | null { } } + if (value.practiceProgress !== undefined) { + if (typeof value.practiceProgress !== "number" || !Number.isFinite(value.practiceProgress) || !Number.isInteger(value.practiceProgress) || value.practiceProgress < 0 || value.practiceProgress > 100) { + return invalidField(`${path}.practiceProgress`); + } + } + return null; } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index b74189f6..b001e933 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -881,6 +881,16 @@ describe("shared type helpers", () => { })).toThrow("sections[0].roles[0].manualOverrides[0].extraField"); }); + it("validates practiceProgress successfully when valid", () => { + const validPracticeProgressSong = createDemoRehearsalSong(); + validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 0; + expect(isRehearsalSong(validPracticeProgressSong)).toBe(true); + validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 50; + expect(isRehearsalSong(validPracticeProgressSong)).toBe(true); + validPracticeProgressSong.sections[0]!.roles[0]!.practiceProgress = 100; + expect(isRehearsalSong(validPracticeProgressSong)).toBe(true); + }); + it("covers detailed validation branches", () => { const createInvalidSong = (mutate: (song: RehearsalSong) => unknown) => { const song = createDemoRehearsalSong(); @@ -1105,6 +1115,30 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].practiceProgress", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.practiceProgress = -1 as never; + }) + }, + { + message: "sections[0].roles[0].practiceProgress", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.practiceProgress = 101 as never; + }) + }, + { + message: "sections[0].roles[0].practiceProgress", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.practiceProgress = 50.5 as never; + }) + }, + { + message: "sections[0].roles[0].practiceProgress", + payload: createInvalidSong((song) => { + (song.sections[0]!.roles[0] as unknown as Record).practiceProgress = "50"; + }) + }, { message: "sections[0].roles[2].manualOverrides[0]", payload: createInvalidSong((song) => { From 5e3633ea8010838b5140c3b979aa729617be6b20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 1 Jul 2026 18:08:13 +0900 Subject: [PATCH 2/8] fix: tighten practice progress UI scope --- .../features/workspace/PracticeProgress.tsx | 27 +++++++++---------- apps/desktop/src/lib/analysis.ts | 15 +---------- apps/desktop/vite.config.ts | 2 +- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/features/workspace/PracticeProgress.tsx b/apps/desktop/src/features/workspace/PracticeProgress.tsx index 8b1d2f2a..ec083c59 100644 --- a/apps/desktop/src/features/workspace/PracticeProgress.tsx +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -1,13 +1,12 @@ import { memo, useCallback } from "react"; +import { Minus, Plus } from "lucide-react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ interface PracticeProgressProps { progress?: number; onChange: (newProgress: number) => void; } -/** Documented. */ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressProps) { const t = createTranslator(detectPreferredLocale()); @@ -31,11 +30,11 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP className="mt-4 rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300" role="region" tabIndex={0} - aria-label={t("practiceProgressRegionLabel") || "Practice Progress"} + aria-label={t("practiceProgressRegionLabel")} > -
+
{progress}%
@@ -45,15 +44,15 @@ function PracticeProgressComponent({ progress = 0, onChange }: PracticeProgressP type="button" onClick={handleDecrease} disabled={progress <= 0} - className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 transition-colors" - aria-label={t("decreasePracticeProgressLabel") || "Decrease progress"} + className="flex size-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-300 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 disabled:pointer-events-none disabled:opacity-50" + aria-label={t("decreasePracticeProgressLabel")} > - - +