diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 5c1c16989..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2024-05-18 - Added focus visible styles for keyboard navigation -**Learning:** Interactive inline buttons (like the chord editor) and scrollable regions with `tabIndex={0}` do not automatically get focus visible styles, meaning keyboard users tabbing through won't know they are focused on them. Unlike central ` + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..44da1ae01 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBlockedCallout.workspace-scope.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; + +function blockedSong(id: string) { + const song = createDemoRehearsalSong(); + song.id = id; + const section = structuredClone(song.sections[0]!); + section.id = `${id}-blocked-section`; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: `${id}-blocked-assignment`, + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: section.id, + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + return song; +} + +describe("FirstBlockedCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { name: "Open verse blocker at 0:10" }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..4bd56d2e8 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,40 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first blocked assignment on the mounted map", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.collaboration = { + ...song.collaboration!, + assignments: [ + ...song.collaboration!.assignments, + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "verse-1", + roleId: "keys-right", + status: "blocked" + } + ] + }; + render(); + + expect(screen.getByText("Tonight's first blocked job")).toBeTruthy(); + expect( + screen.getByText("Keys is blocked on Keyboard 1 Right Hand in the verse at 0:10.") + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open verse blocker at 0:10" })).toBeTruthy(); + }); + + it("keeps the demo map honest when no assignment is blocked", () => { + setNavigatorLanguage("en-US"); + render(); + + expect( + screen.getByText("No blocked job yet. Stay on tonight's map until a part is stuck.") + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /blocker/i })).toBeNull(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..d36128dd6 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstBlockedCallout } from "./FirstBlockedCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, index) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts new file mode 100644 index 000000000..319d88498 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.inherited-metadata.test.ts @@ -0,0 +1,107 @@ +import { createDemoRehearsalSong, type RehearsalAssignment } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstBlockedAssignment } from "./firstBlocked"; + +function songWithBlocked() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "block-own"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: "assign-keys-blocked", + assignee: "Keys", + summary: "Wait on the in-ear mix before the verse color pass.", + sectionId: "block-own", + roleId: "keys-right", + status: "blocked" + } + ], + comments: [], + approvals: [] + }; + return { song, section }; +} + +describe("resolveFirstBlockedAssignment inherited metadata", () => { + it("rejects a song or collaboration whose required metadata is inherited", () => { + const { song } = songWithBlocked(); + const inheritedSong = Object.create({ + collaboration: song.collaboration, + sections: song.sections + }) as typeof song; + expect(resolveFirstBlockedAssignment(inheritedSong)).toBeNull(); + + const inheritedCollaboration = Object.create(song.collaboration!) as NonNullable< + typeof song.collaboration + >; + song.collaboration = inheritedCollaboration; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects inherited assignment fields", () => { + const { song } = songWithBlocked(); + song.collaboration!.assignments = [ + Object.create(song.collaboration!.assignments[0]!) as RehearsalAssignment + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects inherited timing fields when a unique section is required", () => { + const { song, section } = songWithBlocked(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song } = songWithBlocked(); + Object.defineProperty(song.collaboration!.assignments[0]!, "summary", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile summary getter"); + } + }); + + expect(() => resolveFirstBlockedAssignment(song)).not.toThrow(); + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not treat own accessors as stable blocked identity authority", () => { + const { song } = songWithBlocked(); + Object.defineProperty(song.collaboration!.assignments[0]!, "id", { + configurable: true, + enumerable: true, + get() { + return "assign-keys-blocked"; + } + }); + + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not let inherited section metadata host the blocked job", () => { + const { song, section } = songWithBlocked(); + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithBlocked(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("rejects sparse assignment arrays", () => { + const { song } = songWithBlocked(); + const sparse: RehearsalAssignment[] = []; + sparse[1] = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = sparse; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBlocked.test.ts b/apps/desktop/src/features/workspace/firstBlocked.test.ts new file mode 100644 index 000000000..e446b3883 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import { + createDemoRehearsalSong, + type RehearsalAssignment, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { formatBlockedTime, resolveFirstBlockedAssignment } from "./firstBlocked"; + +function withBlocked( + overrides: { + assignmentId?: string; + summary?: string; + assignee?: string; + status?: RehearsalAssignment["status"]; + sectionId?: string; + start?: number; + end?: number; + label?: SectionFormLabel; + roleId?: string | undefined; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.sectionId ?? "verse-blocked"; + section.label = overrides.label ?? "verse"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep blocked jobs local for now.", + assignments: [ + { + id: overrides.assignmentId ?? "assign-keys-blocked", + assignee: overrides.assignee ?? "Keys", + summary: overrides.summary ?? "Wait on the in-ear mix before the verse color pass.", + sectionId: section.id, + roleId: overrides.roleId === undefined ? "keys-right" : overrides.roleId, + status: overrides.status ?? "blocked" + } + ], + comments: [], + approvals: [] + }; + if (overrides.roleId === undefined) { + return song; + } + if (overrides.roleId === "") { + delete song.collaboration.assignments[0]!.roleId; + } + return song; +} + +describe("resolveFirstBlockedAssignment", () => { + it("does not invent a blocked job from the demo in-progress or todo assignments", () => { + expect(resolveFirstBlockedAssignment(createDemoRehearsalSong())).toBeNull(); + }); + + it("picks the earliest owned blocked assignment and its unique section", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked()); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.assignment.assignee).toBe("Keys"); + expect(resolved?.hint).toBe("Wait on the in-ear mix before the verse color pass."); + expect(resolved?.section.id).toBe("verse-blocked"); + expect(resolved?.holdingRole?.id).toBe("keys-right"); + expect(resolved?.atSeconds).toBe(10); + expect(formatBlockedTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatBlockedTime(Number.NaN)).toBe("0:00"); + expect(formatBlockedTime(-4)).toBe("0:00"); + }); + + it("does not invent a blocked job from todo, in_progress, ready, comments, or approvals", () => { + const song = withBlocked({ status: "todo" }); + song.collaboration!.assignments = [ + { + id: "assign-bass-entrance", + assignee: "Rhythm Section", + summary: "Lock the bass entrance against the pickup so the chorus lift lands together.", + sectionId: song.sections[0]!.id, + roleId: "bass-guitar", + status: "in_progress" + }, + { + id: "assign-vocal-ready", + assignee: "Lead Vocal", + summary: "Verse key decision is ready for the first pass.", + sectionId: song.sections[0]!.id, + roleId: "lead-vocal", + status: "ready" + }, + song.collaboration!.assignments[0]! + ]; + song.collaboration!.comments = [ + { + id: "comment-keys-color", + author: "MD", + body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "open" + } + ]; + song.collaboration!.approvals = [ + { + id: "approval-harmony-pass", + scope: "Verse harmony pass", + owner: "MD", + status: "pending" + } + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace summary as a named blocked job", () => { + expect(resolveFirstBlockedAssignment(withBlocked({ summary: "" }))).toBeNull(); + expect(resolveFirstBlockedAssignment(withBlocked({ summary: " \n\t " }))).toBeNull(); + }); + + it("prefers the earlier of two blocked jobs", () => { + const song = withBlocked({ + assignmentId: "assign-late", + start: 40, + end: 56, + label: "chorus", + summary: "Chorus lift is waiting on the in-ear mix." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.label = "verse"; + earlier.timeRange = { start: 8, end: 24 }; + song.sections = [song.sections[0]!, earlier]; + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + id: "assign-early", + assignee: "Rhythm Section", + summary: "Bass entrance is waiting on the click.", + sectionId: "verse-early", + roleId: "bass-guitar", + status: "blocked" + } + ]; + + const resolved = resolveFirstBlockedAssignment(song); + expect(resolved?.assignment.id).toBe("assign-early"); + expect(resolved?.atSeconds).toBe(8); + }); + + it("keeps the blocked job band-wide when the holding role is missing", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked({ roleId: "" })); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.section.id).toBe("verse-blocked"); + }); + + it("does not invent a section from a missing or duplicated section pointer", () => { + const missing = withBlocked(); + missing.collaboration!.assignments[0]!.sectionId = "missing-section"; + expect(resolveFirstBlockedAssignment(missing)).toBeNull(); + + const song = withBlocked(); + const duplicate = structuredClone(song.sections[0]!); + duplicate.timeRange = { start: 40, end: 56 }; + song.sections = [song.sections[0]!, duplicate]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("bounds a long owned summary without splitting a surrogate pair", () => { + const song = withBlocked({ + summary: `${"a".repeat(179)}\uD83D\uDE80trailing` + }); + expect(resolveFirstBlockedAssignment(song)?.hint).toBe(`${"a".repeat(179)}\uD83D\uDE80`); + }); + + it("ties equal blocked times with a stable id", () => { + const song = withBlocked({ assignmentId: "z-late", summary: "Later blocked mix." }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + { + id: "a-early", + assignee: "MD", + summary: "Earlier blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "blocked" + } + ]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("a-early"); + }); + + it("orders equal-time blocked jobs in both id directions", () => { + const song = withBlocked({ assignmentId: "m-mid", start: 12, end: 28, summary: "Middle blocked mix." }); + song.collaboration!.assignments = [ + { + id: "z-late", + assignee: "MD", + summary: "Later blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "blocked" + }, + song.collaboration!.assignments[0]!, + { + id: "a-early", + assignee: "Rhythm Section", + summary: "Earlier blocked mix.", + sectionId: song.sections[0]!.id, + roleId: "bass-guitar", + status: "blocked" + } + ]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("a-early"); + }); + + it("keeps the blocked job band-wide when role identities are duplicated", () => { + const song = withBlocked(); + const role = song.sections[0]!.roles.find((item) => item.id === "keys-right") ?? song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const resolved = resolveFirstBlockedAssignment(song); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("keeps the blocked job band-wide when the holding role is whitespace", () => { + const resolved = resolveFirstBlockedAssignment(withBlocked({ roleId: " " })); + expect(resolved?.assignment.id).toBe("assign-keys-blocked"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("keeps the blocked job band-wide when role or graph collections are missing", () => { + const song = withBlocked(); + delete (song.sections[0] as { roles?: unknown }).roles; + expect(resolveFirstBlockedAssignment(song)?.holdingRole).toBeNull(); + + const graphMissing = withBlocked(); + delete (graphMissing.sections[0] as { partGraph?: unknown }).partGraph; + expect(resolveFirstBlockedAssignment(graphMissing)?.holdingRole).toBeNull(); + }); + + it("does not invent a blocked job when the unique section has no owned time range", () => { + const song = withBlocked(); + delete (song.sections[0] as { timeRange?: unknown }).timeRange; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("does not invent a blocked job when sections are missing, sparse, or not an array", () => { + const missing = withBlocked(); + delete (missing as { sections?: unknown }).sections; + expect(resolveFirstBlockedAssignment(missing)).toBeNull(); + + const sparse = withBlocked(); + const sparseSections: typeof sparse.sections = []; + sparseSections[1] = sparse.sections[0]!; + sparse.sections = sparseSections; + expect(resolveFirstBlockedAssignment(sparse)).toBeNull(); + + const masquerade = withBlocked(); + masquerade.sections = { length: 1, 0: masquerade.sections[0]! } as unknown as typeof masquerade.sections; + expect(resolveFirstBlockedAssignment(masquerade)).toBeNull(); + }); + + it("does not invent a blocked job when a dense array reports a non-integer length", () => { + const song = withBlocked(); + const target = song.sections[0]!; + song.sections = new Proxy([target], { + get(record, property, receiver) { + if (property === "length") { + return 1.5; + } + return Reflect.get(record, property, receiver); + } + }) as typeof song.sections; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("skips non-object assignments while keeping a later owned blocked job", () => { + const song = withBlocked(); + const valid = song.collaboration!.assignments[0]!; + song.collaboration!.assignments = [42 as never, valid]; + expect(resolveFirstBlockedAssignment(song)?.assignment.id).toBe("assign-keys-blocked"); + }); + + it("does not invent a blocked job from duplicated assignment identities", () => { + const song = withBlocked({ assignmentId: "shared-id" }); + song.collaboration!.assignments = [ + song.collaboration!.assignments[0]!, + structuredClone(song.collaboration!.assignments[0]!), + structuredClone(song.collaboration!.assignments[0]!) + ]; + expect(resolveFirstBlockedAssignment(song)).toBeNull(); + }); + + it("contains throws from untrusted runtime property access", () => { + const song = withBlocked(); + const hostile = new Proxy(song, { + get(target, prop, receiver) { + if (prop === "collaboration") { + throw new Error("hostile collaboration"); + } + return Reflect.get(target, prop, receiver); + } + }); + expect(resolveFirstBlockedAssignment(hostile as typeof song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBlocked.ts b/apps/desktop/src/features/workspace/firstBlocked.ts new file mode 100644 index 000000000..531b8b217 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBlocked.ts @@ -0,0 +1,340 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalAssignment, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const ACTIONABLE_STATUS_RANK = { blocked: 0 } as const; +const MAX_ASSIGNMENT_SUMMARY_CHARACTERS = 180; + +/** Tonight's first blocked job: the earliest owned stuck assignment and the part that carries it. */ +export type FirstBlockedAssignment = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + assignment: RehearsalAssignment; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative blocked-job time as m:ss for rehearsal copy. */ +export function formatBlockedTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded owned assignment summary, or null when the field cannot be shown. */ +function ownedAssignmentHint(assignment: RehearsalAssignment): string | null { + if (!hasOwnData(assignment, "summary") || typeof assignment.summary !== "string") { + return null; + } + const hint = assignment.summary.trim(); + if (hint.length === 0) { + return null; + } + return truncateCodePoints(hint, MAX_ASSIGNMENT_SUMMARY_CHARACTERS); +} + +/** Return true when the assignment owns identity, assignee, blocked status, and a section pointer. */ +function isBlockedAssignment(assignment: RehearsalAssignment): boolean { + return ( + isRuntimeObject(assignment) && + hasOwnData(assignment, "id") && + typeof assignment.id === "string" && + assignment.id.trim().length > 0 && + hasOwnData(assignment, "assignee") && + typeof assignment.assignee === "string" && + assignment.assignee.trim().length > 0 && + hasOwnData(assignment, "sectionId") && + typeof assignment.sectionId === "string" && + assignment.sectionId.trim().length > 0 && + hasOwnData(assignment, "status") && + Object.prototype.hasOwnProperty.call(ACTIONABLE_STATUS_RANK, assignment.status) && + ownedAssignmentHint(assignment) !== null + ); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Map unique owned ids onto their records; duplicated ids are not authority. */ +function uniqueOwnedById( + items: T[], + readId: (item: T) => string | null +): Map { + const unique = new Map(); + const repeated = new Set(); + for (const item of items) { + if (!isRuntimeObject(item)) { + continue; + } + const id = readId(item); + if (id === null || repeated.has(id)) { + continue; + } + if (unique.has(id)) { + unique.delete(id); + repeated.add(id); + continue; + } + unique.set(id, item); + } + return unique; +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the corroborated holding part, or null when the blocked job is band-wide. */ +function resolveHoldingRole( + section: RehearsalSection, + assignment: RehearsalAssignment +): RehearsalRole | null { + if (!hasOwnData(assignment, "roleId") || typeof assignment.roleId !== "string") { + return null; + } + const roleId = assignment.roleId.trim(); + if (roleId.length === 0) { + return null; + } + return rankedActiveRoles(section).find((role) => role.id === roleId) ?? null; +} + +/** Return owned sections that can host a blocked assignment. */ +function uniqueReadySections(song: RehearsalSong): Map { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return new Map(); + } + + return uniqueOwnedById( + song.sections.filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ), + (section) => (hasOwnData(section, "id") && typeof section.id === "string" ? section.id : null) + ); +} + +/** Resolve a blocked assignment after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstBlockedAssignment(song: RehearsalSong): FirstBlockedAssignment | null { + if ( + !isRuntimeObject(song) || + !hasOwnData(song, "collaboration") || + !isRuntimeObject(song.collaboration) || + !hasOwnData(song.collaboration, "assignments") || + !isDenseRuntimeArray(song.collaboration.assignments) + ) { + return null; + } + + const sections = uniqueReadySections(song); + if (sections.size === 0) { + return null; + } + + const uniqueAssignments = uniqueOwnedById( + song.collaboration.assignments.filter((assignment) => isBlockedAssignment(assignment)), + (assignment) => + hasOwnData(assignment, "id") && typeof assignment.id === "string" ? assignment.id : null + ); + + const candidates = [...uniqueAssignments.values()] + .flatMap((assignment) => { + const section = sections.get(assignment.sectionId); + const hint = ownedAssignmentHint(assignment); + if (!section || hint === null) { + return []; + } + return [ + { + section, + holdingRole: resolveHoldingRole(section, assignment), + assignment, + atSeconds: section.timeRange.start, + hint + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.assignment.id, right.assignment.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first blocked job, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstBlockedAssignment(song: RehearsalSong): FirstBlockedAssignment | null { + try { + return resolveSafeFirstBlockedAssignment(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..0d0aa7078 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,21 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes Korean section form labels without treating inherited keys as labels", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-blocked next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstBlockedOpenAction")).toBe("{at} {section} 막힘 위치 열기"); + expect(t("firstBlockedBody")).toBe("{assignee}님이 {at} {section}에서 {role} 진행이 막혀 있습니다."); + expect(t("firstBlockedArmed")).toBe("{at} {section} 막힘을 먼저 풀어 주세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..28d9bfd95 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,14 +12,47 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } -/** Documented. */ +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + +/** Detect Korean from the runtime navigator, otherwise English. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { return "ko"; diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..4b6357d1a 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstBlockedLabel": "Tonight's first blocked job", + "firstBlockedOpenAction": "Open {section} blocker at {at}", + "firstBlockedOpenActionBand": "Open the blocked job at {at}", + "firstBlockedBody": "{assignee} is blocked on {role} in the {section} at {at}.", + "firstBlockedBodyBand": "{assignee} is blocked in the {section} at {at}.", + "firstBlockedArmed": "Unblock the {section} job at {at} before the next run.", + "firstBlockedArmedBand": "Unblock the job at {at} before the next run.", + "firstBlockedUnavailable": "No blocked job yet. Stay on tonight's map until a part is stuck.", "workspaceFirstRangeTitle": "Tonight's first range", "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}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5fd7ff71c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstBlockedLabel": "오늘 첫 막힘", + "firstBlockedOpenAction": "{at} {section} 막힘 위치 열기", + "firstBlockedOpenActionBand": "{at} 막힘 위치 열기", + "firstBlockedBody": "{assignee}님이 {at} {section}에서 {role} 진행이 막혀 있습니다.", + "firstBlockedBodyBand": "{assignee}님이 {at} {section}에서 막혀 있습니다.", + "firstBlockedArmed": "{at} {section} 막힘을 먼저 풀어 주세요.", + "firstBlockedArmedBand": "{at} 막힘을 먼저 풀어 주세요.", + "firstBlockedUnavailable": "아직 막힌 일이 없습니다. 막힐 때까지 오늘 지도에 머무르세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..a8651d5b5 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstBlocked.ts", + "src/features/workspace/FirstBlockedCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c3edaae4c 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -94,6 +94,7 @@ These Figma patterns are valid visual guidance but are not yet extracted as stan | Status Pill | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when assignment/comment/approval status UI is reused. | | Song Structure Timeline | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when timeline editing or playback controls are added. | | Export Action Group | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when export controls are reused outside the workspace header. | +| First Blocked Callout | `apps/desktop/src/features/workspace/FirstBlockedCallout.tsx` | Feature-local workspace next-action pattern until a traceable Figma node exists; extract or promote to Canonical Components when the pattern is reused and design authority is published. | ## PR Review Rules diff --git a/docs/doctoring/reduced-motion-first-blocked-navigation.md b/docs/doctoring/reduced-motion-first-blocked-navigation.md new file mode 100644 index 000000000..10728cc06 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-blocked-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-blocked navigation + +Workspace map navigation for tonight's first blocked assignment follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstBlockedCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Blocked-job resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, collaboration, assignment identity/assignee/summary/status/sectionId/roleId, section, time-range, role, and part-graph tokens are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: blocked resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned assignment summary is interpolated once as copy and is never rescanned as template syntax. Todo, in-progress, ready assignments, comments, and approvals cannot invent a blocked job. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the assignment summary is bounded to 180 Unicode code points. +- Test points: inherited song/collaboration/assignment/section/timing metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.