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
8 changes: 8 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ yt_dlp/extractor/go.py
yt_dlp/extractor/nbc.py
yt_dlp/extractor/tbs.py
yt_dlp/extractor/vice.py

# GHSA-wrw7-89jp-8q8g / RUSTSEC-2024-0429: glib 0.18.5 VariantStrIter
# unsoundness inherited only through the Tauri/wry/webkit2gtk/gtk GTK3 stack.
# BandScope ships Windows/macOS artifacts only; Cargo target trees for those
# release targets do not include this Linux GTK stack. Guarded by
# scripts/checks/verify_supply_chain.py and remove when upstream drops or
# patches the chain.
GHSA-wrw7-89jp-8q8g exp:2026-10-31
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": "진척도 증가"
}
2 changes: 1 addition & 1 deletion docs/security/dependency-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Current controlled exceptions:

- No Python vulnerability exceptions are active. `GHSA-5239-wwwm-4pmq` (`Pygments <2.20.0`) was removed by locking `Pygments` to `2.20.0`; the CI `security-audit` workflow must run `pip-audit --local --strict` against the synced `uv` environment without a targeted ignore for that advisory.
- Cargo audit warnings for legacy `gtk3` vulnerabilities (e.g. `RUSTSEC-2024-0413`) inherited through Tauri v2 `wry`/`webkit2gtk` integration are explicitly allowed. These are deep framework dependencies with no alternative, so they are documented exceptions and ignored by default.
- `RUSTSEC-2024-0429` for `glib 0.18.5` is allowed only for the `VariantStrIter` advisory inherited through the Tauri/wry/webkit2gtk/gtk GTK3 stack. A compatible lockfile refresh can move the desktop stack to `tauri 2.11.3`, `wry 0.55.1`, `tao 0.35.3`, `muda 0.19.3`, and related transitive patches, but it still does not move this stack to patched `glib >=0.20.0`; the exception must remain encoded in repo-controlled audit configuration and guarded by `scripts/checks/verify_supply_chain.py`, and it must be removed when upstream drops or patches the chain.
- `RUSTSEC-2024-0429` / `GHSA-wrw7-89jp-8q8g` for `glib 0.18.5` is allowed only for the `VariantStrIter` advisory inherited through the Tauri/wry/webkit2gtk/gtk GTK3 stack. A compatible lockfile refresh can move the desktop stack to `tauri 2.11.3`, `wry 0.55.1`, `tao 0.35.3`, `muda 0.19.3`, and related transitive patches, but it still does not move this stack to patched `glib >=0.20.0`; Cargo target-tree evidence shows this Linux GTK stack is absent from the Windows and macOS artifacts BandScope ships. The exception must remain encoded in repo-controlled cargo-audit, OSV, and Trivy configuration, must carry a Trivy expiry/revisit date, is guarded by `scripts/checks/verify_supply_chain.py`, and must be removed when upstream drops or patches the chain.
- `RUSTSEC-2026-0194` and `RUSTSEC-2026-0195` for `quick-xml 0.39.4` are allowed only while the current compatible upstream owner chains still require vulnerable `quick-xml`: `plist 1.9.0` through Tauri, and `wayland-scanner 0.31.10` through Linux `rfd`/Wayland dependencies. `quick-xml >=0.41.0` is patched, but `plist 1.9.0` requires `quick-xml ^0.39.2` and the current `wayland-scanner` release also has no compatible patched path. BandScope does not expose either owner chain as a user-controlled XML ingestion surface; the exception must stay encoded in repo-controlled cargo-audit and OSV configuration, and must be removed once compatible upstream crates publish a patched dependency path.

Retired third-party deprecation and advisory signal:
Expand Down
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
Loading