From 36034394dbd98057cfe22058b04de232d751988c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 18 Aug 2026 14:14:05 +0000 Subject: [PATCH 01/31] feat(workspace): guide tonight's first pickup on map and player Name the incoming part that catches the first corroborated handoff, or an explicit pickup section, so the room can start the next action. Workspace Open always scrolls the renderer-owned map section. Player Hear appears only when the owning playback surface supplies a callback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 44 ++ apps/desktop/src/features/player/index.tsx | 26 +- ...FirstPickupCallout.reduced-motion.test.tsx | 37 ++ .../workspace/FirstPickupCallout.test.tsx | 155 +++++++ .../features/workspace/FirstPickupCallout.tsx | 145 +++++++ .../src/features/workspace/Workspace.test.tsx | 24 ++ .../src/features/workspace/Workspace.tsx | 7 +- .../workspace/firstPickupHandoff.test.ts | 385 ++++++++++++++++++ .../features/workspace/firstPickupHandoff.ts | 186 +++++++++ apps/desktop/src/i18n/index.test.ts | 9 + apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-pickup-navigation.md | 14 + 18 files changed, 1053 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPickupCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstPickupCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.test.ts create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.ts create mode 100644 docs/doctoring/reduced-motion-first-pickup-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..875ebaa18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +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. +- Name tonight's first pickup with the incoming part, outgoing partner when the graph corroborates it, section, and time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..7dee78f5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Workspace and player copy for tonight's first pickup must name the incoming part, outgoing partner when corroborated, section, and time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..532f42296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first pickup on the workspace and player so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 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 82c2c704a..4627e7e4d 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). `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). Workspace and player name tonight's first pickup so the incoming part can catch the handoff; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. `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/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..9c4cc67ba --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first pickup from this player.") + ).toBeTruthy(); + }); + + it("keeps the pickup hear action unavailable without a player playback callback", () => { + render(); + + expect( + screen.queryByRole("button", { + name: "Hear Lead Vocal pick up from Bass Guitar at 0:30" + }) + ).toBeNull(); + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + }); + + it("delegates the pickup hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Lead Vocal pick up from Bass Guitar at 0:30" + }) + ); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(30); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..a47c263b3 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,14 +1,22 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstPickupCallout } from "../workspace/FirstPickupCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Player surface that names tonight's first pickup and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

No song loaded. Start an analysis to use the player.

+

{t("firstPickupNeedsSong")}

); } @@ -16,12 +24,18 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul return (

{title}

+
@@ -31,9 +45,9 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
- {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal("matchMedia", matchMedia); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx new file mode 100644 index 000000000..e5a6993c7 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstPickupCallout } from "./FirstPickupCallout"; + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstPickupCallout", () => { + it("names the first pickup as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + + grid.remove(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearPickup = vi.fn(); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect(onHearPickup).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first pickup changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + + const replacementSong = createDemoRehearsalSong(); + replacementSong.id = "demo-song-replacement"; + replacementSong.sections[0]!.roles[2]!.name = "Lead Harmony"; + rerender(); + expect(screen.getByText("Lead Harmony picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + }); + + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[2]!.name = "{section}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Open {section} pickup from Bass Guitar at 0:30" + }) + ).toBeTruthy(); + }); + + it("tells the room to stay on the map when no pickup exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect( + screen.getByText("No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.") + ).toBeTruthy(); + }); + + it("names a labeled pickup section without inventing a partner", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections = [ + { + ...verse, + id: "pickup-1", + label: "pickup", + timeRange: { start: 8, end: 10 }, + roles: [ + { + ...verse.roles[2]!, + id: "lead-vocal-pickup", + name: "Lead Vocal" + } + ], + partGraph: [ + { + role_id: "lead-vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + } + ]; + + render(); + expect(screen.getByText("Lead Vocal picks up the pickup at 0:08.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Lead Vocal pickup at 0:08" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx new file mode 100644 index 000000000..3107bbf0b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatPickupTime, resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Props for the first-pickup rehearsal callout. */ +export interface FirstPickupCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearPickup?: (atSeconds: number) => void; +} + +type PickupCopyValues = Readonly>; + +type HeardPickup = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + fromRoleId: string | null; + toRoleId: string; + atSeconds: number; +}>; + +/** Interpolate pickup placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatPickupCopy(template: string, values: PickupCopyValues): string { + return template.replace(/\{(from|to|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof PickupCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredPickupScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first pickup and offer only an action that the current surface can execute. */ +export function FirstPickupCallout({ + song, + actionMode = "workspace-scroll", + onHearPickup +}: FirstPickupCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const pickup = resolveFirstPickupHandoff(song); + const pickupSectionIndex = pickup ? song.sections.indexOf(pickup.section) : -1; + const [heardPickup, setHeardPickup] = useState(null); + + useEffect(() => { + setHeardPickup(null); + }, [ + song.id, + pickupSectionIndex, + pickup?.section.id, + pickup?.fromRole?.id, + pickup?.toRole.id, + pickup?.atSeconds + ]); + + if (!pickup) { + return ( + + ); + } + + const heard = + heardPickup?.songId === song.id && + heardPickup.sectionId === pickup.section.id && + heardPickup.sectionIndex === pickupSectionIndex && + heardPickup.fromRoleId === (pickup.fromRole?.id ?? null) && + heardPickup.toRoleId === pickup.toRole.id && + heardPickup.atSeconds === pickup.atSeconds; + const at = formatPickupTime(pickup.atSeconds); + const copyValues: PickupCopyValues = { + from: pickup.fromRole?.name ?? "", + to: pickup.toRole.name, + section: pickup.section.label, + at + }; + const hasFrom = pickup.fromRole !== null; + const actionLabel = formatPickupCopy( + t( + actionMode === "callback-only" + ? hasFrom + ? "firstPickupAction" + : "firstPickupActionSolo" + : hasFrom + ? "firstPickupOpenAction" + : "firstPickupOpenActionSolo" + ), + copyValues + ); + const body = formatPickupCopy(t(hasFrom ? "firstPickupBody" : "firstPickupBodySolo"), copyValues); + const armed = formatPickupCopy(t(hasFrom ? "firstPickupArmed" : "firstPickupArmedSolo"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || onHearPickup !== undefined; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..b2318066f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,28 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("keeps analysis section ids out of song-structure DOM authority", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + + render(); + + const firstRenderedSection = screen.getByTestId("song-structure-grid").children.item(0); + expect(firstRenderedSection).toBeTruthy(); + expect(firstRenderedSection?.hasAttribute("id")).toBe(false); + }); + + it("names tonight's first pickup as workspace navigation", () => { + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect( + screen.getByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..270e7a75b 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 { FirstPickupCallout } from "./FirstPickupCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,8 @@ 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, sectionIndex) => ( +

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

@@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts new file mode 100644 index 000000000..454b9ba30 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatPickupTime, resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff", () => { + it("picks the incoming catch of the earliest explicit handoff", () => { + const song = createDemoRehearsalSong(); + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.section.id).toBe("verse-1"); + expect(pickup?.fromRole?.id).toBe("bass-guitar"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + expect(pickup?.atSeconds).toBe(30); + expect(formatPickupTime(pickup?.atSeconds ?? -1)).toBe("0:30"); + expect(formatPickupTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no part is ready to pick up", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("prefers an explicit pickup section over a later incoming handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const pickupSection = structuredClone(verse); + pickupSection.id = "pickup-1"; + pickupSection.label = "pickup"; + pickupSection.timeRange = { start: 8, end: 10 }; + pickupSection.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal-pickup", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + pickupSection.partGraph = [ + { + role_id: "lead-vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, pickupSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("pickup-1"); + expect(pickup?.toRole.id).toBe("lead-vocal-pickup"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); + + it("keeps a labeled pickup's incoming partner when the graph corroborates it", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const pickupSection = structuredClone(verse); + pickupSection.id = "pickup-1"; + pickupSection.label = "pickup"; + pickupSection.timeRange = { start: 8, end: 10 }; + pickupSection.roles = [ + { + ...verse.roles[0]!, + id: "bass-pickup", + name: "Bass Guitar", + rehearsalPriority: "medium" + }, + { + ...verse.roles[2]!, + id: "vocal-pickup", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + pickupSection.partGraph = [ + { + role_id: "bass-pickup", + is_active: true, + handoff_to: ["vocal-pickup"], + handoff_from: [] + }, + { + role_id: "vocal-pickup", + is_active: true, + handoff_to: [], + handoff_from: ["bass-pickup"] + } + ]; + song.sections = [pickupSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.toRole.id).toBe("vocal-pickup"); + expect(pickup?.fromRole?.id).toBe("bass-pickup"); + expect(pickup?.atSeconds).toBe(8); + }); + + it("skips an earlier section that only has inactive or empty handoff lists", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "chorus-1"; + later.label = "chorus"; + later.timeRange = { start: 40, end: 70 }; + later.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar-chorus", + name: "Bass Guitar" + }, + { + ...verse.roles[2]!, + id: "lead-vocal-chorus", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "bass-guitar-chorus", + is_active: true, + handoff_to: ["lead-vocal-chorus"], + handoff_from: [] + }, + { + role_id: "lead-vocal-chorus", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar-chorus"] + } + ]; + song.sections = [ + { + ...verse, + partGraph: [ + { + role_id: "bass-guitar", + is_active: false, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "keys-right", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] + }, + later + ]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("chorus-1"); + expect(pickup?.fromRole?.id).toBe("bass-guitar-chorus"); + expect(pickup?.toRole.id).toBe("lead-vocal-chorus"); + expect(pickup?.atSeconds).toBe(70); + }); + + it("does not resolve a section pickup against a role that exists only in another section", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + } + ]; + + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 70 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "chorus-bass", + rehearsalPriority: "medium" + }, + { + ...chorus.roles[2]!, + id: "future-lead", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "chorus-bass", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + }, + { + role_id: "future-lead", + is_active: false, + handoff_to: [], + handoff_from: ["chorus-bass"] + } + ]; + song.sections = [verse, chorus]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("chorus-1"); + expect(pickup?.fromRole?.id).toBe("chorus-bass"); + expect(pickup?.toRole.id).toBe("future-lead"); + }); + + it("rejects an outgoing handoff that the target node does not corroborate", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, handoff_from: [] } : node + ); + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("accepts a reciprocal receiver that is inactive until the next section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ); + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.fromRole?.id).toBe("bass-guitar"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + }); + + it("prefers the higher-priority incoming part when two pickups share a section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections[0] = { + ...verse, + roles: verse.roles.map((role) => + role.id === "keys-right" ? { ...role, rehearsalPriority: "low" as const } : role + ), + partGraph: [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["keys-right", "lead-vocal"], + handoff_from: [] + }, + { + role_id: "keys-right", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar"] + }, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar"] + } + ] + }; + + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("lead-vocal"); + }); + + it("prefers the earlier pickup when a later section also hands off", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "bridge-1"; + later.label = "bridge"; + later.timeRange = { start: 80, end: 100 }; + later.roles = [ + { + ...verse.roles[1]!, + id: "keys-bridge", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high" + }, + { + ...verse.roles[2]!, + id: "vocal-bridge", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "keys-bridge", + is_active: true, + handoff_to: ["vocal-bridge"], + handoff_from: [] + }, + { + role_id: "vocal-bridge", + is_active: false, + handoff_to: [], + handoff_from: ["keys-bridge"] + } + ]; + song.sections = [verse, later]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("verse-1"); + expect(pickup?.toRole.id).toBe("lead-vocal"); + expect(pickup?.atSeconds).toBe(30); + }); + + it("skips non-finite ends, unknown priorities, missing roles, and self-handoffs", () => { + const song = createDemoRehearsalSong(); + const invalidEnd = structuredClone(song.sections[0]!); + invalidEnd.id = "invalid-end"; + invalidEnd.timeRange = { start: 0, end: Number.NaN }; + invalidEnd.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + } + ]; + + const validSection = structuredClone(song.sections[0]!); + validSection.id = "valid-chorus"; + validSection.label = "chorus"; + validSection.timeRange = { start: 20, end: 50 }; + const invalidPriorityRole = { + ...validSection.roles[0]!, + id: "invalid-priority" + }; + (invalidPriorityRole as unknown as { rehearsalPriority: string }).rehearsalPriority = "urgent"; + validSection.roles = [ + invalidPriorityRole, + { + ...validSection.roles[2]!, + id: "safe-lead", + rehearsalPriority: "high" + }, + { + ...validSection.roles[0]!, + id: "safe-bass", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + validSection.partGraph = [ + { + role_id: "missing-role", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "invalid-priority", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "safe-bass", + is_active: true, + handoff_to: ["safe-bass", " ", "nobody"], + handoff_from: [] + }, + { + role_id: "safe-bass", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "safe-lead", + is_active: false, + handoff_to: [], + handoff_from: ["safe-bass"] + } + ]; + + song.sections = [invalidEnd, validSection]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.section.id).toBe("valid-chorus"); + expect(pickup?.fromRole?.id).toBe("safe-bass"); + expect(pickup?.toRole.id).toBe("safe-lead"); + expect(pickup?.atSeconds).toBe(50); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts new file mode 100644 index 000000000..8df06894c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -0,0 +1,186 @@ +import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first pickup: the incoming part that catches a handoff or a labeled pickup section. */ +export type FirstPickupHandoff = { + section: RehearsalSection; + fromRole: RehearsalRole | null; + toRole: RehearsalRole; + atSeconds: number; +}; + +/** Format a non-negative pickup time as m:ss for rehearsal copy. */ +export function formatPickupTime(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}`; +} + +/** Return true when the role carries a ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); +} + +/** Require the receiving graph node to corroborate the outgoing edge. */ +function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { + return section.partGraph.some( + (candidate) => + candidate.role_id === toRoleId && + Array.isArray(candidate.handoff_from) && + candidate.handoff_from.includes(fromRoleId) + ); +} + +/** Return ranked roles in a section, optionally requiring an active graph node. */ +function rankedRolesInSection(section: RehearsalSection, requireActive: boolean): RehearsalRole[] { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const activeIds = new Set( + section.partGraph.filter((node) => node.is_active).map((node) => node.role_id) + ); + + return section.roles.filter((role) => { + if (!hasRankedPriority(role) || rolesInSection.get(role.id) !== role) { + return false; + } + return !requireActive || activeIds.has(role.id); + }); +} + +/** Prefer the highest-priority ranked role, then a stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return left.id.localeCompare(right.id); + })[0] ?? null; +} + +/** Resolve an incoming corroborating partner for a pickup role, if one exists. */ +function resolveIncomingPartner(section: RehearsalSection, toRole: RehearsalRole): RehearsalRole | null { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const incomingIds = section.partGraph + .filter((node) => node.role_id === toRole.id && Array.isArray(node.handoff_from)) + .flatMap((node) => node.handoff_from); + + const partners = incomingIds + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) + .map((roleId) => rolesInSection.get(roleId) ?? null) + .filter( + (role): role is RehearsalRole => + role !== null && + hasRankedPriority(role) && + role.id !== toRole.id && + hasReciprocalHandoff(section, role.id, toRole.id) + ); + + return pickHighestPriorityRole(partners); +} + +/** Return whether a section has a bounded, non-negative rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + return ( + Number.isFinite(section.timeRange.start) && + section.timeRange.start >= 0 && + Number.isFinite(section.timeRange.end) && + section.timeRange.end >= section.timeRange.start + ); +} + +/** Prefer an explicit pickup form label before falling back to an incoming handoff. */ +function resolveLabeledPickupSection(song: RehearsalSong): FirstPickupHandoff | null { + const pickupSections = song.sections + .filter((section) => section.label === "pickup" && hasBoundedTimeRange(section)) + .sort((left, right) => left.timeRange.start - right.timeRange.start); + + for (const section of pickupSections) { + const toRole = pickHighestPriorityRole(rankedRolesInSection(section, false)); + if (!toRole) { + continue; + } + return { + section, + fromRole: resolveIncomingPartner(section, toRole), + toRole, + atSeconds: section.timeRange.start + }; + } + + return null; +} + +/** Return the first validated incoming pickup, or null when no safe candidate remains. */ +export function resolveFirstPickupHandoff(song: RehearsalSong): FirstPickupHandoff | null { + const labeled = resolveLabeledPickupSection(song); + if (labeled) { + return labeled; + } + + const sections = song.sections.filter(hasBoundedTimeRange).sort((left, right) => { + if (left.timeRange.end !== right.timeRange.end) { + return left.timeRange.end - right.timeRange.end; + } + return left.timeRange.start - right.timeRange.start; + }); + + const candidates: FirstPickupHandoff[] = []; + + for (const section of sections) { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + + for (const node of section.partGraph) { + if (!node.is_active || !Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { + continue; + } + + const fromRole = rolesInSection.get(node.role_id); + if (!fromRole || !hasRankedPriority(fromRole)) { + continue; + } + + const targets = node.handoff_to + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) + .map((roleId) => rolesInSection.get(roleId) ?? null) + .filter( + (role): role is RehearsalRole => + role !== null && + hasRankedPriority(role) && + role.id !== fromRole.id && + hasReciprocalHandoff(section, fromRole.id, role.id) + ); + + const toRole = pickHighestPriorityRole(targets); + if (!toRole) { + continue; + } + + candidates.push({ + section, + fromRole, + toRole, + atSeconds: section.timeRange.end + }); + } + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return PRIORITY_RANK[left.toRole.rehearsalPriority] - PRIORITY_RANK[right.toRole.rehearsalPriority]; + }); + + return candidates[0] ?? null; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..b1fffa1fa 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -74,5 +74,14 @@ describe("i18n", () => { koDictionary.appSubtitle = originalSubtitle; } }); + + it("keeps first-pickup keys in both baseline locales", () => { + const tEn = createTranslator("en"); + const tKo = createTranslator("ko"); + expect(tEn("firstPickupLabel")).toBe("Tonight's first pickup"); + expect(tKo("firstPickupLabel")).toBe("오늘 첫 픽업"); + expect(tEn("firstPickupOpenAction")).toContain("{to}"); + expect(tKo("firstPickupOpenAction")).toContain("{to}"); + }); }); }); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..d0fa0d413 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstPickupLabel": "Tonight's first pickup", + "firstPickupAction": "Hear {to} pick up from {from} at {at}", + "firstPickupActionSolo": "Hear {to} pick up at {at}", + "firstPickupOpenAction": "Open {to} pickup from {from} at {at}", + "firstPickupOpenActionSolo": "Open {to} pickup at {at}", + "firstPickupBody": "{to} picks up from {from} at the end of the {section} ({at}).", + "firstPickupBodySolo": "{to} picks up the {section} at {at}.", + "firstPickupArmed": "Start {to}'s pickup from {from} before the next downbeat ({at}).", + "firstPickupArmedSolo": "Start {to}'s pickup into the {section} ({at}).", + "firstPickupUnavailable": "No pickup yet. Stay on tonight's map until a part is ready to catch the handoff.", + "firstPickupNeedsSong": "Analyze tonight's song first, then hear the first pickup from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..25956c67c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstPickupLabel": "오늘 첫 픽업", + "firstPickupAction": "{at}에 {from}에게서 받는 {to} 듣기", + "firstPickupActionSolo": "{at}에 받는 {to} 듣기", + "firstPickupOpenAction": "{at} {from}→{to} 픽업 위치 열기", + "firstPickupOpenActionSolo": "{at} {to} 픽업 위치 열기", + "firstPickupBody": "{to}이 {at} {section} 끝에서 {from}의 넘김을 받습니다.", + "firstPickupBodySolo": "{to}이 {at} {section}에서 픽업합니다.", + "firstPickupArmed": "다음 다운비트 전에 {from}에게서 받는 {to} 픽업을 시작하세요 ({at}).", + "firstPickupArmedSolo": "{section}으로 들어가는 {to} 픽업을 시작하세요 ({at}).", + "firstPickupUnavailable": "아직 픽업이 없습니다. 파트가 넘김을 받을 준비가 될 때까지 오늘 지도에 머무르세요.", + "firstPickupNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 픽업을 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..ab738d16f 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Pickup Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstPickupCallout.tsx` | Name the incoming part, outgoing partner when the graph corroborates it, section, and time. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearPickup` exists and delegates the exact pickup second to that callback. Keep the unavailable state guidance-only. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-pickup-navigation.md b/docs/doctoring/reduced-motion-first-pickup-navigation.md new file mode 100644 index 000000000..c8078b91d --- /dev/null +++ b/docs/doctoring/reduced-motion-first-pickup-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-pickup navigation + +Workspace map navigation for tonight's first pickup follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstPickupCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Pickup resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. From f68e77f8b1b595be63f0cf56e57f8090d44394e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:15:32 -0700 Subject: [PATCH 02/31] test(workspace): reject inactive pickup handoff source --- ...kupHandoff.inactive-labeled-source.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts new file mode 100644 index 000000000..d0540cb15 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-source.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff labeled pickup authority", () => { + it("does not name an inactive outgoing role as the source of a labeled pickup", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[0]!, + id: "resting-bass", + name: "Bass Guitar", + rehearsalPriority: "medium" + }, + { + ...section.roles[2]!, + id: "pickup-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-bass", + is_active: false, + handoff_to: ["pickup-vocal"], + handoff_from: [] + }, + { + role_id: "pickup-vocal", + is_active: true, + handoff_to: [], + handoff_from: ["resting-bass"] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.toRole.id).toBe("pickup-vocal"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); +}); From 2842d00292b6bd8262a0b651a93e7ec9eb5a98f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:17:43 -0700 Subject: [PATCH 03/31] fix(workspace): reject inactive labeled pickup source --- .../src/features/workspace/firstPickupHandoff.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts index 8df06894c..08a87e1f8 100644 --- a/apps/desktop/src/features/workspace/firstPickupHandoff.ts +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -25,6 +25,21 @@ function hasRankedPriority(role: RehearsalRole): boolean { return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); } +/** Require an active outgoing graph node to authorize a handoff source. */ +function hasActiveOutgoingHandoff( + section: RehearsalSection, + fromRoleId: string, + toRoleId: string +): boolean { + return section.partGraph.some( + (candidate) => + candidate.role_id === fromRoleId && + candidate.is_active && + Array.isArray(candidate.handoff_to) && + candidate.handoff_to.includes(toRoleId) + ); +} + /** Require the receiving graph node to corroborate the outgoing edge. */ function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { return section.partGraph.some( @@ -79,6 +94,7 @@ function resolveIncomingPartner(section: RehearsalSection, toRole: RehearsalRole role !== null && hasRankedPriority(role) && role.id !== toRole.id && + hasActiveOutgoingHandoff(section, role.id, toRole.id) && hasReciprocalHandoff(section, role.id, toRole.id) ); From 96d8dc5464fa06727b33db77117451c31a848c42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:04:17 -0700 Subject: [PATCH 04/31] test: reject inactive labeled pickup receiver --- ...pHandoff.inactive-labeled-receiver.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts new file mode 100644 index 000000000..4e47b7405 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.inactive-labeled-receiver.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff labeled pickup receiver authority", () => { + it("does not announce an inactive higher-priority role as the labeled pickup", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + + expect(pickup?.toRole.id).toBe("active-bass"); + expect(pickup?.fromRole).toBeNull(); + expect(pickup?.atSeconds).toBe(8); + }); +}); From d462dc4afd8add18610438894f42f1d9142d87bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:05:44 -0700 Subject: [PATCH 05/31] fix: require active role for labeled pickup --- apps/desktop/src/features/workspace/firstPickupHandoff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts index 08a87e1f8..70fa1c4d5 100644 --- a/apps/desktop/src/features/workspace/firstPickupHandoff.ts +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -118,7 +118,7 @@ function resolveLabeledPickupSection(song: RehearsalSong): FirstPickupHandoff | .sort((left, right) => left.timeRange.start - right.timeRange.start); for (const section of pickupSections) { - const toRole = pickHighestPriorityRole(rankedRolesInSection(section, false)); + const toRole = pickHighestPriorityRole(rankedRolesInSection(section, true)); if (!toRole) { continue; } From e01321e11376c10d842c9a5ba7f9e53882f50375 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:10:36 -0700 Subject: [PATCH 06/31] test: reject non-boolean pickup activity flags --- .../firstPickupHandoff.activity-type.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts new file mode 100644 index 000000000..f8bb22f01 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.activity-type.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstPickupHandoff activity-type authority", () => { + it("does not treat a string false flag as an active labeled pickup receiver", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("active-bass"); + }); + + it("does not treat a string false flag as an active labeled pickup source", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + section.roles = [ + { + ...section.roles[0]!, + id: "resting-bass", + name: "Resting Bass", + rehearsalPriority: "medium" + }, + { + ...section.roles[2]!, + id: "pickup-vocal", + name: "Pickup Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-bass", + is_active: runtimeStringFalse, + handoff_to: ["pickup-vocal"], + handoff_from: [] + }, + { + role_id: "pickup-vocal", + is_active: true, + handoff_to: [], + handoff_from: ["resting-bass"] + } + ]; + song.sections = [section]; + + const pickup = resolveFirstPickupHandoff(song); + expect(pickup?.toRole.id).toBe("pickup-vocal"); + expect(pickup?.fromRole).toBeNull(); + }); + + it("does not treat a string false flag as an active generic handoff source", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.partGraph = section.partGraph.map((node) => + node.role_id === "bass-guitar" ? { ...node, is_active: runtimeStringFalse } : node + ); + + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); From cbcdc2d8b2b4f60531b9115fd96d88699ec2be8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:12:39 -0700 Subject: [PATCH 07/31] fix: require boolean pickup activity evidence --- .../src/features/workspace/firstPickupHandoff.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts index 70fa1c4d5..bcc0086a6 100644 --- a/apps/desktop/src/features/workspace/firstPickupHandoff.ts +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -34,7 +34,7 @@ function hasActiveOutgoingHandoff( return section.partGraph.some( (candidate) => candidate.role_id === fromRoleId && - candidate.is_active && + candidate.is_active === true && Array.isArray(candidate.handoff_to) && candidate.handoff_to.includes(toRoleId) ); @@ -54,7 +54,7 @@ function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toR function rankedRolesInSection(section: RehearsalSection, requireActive: boolean): RehearsalRole[] { const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); const activeIds = new Set( - section.partGraph.filter((node) => node.is_active).map((node) => node.role_id) + section.partGraph.filter((node) => node.is_active === true).map((node) => node.role_id) ); return section.roles.filter((role) => { @@ -153,7 +153,11 @@ export function resolveFirstPickupHandoff(song: RehearsalSong): FirstPickupHando const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { - if (!node.is_active || !Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { + if ( + node.is_active !== true || + !Array.isArray(node.handoff_to) || + node.handoff_to.length === 0 + ) { continue; } From 34e77dd829aaf5bb27ca5c6738090851ac1e1ce4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:30:29 -0700 Subject: [PATCH 08/31] test: reject malformed pickup role ids --- ...firstPickupHandoff.invalid-role-id.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..a7f6634d6 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-role-id.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +describe("resolveFirstPickupHandoff runtime role identity", () => { + it("ignores an active pickup role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "pickup-1"; + section.label = "pickup"; + section.timeRange = { start: 8, end: 10 }; + + const safeRole = { + ...section.roles[2]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "high" as const + }; + const malformedRole = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)?.toRole.id).toBe("safe-vocal"); + }); +}); From b01951dac1573dd269dd08b812d2b98cb2ba7b5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:31:23 -0700 Subject: [PATCH 09/31] fix: reject malformed pickup role ids --- apps/desktop/src/features/workspace/firstPickupHandoff.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts index bcc0086a6..b2798db56 100644 --- a/apps/desktop/src/features/workspace/firstPickupHandoff.ts +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -20,9 +20,13 @@ export function formatPickupTime(totalSeconds: number): string { return `${minutes}:${seconds}`; } -/** Return true when the role carries a ranked rehearsal priority. */ +/** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { - return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); } /** Require an active outgoing graph node to authorize a handoff source. */ From 9c8320c7ce9caf893289ee1e8fdd1a48e30c55a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:40:30 -0700 Subject: [PATCH 10/31] test(workspace): reject malformed pickup section collections --- ...Handoff.invalid-section-collection.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts new file mode 100644 index 000000000..14e0bf450 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.invalid-section-collection.test.ts @@ -0,0 +1,38 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstPickupHandoff } from "./firstPickupHandoff"; + +/** Cast runtime input through the static song contract to exercise the resolver trust boundary. */ +function runtimeSong(value: unknown): RehearsalSong { + return value as RehearsalSong; +} + +/** Replace the section collection with untrusted runtime evidence. */ +function songWithRuntimeSections(sections: unknown): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = sections as RehearsalSong["sections"]; + return song; +} + +describe("resolveFirstPickupHandoff runtime section collection", () => { + it("fails closed when the runtime song root is null", () => { + const song = runtimeSong(null); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("fails closed when the runtime section collection is not an array", () => { + const song = songWithRuntimeSections(null); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements instead of dereferencing them", () => { + const song = songWithRuntimeSections([null, 42]); + + expect(() => resolveFirstPickupHandoff(song)).not.toThrow(); + expect(resolveFirstPickupHandoff(song)).toBeNull(); + }); +}); From f0aa2571a8c2b2a24cef003cf4cddeab8f3cb4fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:41:20 -0700 Subject: [PATCH 11/31] fix(workspace): reject malformed pickup section collections --- .../features/workspace/firstPickupHandoff.ts | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstPickupHandoff.ts b/apps/desktop/src/features/workspace/firstPickupHandoff.ts index b2798db56..a63283dc3 100644 --- a/apps/desktop/src/features/workspace/firstPickupHandoff.ts +++ b/apps/desktop/src/features/workspace/firstPickupHandoff.ts @@ -20,6 +20,28 @@ export function formatPickupTime(totalSeconds: number): string { return `${minutes}:${seconds}`; } +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return whether every numeric index is present 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 (!(index in value)) { + return false; + } + } + return true; +} + /** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { return ( @@ -74,13 +96,15 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { if (roles.length === 0) { return null; } - return [...roles].sort((left, right) => { - const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; - if (rankDelta !== 0) { - return rankDelta; - } - return left.id.localeCompare(right.id); - })[0] ?? null; + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return left.id.localeCompare(right.id); + })[0] ?? null + ); } /** Resolve an incoming corroborating partner for a pickup role, if one exists. */ @@ -107,6 +131,9 @@ function resolveIncomingPartner(section: RehearsalSection, toRole: RehearsalRole /** Return whether a section has a bounded, non-negative rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!isRuntimeObject(section.timeRange)) { + return false; + } return ( Number.isFinite(section.timeRange.start) && section.timeRange.start >= 0 && @@ -118,7 +145,10 @@ function hasBoundedTimeRange(section: RehearsalSection): boolean { /** Prefer an explicit pickup form label before falling back to an incoming handoff. */ function resolveLabeledPickupSection(song: RehearsalSong): FirstPickupHandoff | null { const pickupSections = song.sections - .filter((section) => section.label === "pickup" && hasBoundedTimeRange(section)) + .filter( + (section) => + isRuntimeObject(section) && section.label === "pickup" && hasBoundedTimeRange(section) + ) .sort((left, right) => left.timeRange.start - right.timeRange.start); for (const section of pickupSections) { @@ -139,17 +169,23 @@ function resolveLabeledPickupSection(song: RehearsalSong): FirstPickupHandoff | /** Return the first validated incoming pickup, or null when no safe candidate remains. */ export function resolveFirstPickupHandoff(song: RehearsalSong): FirstPickupHandoff | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + const labeled = resolveLabeledPickupSection(song); if (labeled) { return labeled; } - const sections = song.sections.filter(hasBoundedTimeRange).sort((left, right) => { - if (left.timeRange.end !== right.timeRange.end) { - return left.timeRange.end - right.timeRange.end; - } - return left.timeRange.start - right.timeRange.start; - }); + const sections = song.sections + .filter((section) => isRuntimeObject(section) && hasBoundedTimeRange(section)) + .sort((left, right) => { + if (left.timeRange.end !== right.timeRange.end) { + return left.timeRange.end - right.timeRange.end; + } + return left.timeRange.start - right.timeRange.start; + }); const candidates: FirstPickupHandoff[] = []; From 509cc917888f523120efbbfff5e4860f8e93729e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:41:57 -0700 Subject: [PATCH 12/31] test(workspace): require pickup action execution before arming --- ...FirstPickupCallout.missing-target.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx new file mode 100644 index 000000000..9fc302c3d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.missing-target.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstPickupCallout } from "./FirstPickupCallout"; + +describe("FirstPickupCallout renderer-owned action completion", () => { + it("does not arm the pickup when the renderer-owned section target is missing", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Lead Vocal pickup from Bass Guitar at 0:30" + }) + ); + + expect(screen.getByText("Lead Vocal picks up from Bass Guitar at the end of the verse (0:30).")).toBeTruthy(); + expect( + screen.queryByText(/Start Lead Vocal's pickup from Bass Guitar before the next downbeat \(0:30\)/) + ).toBeNull(); + }); +}); From 7f8e417efec720b843e9b926b5fd48dffc9032af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:43:40 -0700 Subject: [PATCH 13/31] fix(workspace): arm pickup only after action executes --- .../features/workspace/FirstPickupCallout.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstPickupCallout.tsx b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx index 3107bbf0b..71e3fad6d 100644 --- a/apps/desktop/src/features/workspace/FirstPickupCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstPickupCallout.tsx @@ -102,7 +102,18 @@ export function FirstPickupCallout({ ); const body = formatPickupCopy(t(hasFrom ? "firstPickupBody" : "firstPickupBodySolo"), copyValues); const armed = formatPickupCopy(t(hasFrom ? "firstPickupArmed" : "firstPickupArmedSolo"), copyValues); - const canExecuteAction = actionMode === "workspace-scroll" || onHearPickup !== undefined; + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearPickup === "function"; + /** Record completion only after the owning surface has executed the selected pickup action. */ + const markPickupActionComplete = () => { + setHeardPickup({ + songId: song.id, + sectionId: pickup.section.id, + sectionIndex: pickupSectionIndex, + fromRoleId: pickup.fromRole?.id ?? null, + toRoleId: pickup.toRole.id, + atSeconds: pickup.atSeconds + }); + }; return (