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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

## [0.1.3] - 2026-04-29

### Fixed
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/src/features/workspace/PracticeProgress.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PracticeProgress onChange={handleChange} />);

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(<PracticeProgress progress={50} onChange={handleChange} />);

expect(screen.getByText("50%")).toBeTruthy();
});

it("calls onChange with increased value when increase button is clicked", () => {
const handleChange = vi.fn();
render(<PracticeProgress progress={50} onChange={handleChange} />);

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(<PracticeProgress progress={50} onChange={handleChange} />);

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(<PracticeProgress progress={95} onChange={handleChange} />);

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(<PracticeProgress progress={5} onChange={handleChange} />);

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(<PracticeProgress progress={50} onChange={handleChange} />);

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(<PracticeProgress progress={50} onChange={handleChange} />);

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(<PracticeProgress progress={50} onChange={handleChange} />);

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(<PracticeProgress progress={100} onChange={handleChange} />);

const increaseBtn = screen.getByRole("button", { name: "increasePracticeProgressLabel" }) as HTMLButtonElement;
expect(increaseBtn.disabled).toBe(true);
});
});
90 changes: 90 additions & 0 deletions apps/desktop/src/features/workspace/PracticeProgress.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!Number.isNaN(value)) {
onChange(Math.max(0, Math.min(100, value)));
}
}, [onChange]);

return (
<div
className="mt-4 rounded-xl border border-indigo-300/20 bg-indigo-300/[0.08] p-4 focus-within:ring-2 focus-within:ring-indigo-300"
role="region"
aria-label={t("practiceProgressRegionLabel")}
>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="practice-progress-slider" className="text-xs font-black uppercase tracking-[0.24em] text-indigo-200">
{t("practiceProgressLabel")}
</label>
<span className="text-sm font-semibold text-slate-200">{progress}%</span>
</div>

<div className="flex items-center gap-4">
<button
type="button"
onClick={handleDecrease}
disabled={progress <= 0}
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")}
>
<Minus className="size-4" aria-hidden="true" />
</button>

<div className="relative h-3 flex-1 overflow-hidden rounded-full bg-slate-900/50 shadow-inner">
<div
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-indigo-500 to-cyan-400 transition-all duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
<input
id="practice-progress-slider"
type="range"
min="0"
max="100"
step="1"
value={progress}
onChange={handleSliderChange}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>

<button
type="button"
onClick={handleIncrease}
disabled={progress >= 100}
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("increasePracticeProgressLabel")}
>
<Plus className="size-4" aria-hidden="true" />
</button>
</div>
</div>
);
}

const PracticeProgress = memo(PracticeProgressComponent);

export { PracticeProgress };
30 changes: 30 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Workspace song={song} onSongUpdate={onSongUpdate} />);

// 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 = [];
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -411,6 +439,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
</div>
</div>
)}
<PracticeProgress progress={activeRoleDetails?.practiceProgress} onChange={handlePracticeProgressChange} />
<GrooveMap notes={activeRoleDetails?.transcription} isLoading={false} />
</div>
)}
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 @@ -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"
}
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 @@ -65,5 +65,9 @@
"youtubePlaceholder": "유튜브 URL...",
"importYoutube": "유튜브 가져오기",
"importingYoutube": "가져오는 중...",
"youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다."
"youtubeImportFailed": "유튜브 URL 가져오기에 실패했습니다.",
"practiceProgressRegionLabel": "연습 진척도",
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가"
}
10 changes: 9 additions & 1 deletion packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export type RehearsalRole = {
manualOverrides: ManualOverride[];
overlapWarnings: string[];
transcription?: TranscriptionNote[];
practiceProgress?: number;
};

/** Documented. */
Expand Down Expand Up @@ -1478,7 +1479,8 @@ function validateRehearsalRole(value: unknown, path: string): string | null {
"transpositionPlan",
"manualOverrides",
"overlapWarnings",
"transcription"
"transcription",
"practiceProgress"
],
path
);
Expand Down Expand Up @@ -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;
}

Expand Down
34 changes: 34 additions & 0 deletions packages/shared-types/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown>).practiceProgress = "50";
})
},
{
message: "sections[0].roles[0].transcription",
payload: createInvalidSong((song) => {
Expand Down
Loading