From bbf0cf63acd246be30cdc4860bb9a9d9d202dae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:18:33 +0000 Subject: [PATCH 01/11] feat(workspace): name tonight's first unlogged practice pass The ready map already records 0-100 practice progress after a part is selected. Name the first part that still has no stored mark and tell the player to select it and record tonight's first pass. Malformed marks and duplicate role ids fail closed. --- AGENTS.md | 2 +- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 29 ++++++ .../src/features/workspace/Workspace.tsx | 17 ++++ .../workspace/firstUnloggedPractice.test.ts | 93 ++++++++++++++++++ .../workspace/firstUnloggedPractice.ts | 96 +++++++++++++++++++ apps/desktop/src/locales/en/common.json | 4 + apps/desktop/src/locales/ko/common.json | 4 + docs/architecture/overview.md | 1 + docs/doctoring/first-unlogged-practice.md | 15 +++ 12 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts create mode 100644 apps/desktop/src/features/workspace/firstUnloggedPractice.ts create mode 100644 docs/doctoring/first-unlogged-practice.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..05b66797b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. -- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. +- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, setup cues, and unlogged practice passes are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. ## Safety diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..6cff2e4ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - the first named part that still has no practice mark, with a record-tonight's-first-pass next action - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..6c2facd0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. +- Name tonight's first unlogged practice pass on the ready rehearsal map and tell the player to select that part and record it. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..8147db023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, the next instrument check, and the first unlogged practice pass to record. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..c7c17c72e 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -196,6 +196,35 @@ describe("Workspace", () => { ); }); + it("names tonight's first unlogged practice pass and tells the player to record it", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const callout = screen.getByTestId("first-unlogged-practice"); + expect(callout).toHaveTextContent("Tonight's first unlogged pass"); + expect(callout).toHaveTextContent( + "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass." + ); + }); + + it("asks the selected part to switch when it already has a practice mark", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 80 + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("first-unlogged-practice")).toHaveTextContent( + "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass." + ); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..3b43786f6 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillUnloggedPracticeCopy, firstUnloggedPractice } from "./firstUnloggedPractice"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const firstUnlogged = useMemo(() => firstUnloggedPractice(song, activeRole), [activeRole, song]); + const firstUnloggedCopy = firstUnlogged + ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), { + roleName: firstUnlogged.roleName, + sectionLabel: firstUnlogged.sectionLabel + }) + : t(activeRole ? "workspaceFirstUnloggedPracticeSelectedReady" : "workspaceFirstUnloggedPracticeMissing"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +318,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+
+

{t("workspaceFirstUnloggedPracticeTitle")}

+

{firstUnloggedCopy}

+
+

{t("workspaceSongTimelineLabel")}

diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts new file mode 100644 index 000000000..ea3481a25 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -0,0 +1,93 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillUnloggedPracticeCopy, + firstUnloggedPractice, + hasLoggedPracticeProgress +} from "./firstUnloggedPractice"; + +describe("hasLoggedPracticeProgress", () => { + it("admits only 0–100 integers", () => { + expect(hasLoggedPracticeProgress(0)).toBe(true); + expect(hasLoggedPracticeProgress(100)).toBe(true); + expect(hasLoggedPracticeProgress(40)).toBe(true); + expect(hasLoggedPracticeProgress(undefined)).toBe(false); + expect(hasLoggedPracticeProgress(40.5)).toBe(false); + expect(hasLoggedPracticeProgress(-1)).toBe(false); + expect(hasLoggedPracticeProgress(101)).toBe(false); + expect(hasLoggedPracticeProgress("40")).toBe(false); + }); +}); + +describe("firstUnloggedPractice", () => { + it("names the first demo part that still has no practice mark", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong())).toEqual({ + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("skips parts that already own a 0–100 mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 40 + }; + + expect(firstUnloggedPractice(song)).toEqual({ + sectionLabel: "verse", + roleName: "Keyboard 1 Right Hand" + }); + }); + + it("limits the callout to the selected unlogged part", () => { + expect(firstUnloggedPractice(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal" + }); + }); + + it("returns null when the selected part already has a mark", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 0 + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toBeNull(); + }); + + it("skips malformed marks and duplicate role ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + practiceProgress: 150 as unknown as number + }; + song.sections[0]!.roles[1] = { + ...song.sections[0]!.roles[1]!, + id: "bass-guitar" + }; + + expect(firstUnloggedPractice(song)?.roleName).toBe("Lead Vocal"); + }); + + it("fails closed on malformed runtime roots and collections", () => { + for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { + expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toBeNull(); + } + }); +}); + +describe("fillUnloggedPracticeCopy", () => { + it("replaces tokens without inheriting object members", () => { + expect( + fillUnloggedPracticeCopy("{roleName} in {sectionLabel} before {sectionLabel}.", { + roleName: "Bass Guitar", + sectionLabel: "verse" + }) + ).toBe("Bass Guitar in verse before verse."); + expect(fillUnloggedPracticeCopy("Check {toString} before {missingToken}.", { roleName: "Bass Guitar" })).toBe( + "Check {toString} before {missingToken}." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts new file mode 100644 index 000000000..5c3672667 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -0,0 +1,96 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Tonight's first named part that still has no stored practice mark. */ +export type FirstUnloggedPractice = { + sectionLabel: string; + roleName: string; +}; + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Return whether a role already owns a 0–100 integer practice mark. */ +export function hasLoggedPracticeProgress(value: unknown): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; +} + +/** + * Pick the first named part that still needs tonight's first practice mark. + * + * Missing `practiceProgress` is unlogged. Out-of-range or non-integer marks are + * not authority and are skipped. Runtime roots and collection members are + * untrusted; malformed evidence fails closed instead of naming a pass. + */ +export function firstUnloggedPractice( + song: RehearsalSong, + activeRole: string | null = null +): FirstUnloggedPractice | null { + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + return null; + } + + const seenRoleIds = new Set(); + const repeatedRoleIds = new Set(); + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + continue; + } + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + const roleId = meaningfulRangeText(roleValue.id); + if (!roleId) { + continue; + } + if (seenRoleIds.has(roleId)) { + repeatedRoleIds.add(roleId); + } else { + seenRoleIds.add(roleId); + } + } + } + + for (const sectionValue of runtimeSong.sections) { + if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { + continue; + } + const sectionLabel = meaningfulRangeText(sectionValue.label); + if (!sectionLabel) { + continue; + } + + for (const roleValue of sectionValue.roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + const roleId = meaningfulRangeText(roleValue.id); + const roleName = meaningfulRangeText(roleValue.name); + if (!roleId || !roleName || repeatedRoleIds.has(roleId)) { + continue; + } + if (activeRole && roleId !== activeRole) { + continue; + } + if (hasLoggedPracticeProgress(roleValue.practiceProgress)) { + continue; + } + if (roleValue.practiceProgress !== undefined && !hasLoggedPracticeProgress(roleValue.practiceProgress)) { + continue; + } + return { sectionLabel, roleName }; + } + } + + return null; +} + +/** Fill trusted `{token}` placeholders for unlogged-practice copy. */ +export function fillUnloggedPracticeCopy(template: string, values: Record): string { + return fillRangeCopy(template, values); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..ec8ffdccd 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstUnloggedPracticeTitle": "Tonight's first unlogged pass", + "workspaceFirstUnloggedPracticeCheck": "{roleName} in {sectionLabel} has no practice logged yet. Select that part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeMissing": "Every named part already has a practice mark. Keep going until the room is ready.", + "workspaceFirstUnloggedPracticeSelectedReady": "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..b5990352b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,10 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstUnloggedPracticeTitle": "오늘 아직 기록 안 된 첫 연습", + "workspaceFirstUnloggedPracticeCheck": "{sectionLabel}의 {roleName}은 아직 연습 기록이 없습니다. 그 파트를 선택하고 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeMissing": "이름 있는 파트는 모두 연습 기록이 있습니다. 방이 준비될 때까지 이어서 연습하세요.", + "workspaceFirstUnloggedPracticeSelectedReady": "이 파트는 이미 연습 기록이 있습니다. 아직 기록 안 된 다음 파트로 바꿔 오늘 첫 패스를 기록하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..b51cef266 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -12,6 +12,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c - section roadmap with entries, dropouts, pickups, stops, and handoffs - groove and timing cues - role ranges, overlap warnings, and simplification guidance +- the first named part that still has no practice mark, with a record-tonight's-first-pass next action - transposition, capo, tuning, or setup cues where relevant - role-specific confidence and rehearsal priority diff --git a/docs/doctoring/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md new file mode 100644 index 000000000..d96b757c4 --- /dev/null +++ b/docs/doctoring/first-unlogged-practice.md @@ -0,0 +1,15 @@ +# First unlogged practice pass + +The ready workspace names the first part that still has no stored `practiceProgress` so a player can select it and record tonight's first pass. + +## Authority + +`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires unique owned identity and a named section/part, treats missing `practiceProgress` as unlogged, and skips non-integer or out-of-range marks instead of inventing a pass. + +#1107 still owns the selected-part tracker copy. This slice does not start playback (#961) or change MIR (#828 / #770). + +## Security notes + +- Untrusted input: `practiceProgress` and role/section identity inside a loaded project payload. +- Trust boundary: project JSON → lexical admission → React copy. +- Safe failure: no filesystem, URL, or network dereference; malformed marks never become practice authority. From f88acba2707347325694c099dee02535c948d65b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:32:38 +0900 Subject: [PATCH 02/11] test(workspace): reproduce repeated-role practice authority drift --- .../workspace/firstUnloggedPractice.test.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts index ea3481a25..e35cde58e 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -57,7 +57,7 @@ describe("firstUnloggedPractice", () => { expect(firstUnloggedPractice(song, "lead-vocal")).toBeNull(); }); - it("skips malformed marks and duplicate role ids", () => { + it("skips malformed marks and duplicate role ids inside one section", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, @@ -71,6 +71,56 @@ describe("firstUnloggedPractice", () => { expect(firstUnloggedPractice(song)?.roleName).toBe("Lead Vocal"); }); + it("treats the same named role across sections as one part", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections.push({ + ...structuredClone(verse), + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }); + + expect(firstUnloggedPractice(song)).toEqual({ + sectionLabel: "verse", + roleName: "Bass Guitar" + }); + }); + + it("fails closed when repeated section copies disagree about practice state", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + chorus.roles[0] = { + ...chorus.roles[0]!, + practiceProgress: 40 + }; + song.sections.push(chorus); + + expect(firstUnloggedPractice(song, "bass-guitar")).toBeNull(); + }); + + it("rejects inherited identity and practice evidence", () => { + const inheritedRole = Object.create({ + id: "ghost-role", + name: "Ghost Role", + practiceProgress: 40 + }) as Record; + const song = { + sections: [ + { + label: "verse", + roles: [inheritedRole] + } + ] + } as unknown as RehearsalSong; + + expect(firstUnloggedPractice(song)).toBeNull(); + }); + it("fails closed on malformed runtime roots and collections", () => { for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toBeNull(); From dc65cc78b949f22e6af3f16a19f238bbe4dfa2b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:33:10 +0900 Subject: [PATCH 03/11] fix(workspace): reconcile repeated role practice evidence --- .../workspace/firstUnloggedPractice.ts | 138 +++++++++++++----- 1 file changed, 103 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index 5c3672667..5340f9b33 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -7,84 +7,152 @@ export type FirstUnloggedPractice = { roleName: string; }; +type PracticeMark = + | { kind: "unlogged" } + | { kind: "logged"; value: number } + | { kind: "invalid" }; + +type RoleEvidence = { + roleName: string; + firstSectionLabel: string; + marks: PracticeMark[]; +}; + /** Return whether an untrusted runtime value is a plain object record. */ function isRuntimeObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Return whether a record owns a field rather than inheriting it. */ +function owns(record: Record, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + /** Return whether a role already owns a 0–100 integer practice mark. */ export function hasLoggedPracticeProgress(value: unknown): boolean { return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100; } +/** Admit one role-copy practice mark without granting inherited values authority. */ +function practiceMark(roleValue: Record): PracticeMark { + if (!owns(roleValue, "practiceProgress")) { + return { kind: "unlogged" }; + } + const value = roleValue.practiceProgress; + if (!hasLoggedPracticeProgress(value)) { + return { kind: "invalid" }; + } + return { kind: "logged", value }; +} + +/** Return whether every section copy agrees that the named part is still unlogged. */ +function isConsistentlyUnlogged(marks: PracticeMark[]): boolean { + if (marks.length === 0 || marks.some((mark) => mark.kind === "invalid")) { + return false; + } + + const logged = marks.filter((mark): mark is Extract => mark.kind === "logged"); + if (logged.length === 0) { + return true; + } + if (logged.length !== marks.length) { + return false; + } + + const expected = logged[0]!.value; + return logged.every((mark) => mark.value === expected) ? false : false; +} + /** * Pick the first named part that still needs tonight's first practice mark. * - * Missing `practiceProgress` is unlogged. Out-of-range or non-integer marks are - * not authority and are skipped. Runtime roots and collection members are - * untrusted; malformed evidence fails closed instead of naming a pass. + * The same role id may legitimately appear in several song sections. Those + * copies are one rehearsal part only when their display name agrees and every + * copy consistently omits `practiceProgress`. A duplicate id inside one + * section, conflicting names, mixed logged/unlogged copies, malformed marks, + * inherited identity, or malformed collection evidence is not authority and + * cannot produce a rehearsal instruction. */ export function firstUnloggedPractice( song: RehearsalSong, activeRole: string | null = null ): FirstUnloggedPractice | null { const runtimeSong: unknown = song; - if (!isRuntimeObject(runtimeSong) || !Array.isArray(runtimeSong.sections)) { + if (!isRuntimeObject(runtimeSong) || !owns(runtimeSong, "sections") || !Array.isArray(runtimeSong.sections)) { return null; } - const seenRoleIds = new Set(); - const repeatedRoleIds = new Set(); + const evidenceByRole = new Map(); + const roleOrder: string[] = []; + const invalidRoleIds = new Set(); for (const sectionValue of runtimeSong.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { - continue; - } - for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue)) { - continue; - } - const roleId = meaningfulRangeText(roleValue.id); - if (!roleId) { - continue; - } - if (seenRoleIds.has(roleId)) { - repeatedRoleIds.add(roleId); - } else { - seenRoleIds.add(roleId); - } + if ( + !isRuntimeObject(sectionValue) || + !owns(sectionValue, "label") || + !owns(sectionValue, "roles") || + !Array.isArray(sectionValue.roles) + ) { + return null; } - } - for (const sectionValue of runtimeSong.sections) { - if (!isRuntimeObject(sectionValue) || !Array.isArray(sectionValue.roles)) { - continue; - } const sectionLabel = meaningfulRangeText(sectionValue.label); if (!sectionLabel) { - continue; + return null; } + const sectionRoleIds = new Set(); for (const roleValue of sectionValue.roles) { - if (!isRuntimeObject(roleValue)) { + if ( + !isRuntimeObject(roleValue) || + !owns(roleValue, "id") || + !owns(roleValue, "name") + ) { continue; } + const roleId = meaningfulRangeText(roleValue.id); const roleName = meaningfulRangeText(roleValue.name); - if (!roleId || !roleName || repeatedRoleIds.has(roleId)) { + if (!roleId || !roleName) { continue; } - if (activeRole && roleId !== activeRole) { + + if (sectionRoleIds.has(roleId)) { + invalidRoleIds.add(roleId); continue; } - if (hasLoggedPracticeProgress(roleValue.practiceProgress)) { + sectionRoleIds.add(roleId); + + const existing = evidenceByRole.get(roleId); + if (!existing) { + evidenceByRole.set(roleId, { + roleName, + firstSectionLabel: sectionLabel, + marks: [practiceMark(roleValue)] + }); + roleOrder.push(roleId); continue; } - if (roleValue.practiceProgress !== undefined && !hasLoggedPracticeProgress(roleValue.practiceProgress)) { - continue; + + if (existing.roleName !== roleName) { + invalidRoleIds.add(roleId); } - return { sectionLabel, roleName }; + existing.marks.push(practiceMark(roleValue)); + } + } + + for (const roleId of roleOrder) { + if (invalidRoleIds.has(roleId) || (activeRole && roleId !== activeRole)) { + continue; + } + const evidence = evidenceByRole.get(roleId); + if (!evidence || !isConsistentlyUnlogged(evidence.marks)) { + continue; } + return { + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; } return null; From 26ac2a248ae66b69ff533489386c306a17da8ec4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:33:43 +0900 Subject: [PATCH 04/11] refactor(workspace): keep unlogged admission explicit --- .../features/workspace/firstUnloggedPractice.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index 5340f9b33..f35096a61 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -47,20 +47,7 @@ function practiceMark(roleValue: Record): PracticeMark { /** Return whether every section copy agrees that the named part is still unlogged. */ function isConsistentlyUnlogged(marks: PracticeMark[]): boolean { - if (marks.length === 0 || marks.some((mark) => mark.kind === "invalid")) { - return false; - } - - const logged = marks.filter((mark): mark is Extract => mark.kind === "logged"); - if (logged.length === 0) { - return true; - } - if (logged.length !== marks.length) { - return false; - } - - const expected = logged[0]!.value; - return logged.every((mark) => mark.value === expected) ? false : false; + return marks.length > 0 && marks.every((mark) => mark.kind === "unlogged"); } /** From 81b661d17418003cff87ea80d35166ec91949642 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:34:33 +0900 Subject: [PATCH 05/11] docs(workspace): repair unlogged-practice authority note --- docs/doctoring/first-unlogged-practice.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md index d96b757c4..bf4695c96 100644 --- a/docs/doctoring/first-unlogged-practice.md +++ b/docs/doctoring/first-unlogged-practice.md @@ -4,12 +4,12 @@ The ready workspace names the first part that still has no stored `practiceProgr ## Authority -`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires unique owned identity and a named section/part, treats missing `practiceProgress` as unlogged, and skips non-integer or out-of-range marks instead of inventing a pass. +`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires owned role/section identity, allows the same role id to recur across sections only when its display name remains consistent, treats a role as unlogged only when every admitted section copy omits `practiceProgress`, and rejects duplicate ids inside one section or malformed/conflicting practice evidence instead of inventing a pass. -#1107 still owns the selected-part tracker copy. This slice does not start playback (#961) or change MIR (#828 / #770). +Issue `#1107` still owns the selected-part tracker copy. This slice does not start playback (`#961`) or change MIR (`#828` / `#770`). ## Security notes - Untrusted input: `practiceProgress` and role/section identity inside a loaded project payload. - Trust boundary: project JSON → lexical admission → React copy. -- Safe failure: no filesystem, URL, or network dereference; malformed marks never become practice authority. +- Safe failure: inherited identity, malformed collections, conflicting section copies, and malformed marks never become rehearsal authority; no filesystem, URL, subprocess, IPC, or network dereference is added. From f38dd6d95221559e19e78e079eacc599224a4c5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:37:43 +0900 Subject: [PATCH 06/11] test(workspace): distinguish invalid practice from completed state --- .../workspace/firstUnloggedPractice.test.ts | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts index e35cde58e..f97e306a2 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts @@ -22,6 +22,7 @@ describe("hasLoggedPracticeProgress", () => { describe("firstUnloggedPractice", () => { it("names the first demo part that still has no practice mark", () => { expect(firstUnloggedPractice(createDemoRehearsalSong())).toEqual({ + kind: "unlogged", sectionLabel: "verse", roleName: "Bass Guitar" }); @@ -35,6 +36,7 @@ describe("firstUnloggedPractice", () => { }; expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", sectionLabel: "verse", roleName: "Keyboard 1 Right Hand" }); @@ -42,19 +44,20 @@ describe("firstUnloggedPractice", () => { it("limits the callout to the selected unlogged part", () => { expect(firstUnloggedPractice(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + kind: "unlogged", sectionLabel: "verse", roleName: "Lead Vocal" }); }); - it("returns null when the selected part already has a mark", () => { + it("distinguishes a selected part with a trustworthy practice mark", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[2] = { ...song.sections[0]!.roles[2]!, practiceProgress: 0 }; - expect(firstUnloggedPractice(song, "lead-vocal")).toBeNull(); + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" }); }); it("skips malformed marks and duplicate role ids inside one section", () => { @@ -68,7 +71,11 @@ describe("firstUnloggedPractice", () => { id: "bass-guitar" }; - expect(firstUnloggedPractice(song)?.roleName).toBe("Lead Vocal"); + expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", + sectionLabel: "verse", + roleName: "Lead Vocal" + }); }); it("treats the same named role across sections as one part", () => { @@ -82,12 +89,13 @@ describe("firstUnloggedPractice", () => { }); expect(firstUnloggedPractice(song)).toEqual({ + kind: "unlogged", sectionLabel: "verse", roleName: "Bass Guitar" }); }); - it("fails closed when repeated section copies disagree about practice state", () => { + it("reports unavailable when repeated section copies disagree about selected practice state", () => { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; const chorus = structuredClone(verse); @@ -100,7 +108,37 @@ describe("firstUnloggedPractice", () => { }; song.sections.push(chorus); - expect(firstUnloggedPractice(song, "bass-guitar")).toBeNull(); + expect(firstUnloggedPractice(song, "bass-guitar")).toEqual({ kind: "unavailable" }); + }); + + it("reports unavailable for a selected malformed practice mark instead of claiming it is logged", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 150 as unknown as number + }; + + expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "unavailable" }); + }); + + it("returns all-logged only when every named role has trustworthy consistent marks", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "all-logged" }); + }); + + it("reports unavailable rather than all-logged when the remaining evidence is malformed", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({ + ...role, + practiceProgress: index === 2 ? (150 as unknown as number) : index * 40 + })); + + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); }); it("rejects inherited identity and practice evidence", () => { @@ -118,12 +156,12 @@ describe("firstUnloggedPractice", () => { ] } as unknown as RehearsalSong; - expect(firstUnloggedPractice(song)).toBeNull(); + expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" }); }); it("fails closed on malformed runtime roots and collections", () => { for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) { - expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toBeNull(); + expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toEqual({ kind: "unavailable" }); } }); }); From 898394f4b4e69ada229defcb49fbcc53bf7de665 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:38:19 +0900 Subject: [PATCH 07/11] fix(workspace): separate unlogged practice trust states --- .../workspace/firstUnloggedPractice.ts | 100 ++++++++++++++---- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts index f35096a61..37c6e47fc 100644 --- a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts +++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts @@ -1,11 +1,16 @@ import type { RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; -/** Tonight's first named part that still has no stored practice mark. */ -export type FirstUnloggedPractice = { - sectionLabel: string; - roleName: string; -}; +/** Trustworthy state of tonight's first unlogged-practice decision. */ +export type FirstUnloggedPractice = + | { + kind: "unlogged"; + sectionLabel: string; + roleName: string; + } + | { kind: "selected-logged" } + | { kind: "all-logged" } + | { kind: "unavailable" }; type PracticeMark = | { kind: "unlogged" } @@ -50,28 +55,40 @@ function isConsistentlyUnlogged(marks: PracticeMark[]): boolean { return marks.length > 0 && marks.every((mark) => mark.kind === "unlogged"); } +/** Return whether every section copy owns the same trustworthy practice mark. */ +function isConsistentlyLogged(marks: PracticeMark[]): boolean { + if (marks.length === 0 || marks.some((mark) => mark.kind !== "logged")) { + return false; + } + const expected = (marks[0] as Extract).value; + return marks.every( + (mark) => mark.kind === "logged" && mark.value === expected + ); +} + /** - * Pick the first named part that still needs tonight's first practice mark. + * Resolve tonight's first trustworthy unlogged-practice state. * * The same role id may legitimately appear in several song sections. Those - * copies are one rehearsal part only when their display name agrees and every - * copy consistently omits `practiceProgress`. A duplicate id inside one + * copies are one rehearsal part only when their display name agrees and their + * practice evidence is role-wide consistent. Duplicate ids inside one * section, conflicting names, mixed logged/unlogged copies, malformed marks, - * inherited identity, or malformed collection evidence is not authority and - * cannot produce a rehearsal instruction. + * inherited identity, or malformed collection evidence never become proof + * that a part—or the whole rehearsal—has already been logged. */ export function firstUnloggedPractice( song: RehearsalSong, activeRole: string | null = null -): FirstUnloggedPractice | null { +): FirstUnloggedPractice { const runtimeSong: unknown = song; if (!isRuntimeObject(runtimeSong) || !owns(runtimeSong, "sections") || !Array.isArray(runtimeSong.sections)) { - return null; + return { kind: "unavailable" }; } const evidenceByRole = new Map(); const roleOrder: string[] = []; const invalidRoleIds = new Set(); + let hasInvalidEvidence = false; for (const sectionValue of runtimeSong.sections) { if ( @@ -80,12 +97,12 @@ export function firstUnloggedPractice( !owns(sectionValue, "roles") || !Array.isArray(sectionValue.roles) ) { - return null; + return { kind: "unavailable" }; } const sectionLabel = meaningfulRangeText(sectionValue.label); if (!sectionLabel) { - return null; + return { kind: "unavailable" }; } const sectionRoleIds = new Set(); @@ -95,27 +112,35 @@ export function firstUnloggedPractice( !owns(roleValue, "id") || !owns(roleValue, "name") ) { + hasInvalidEvidence = true; continue; } const roleId = meaningfulRangeText(roleValue.id); const roleName = meaningfulRangeText(roleValue.name); if (!roleId || !roleName) { + hasInvalidEvidence = true; continue; } if (sectionRoleIds.has(roleId)) { invalidRoleIds.add(roleId); + hasInvalidEvidence = true; continue; } sectionRoleIds.add(roleId); + const mark = practiceMark(roleValue); + if (mark.kind === "invalid") { + hasInvalidEvidence = true; + } + const existing = evidenceByRole.get(roleId); if (!existing) { evidenceByRole.set(roleId, { roleName, firstSectionLabel: sectionLabel, - marks: [practiceMark(roleValue)] + marks: [mark] }); roleOrder.push(roleId); continue; @@ -123,26 +148,55 @@ export function firstUnloggedPractice( if (existing.roleName !== roleName) { invalidRoleIds.add(roleId); + hasInvalidEvidence = true; } - existing.marks.push(practiceMark(roleValue)); + existing.marks.push(mark); } } + if (activeRole) { + const evidence = evidenceByRole.get(activeRole); + if (!evidence || invalidRoleIds.has(activeRole)) { + return { kind: "unavailable" }; + } + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (isConsistentlyLogged(evidence.marks)) { + return { kind: "selected-logged" }; + } + return { kind: "unavailable" }; + } + for (const roleId of roleOrder) { - if (invalidRoleIds.has(roleId) || (activeRole && roleId !== activeRole)) { + if (invalidRoleIds.has(roleId)) { continue; } const evidence = evidenceByRole.get(roleId); - if (!evidence || !isConsistentlyUnlogged(evidence.marks)) { + if (!evidence) { + hasInvalidEvidence = true; continue; } - return { - sectionLabel: evidence.firstSectionLabel, - roleName: evidence.roleName - }; + if (isConsistentlyUnlogged(evidence.marks)) { + return { + kind: "unlogged", + sectionLabel: evidence.firstSectionLabel, + roleName: evidence.roleName + }; + } + if (!isConsistentlyLogged(evidence.marks)) { + hasInvalidEvidence = true; + } } - return null; + if (roleOrder.length === 0 || invalidRoleIds.size > 0 || hasInvalidEvidence) { + return { kind: "unavailable" }; + } + return { kind: "all-logged" }; } /** Fill trusted `{token}` placeholders for unlogged-practice copy. */ From c7b1ca4f632c95fbeb8b062c65b7f67b5229fe93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:39:44 +0900 Subject: [PATCH 08/11] test(workspace): reject false completed-practice copy --- .../src/features/workspace/Workspace.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index c7c17c72e..fd8554f0f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -225,6 +225,24 @@ describe("Workspace", () => { ); }); + it("does not call malformed selected progress a completed practice mark", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2] = { + ...song.sections[0]!.roles[2]!, + practiceProgress: 150 as unknown as number + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const callout = screen.getByTestId("first-unlogged-practice"); + expect(callout).toHaveTextContent( + "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged." + ); + expect(callout).not.toHaveTextContent("already has a practice mark"); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); From e35d408a056cb86a71c3289e4e611b632ea91094 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:40:41 +0900 Subject: [PATCH 09/11] feat(i18n): name unavailable practice evidence --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index ec8ffdccd..5cb997e4d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -157,6 +157,7 @@ "workspaceFirstUnloggedPracticeCheck": "{roleName} in {sectionLabel} has no practice logged yet. Select that part and record tonight's first pass.", "workspaceFirstUnloggedPracticeMissing": "Every named part already has a practice mark. Keep going until the room is ready.", "workspaceFirstUnloggedPracticeSelectedReady": "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass.", + "workspaceFirstUnloggedPracticeUnavailable": "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } From 51e0296b440253b87caddd309e21942185138b5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:41:13 +0900 Subject: [PATCH 10/11] feat(i18n): localize unavailable practice evidence --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index b5990352b..59d80fc80 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -157,6 +157,7 @@ "workspaceFirstUnloggedPracticeCheck": "{sectionLabel}의 {roleName}은 아직 연습 기록이 없습니다. 그 파트를 선택하고 오늘 첫 패스를 기록하세요.", "workspaceFirstUnloggedPracticeMissing": "이름 있는 파트는 모두 연습 기록이 있습니다. 방이 준비될 때까지 이어서 연습하세요.", "workspaceFirstUnloggedPracticeSelectedReady": "이 파트는 이미 연습 기록이 있습니다. 아직 기록 안 된 다음 파트로 바꿔 오늘 첫 패스를 기록하세요.", + "workspaceFirstUnloggedPracticeUnavailable": "이 파트의 연습 진행 정보가 서로 맞지 않거나 유효하지 않습니다. 기록된 것으로 보기 전에 연습 표시를 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From b721ea7648d50259acbcf286068041db7b153171 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:42:42 +0900 Subject: [PATCH 11/11] fix(workspace): render practice evidence states explicitly --- .../src/features/workspace/Workspace.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 3b43786f6..180210a7f 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -165,12 +165,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp ) : t("workspaceFirstRangeMissing"); const firstUnlogged = useMemo(() => firstUnloggedPractice(song, activeRole), [activeRole, song]); - const firstUnloggedCopy = firstUnlogged - ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), { - roleName: firstUnlogged.roleName, - sectionLabel: firstUnlogged.sectionLabel - }) - : t(activeRole ? "workspaceFirstUnloggedPracticeSelectedReady" : "workspaceFirstUnloggedPracticeMissing"); + const firstUnloggedCopy = + firstUnlogged.kind === "unlogged" + ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), { + roleName: firstUnlogged.roleName, + sectionLabel: firstUnlogged.sectionLabel + }) + : firstUnlogged.kind === "selected-logged" + ? t("workspaceFirstUnloggedPracticeSelectedReady") + : firstUnlogged.kind === "all-logged" + ? t("workspaceFirstUnloggedPracticeMissing") + : t("workspaceFirstUnloggedPracticeUnavailable"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -529,4 +534,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ No newline at end of file