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/features/workspace/PracticeProgress.test.tsx b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx new file mode 100644 index 00000000..5105c3c5 --- /dev/null +++ b/apps/desktop/src/features/workspace/PracticeProgress.test.tsx @@ -0,0 +1,103 @@ +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("keeps focus on interactive controls instead of the progress region", () => { + const handleChange = vi.fn(); + render(); + + expect(screen.getByRole("region", { name: "practiceProgressRegionLabel" })).not.toHaveAttribute("tabindex"); + expect(screen.getByRole("slider")).toBeInTheDocument(); + }); + + 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..359ab81a --- /dev/null +++ b/apps/desktop/src/features/workspace/PracticeProgress.tsx @@ -0,0 +1,90 @@ +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()); + + 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 2f990eec..50f0e0d5 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] @@ -411,6 +439,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
)} + )} 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/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ff04618b..ece45e8f 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 bd2bc6fa..3434a6ff 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -892,6 +892,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(); @@ -1116,6 +1126,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[0].transcription", payload: createInvalidSong((song) => {