From f414579687131d3ac52f7fbe6ff4abde8840dd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:40:24 +0200 Subject: [PATCH 1/2] Allow song-specific mix names - Persist mix name overrides per song - Show overridden names in song editing and live views --- .../routes/shows/$showId/setlist/$songId.tsx | 106 +++++++++++++++--- docs/migrations.md | 2 +- packages/backend/src/database/Database.ts | 2 + .../src/database/migrations/0001_initial.ts | 11 ++ packages/backend/src/rpc/Rpc.ts | 12 +- packages/backend/src/shows/ShowRepository.ts | 29 ++++- .../backend/src/songs/SongService.test.ts | 46 ++++++++ packages/backend/src/songs/SongService.ts | 23 +++- packages/contracts/src/rpc.ts | 2 + packages/contracts/src/song.test.ts | 7 ++ packages/contracts/src/song.ts | 15 +++ .../frontend/src/live/LiveSongView.test.ts | 6 + packages/frontend/src/live/LiveSongView.ts | 4 +- packages/frontend/src/songs/SongAtoms.ts | 1 + 14 files changed, 246 insertions(+), 20 deletions(-) diff --git a/apps/web/src/routes/shows/$showId/setlist/$songId.tsx b/apps/web/src/routes/shows/$showId/setlist/$songId.tsx index 721dca4..9284a87 100644 --- a/apps/web/src/routes/shows/$showId/setlist/$songId.tsx +++ b/apps/web/src/routes/shows/$showId/setlist/$songId.tsx @@ -12,6 +12,7 @@ import { type Song, type SongArtist, type SongId, + type SongMixName, type SongMicrophoneName, type SongMixAssignment, type SongName, @@ -165,6 +166,7 @@ function SongDetail({ const [microphoneNames, setMicrophoneNames] = React.useState>( song.microphoneNames ?? [], ); + const [mixNames, setMixNames] = React.useState>(song.mixNames ?? []); const [isSaving, setIsSaving] = React.useState(false); const isSavingRef = React.useRef(false); const [saveError, setSaveError] = React.useState(); @@ -181,6 +183,7 @@ function SongDetail({ setNotes(song.notes ?? ""); setAssignments(song.mixAssignments); setMicrophoneNames(song.microphoneNames ?? []); + setMixNames(song.mixNames ?? []); }, [song]); React.useLayoutEffect(() => { @@ -208,6 +211,7 @@ function SongDetail({ readonly notes?: string; readonly assignments?: ReadonlyArray; readonly microphoneNames?: ReadonlyArray; + readonly mixNames?: ReadonlyArray; }, blockUi = true, ) => { @@ -224,6 +228,9 @@ function SongDetail({ const normalizedMicrophoneNames = (next?.microphoneNames ?? microphoneNames).filter((item) => activeMicrophoneIds.has(item.microphoneId), ); + const normalizedMixNames = (next?.mixNames ?? mixNames).filter((item) => + activeMixIds.has(item.mixId), + ); if (blockUi) { isSavingRef.current = true; setIsSaving(true); @@ -238,6 +245,7 @@ function SongDetail({ notes: next?.notes ?? notes, mixAssignments: normalizedAssignments, microphoneNames: normalizedMicrophoneNames, + mixNames: normalizedMixNames, }, reactivityKeys: songsRpcReactivityKey(showId), }); @@ -252,6 +260,7 @@ function SongDetail({ setNotes(song.notes ?? ""); setAssignments(song.mixAssignments); setMicrophoneNames(song.microphoneNames ?? []); + setMixNames(song.mixNames ?? []); return false; } return true; @@ -392,9 +401,27 @@ function SongDetail({ > {mix.number} - - {mix.name || (mix.id === mainMixId ? "Main mix" : "Mix")} - + item.mixId === mix.id)?.name ?? mix.name ?? "" + } + placeholder={mix.id === mainMixId ? "Main mix" : "Add name"} + ariaLabel={`Name override for mix ${mix.number}`} + disabled={isSaving} + inputClassName="min-w-0 pl-1 pr-2.5 text-left" + persistentInput + onSave={(value) => { + const trimmed = value.trim(); + const override = + trimmed && trimmed !== (mix.name?.trim() ?? "") ? trimmed : undefined; + const nextMixNames = [ + ...mixNames.filter((item) => item.mixId !== mix.id), + ...(override ? [{ mixId: mix.id, name: override }] : []), + ]; + setMixNames(nextMixNames); + void save({ mixNames: nextMixNames }, false); + }} + /> {mix.id === mainMixId && Main mix} @@ -489,50 +516,99 @@ function MicrophoneName({ const inheritedName = microphone.name ?? ""; const displayedName = microphoneNames.find((item) => item.microphoneId === microphone.id)?.name ?? inheritedName; + return ( + + ); +} + +function ChannelNameEditor({ + displayedName, + placeholder, + ariaLabel, + disabled, + className, + inputClassName, + onSave, + stopPropagation = false, + persistentInput = false, +}: { + readonly displayedName: string; + readonly placeholder: string; + readonly ariaLabel: string; + readonly disabled: boolean; + readonly className?: string; + readonly inputClassName?: string; + readonly onSave: (value: string) => void; + readonly stopPropagation?: boolean; + readonly persistentInput?: boolean; +}) { const [editing, setEditing] = React.useState(false); const [value, setValue] = React.useState(displayedName); + const cancelSaveRef = React.useRef(false); React.useEffect(() => setValue(displayedName), [displayedName]); - if (!editing) { + if (!editing && !persistentInput) { return ( ); } return ( { + cancelSaveRef.current = false; + setEditing(true); + }} onChange={(event) => setValue(event.currentTarget.value)} onBlur={() => { setEditing(false); - if (value.trim() !== displayedName) onSave(value); + if (!cancelSaveRef.current && value.trim() !== displayedName) onSave(value); + cancelSaveRef.current = false; }} onKeyDown={(event) => { - event.stopPropagation(); + if (stopPropagation) event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { + cancelSaveRef.current = true; setValue(displayedName); - setEditing(false); + event.currentTarget.blur(); } }} - onClick={(event) => event.stopPropagation()} - className="h-auto min-w-0 border-transparent bg-transparent p-0 text-center text-base leading-none font-medium shadow-none focus-visible:bg-input/30 focus-visible:ring-0 dark:bg-transparent dark:focus-visible:bg-input/30" + onClick={(event) => stopPropagation && event.stopPropagation()} + className={cn( + "h-auto min-w-0 text-base leading-none font-medium", + persistentInput + ? "border-transparent bg-transparent shadow-none focus-visible:bg-input/30 dark:bg-transparent dark:focus-visible:bg-input/30" + : "border-transparent bg-transparent p-0 shadow-none focus-visible:bg-input/30 focus-visible:ring-0 dark:bg-transparent dark:focus-visible:bg-input/30", + inputClassName, + )} /> ); } diff --git a/docs/migrations.md b/docs/migrations.md index 1305948..d77b8c8 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -95,7 +95,7 @@ The baseline contains these areas: - normalized client/invitation scope tables with constrained scope values; - `shows`, `microphones`, `mixes`, and `songs`, including ownership, ordering, timestamps, and the existing soft-deletion semantics; -- normalized song-to-mix assignments and ordered microphone-name overrides; +- normalized song-to-mix assignments and ordered microphone- and mix-name overrides; - chat channels, messages, profile-channel state, and presets; and - indexes for all foreign keys and hot list, ordering, authorization, and sequence queries. diff --git a/packages/backend/src/database/Database.ts b/packages/backend/src/database/Database.ts index 27f2f0e..29483eb 100644 --- a/packages/backend/src/database/Database.ts +++ b/packages/backend/src/database/Database.ts @@ -32,6 +32,7 @@ const criticalTables = [ "song_mix_assignments", "song_mix_assignment_microphones", "song_microphone_names", + "song_mix_names", "chat_channels", "chat_messages", "chat_profile_channel_state", @@ -100,6 +101,7 @@ const expectedColumns: Readonly= 0), + PRIMARY KEY (song_id, mix_id), + UNIQUE (song_id, position), + FOREIGN KEY (show_id, mix_id) REFERENCES mixes(show_id, id) ON DELETE CASCADE + )`; + yield* sql`CREATE INDEX song_mix_names_show_mix ON song_mix_names(show_id, mix_id)`; yield* sql`CREATE TABLE chat_channels ( id TEXT PRIMARY KEY NOT NULL, diff --git a/packages/backend/src/rpc/Rpc.ts b/packages/backend/src/rpc/Rpc.ts index cfc4448..6956c15 100644 --- a/packages/backend/src/rpc/Rpc.ts +++ b/packages/backend/src/rpc/Rpc.ts @@ -158,7 +158,16 @@ const handlers = ShowtimeRpcs.toLayer( songsSyncKey(showId), songs.create({ showId, id, name, artist, insertAfterSongId }), ), - "songs.edit": ({ showId, id, name, artist, notes, mixAssignments, microphoneNames }) => + "songs.edit": ({ + showId, + id, + name, + artist, + notes, + mixAssignments, + microphoneNames, + mixNames, + }) => sync.mutation( songsSyncKey(showId), songs.edit({ @@ -168,6 +177,7 @@ const handlers = ShowtimeRpcs.toLayer( artist, mixAssignments, microphoneNames, + mixNames, ...(notes === undefined ? {} : { notes }), }), ), diff --git a/packages/backend/src/shows/ShowRepository.ts b/packages/backend/src/shows/ShowRepository.ts index 0b633f1..973d9ca 100644 --- a/packages/backend/src/shows/ShowRepository.ts +++ b/packages/backend/src/shows/ShowRepository.ts @@ -16,6 +16,7 @@ import { type Microphone, type Mix, type Song, + type SongMixName, type SongMicrophoneName, type SongMixAssignment, } from "@showtime/contracts"; @@ -92,6 +93,12 @@ const MicrophoneNameRow = Schema.Struct({ name: Schema.String, position: Schema.Int, }); +const MixNameRow = Schema.Struct({ + songId: SongId, + mixId: MixId, + name: Schema.String, + position: Schema.Int, +}); const rpcError = (message: string, cause?: unknown) => new RpcError({ message, ...(cause === undefined ? {} : { cause }) }); @@ -152,11 +159,19 @@ const make = Effect.fn("ShowRepository.make")(function* () { FROM song_microphone_names n INNER JOIN songs s ON s.id = n.song_id WHERE s.show_id = ${showId} ORDER BY n.song_id, n.position`, }); + const findMixNames = SqlSchema.findAll({ + Request: ShowId, + Result: MixNameRow, + execute: (showId) => sql`SELECT n.song_id AS songId, n.mix_id AS mixId, + n.name, n.position + FROM song_mix_names n INNER JOIN songs s ON s.id = n.song_id + WHERE s.show_id = ${showId} ORDER BY n.song_id, n.position`, + }); const loadDocument = Effect.fn("ShowRepository.loadDocument")(function* ( row: typeof ShowRow.Type, ) { - const [microphoneRows, mixRows, songRows, assignmentRows, microphoneNameRows] = + const [microphoneRows, mixRows, songRows, assignmentRows, microphoneNameRows, mixNameRows] = yield* Effect.all( [ findMicrophones(row.id), @@ -164,6 +179,7 @@ const make = Effect.fn("ShowRepository.make")(function* () { findSongs(row.id), findAssignments(row.id), findMicrophoneNames(row.id), + findMixNames(row.id), ], { concurrency: "unbounded" }, ); @@ -206,8 +222,15 @@ const make = Effect.fn("ShowRepository.make")(function* () { names.push({ microphoneId: row.microphoneId, name: row.name }); namesBySong.set(row.songId, names); } + const mixNamesBySong = new Map>(); + for (const row of mixNameRows) { + const names = mixNamesBySong.get(row.songId) ?? []; + names.push({ mixId: row.mixId, name: row.name }); + mixNamesBySong.set(row.songId, names); + } const songs: ReadonlyArray = songRows.map((item) => { const microphoneNames = namesBySong.get(item.id) ?? []; + const mixNames = mixNamesBySong.get(item.id) ?? []; return { id: item.id, name: item.name, @@ -217,6 +240,7 @@ const make = Effect.fn("ShowRepository.make")(function* () { updatedAt: item.updatedAt, ...(item.notes === null ? {} : { notes: item.notes }), ...(microphoneNames.length === 0 ? {} : { microphoneNames }), + ...(mixNames.length === 0 ? {} : { mixNames }), ...(item.deletedAt === null ? {} : { deletedAt: item.deletedAt }), }; }); @@ -275,6 +299,9 @@ const make = Effect.fn("ShowRepository.make")(function* () { for (const [namePosition, name] of (song.microphoneNames ?? []).entries()) yield* sql`INSERT INTO song_microphone_names (song_id, microphone_id, name, position) VALUES (${song.id}, ${name.microphoneId}, ${name.name}, ${namePosition})`; + for (const [namePosition, name] of (song.mixNames ?? []).entries()) + yield* sql`INSERT INTO song_mix_names (song_id, show_id, mix_id, name, position) + VALUES (${song.id}, ${showId}, ${name.mixId}, ${name.name}, ${namePosition})`; } }); diff --git a/packages/backend/src/songs/SongService.test.ts b/packages/backend/src/songs/SongService.test.ts index e3b9dcc..3a5d49a 100644 --- a/packages/backend/src/songs/SongService.test.ts +++ b/packages/backend/src/songs/SongService.test.ts @@ -17,6 +17,8 @@ import { ShowService } from "../shows/ShowService.js"; import * as ShowServiceLayer from "../shows/ShowService.js"; import { MicrophoneService } from "../microphones/MicrophoneService.js"; import * as MicrophoneServiceLayer from "../microphones/MicrophoneService.js"; +import { MixService } from "../mixes/MixService.js"; +import * as MixServiceLayer from "../mixes/MixService.js"; import { SongService } from "./SongService.js"; import * as SongServiceLayer from "./SongService.js"; @@ -31,6 +33,7 @@ const makeLayer = (home: string) => { ShowServiceLayer.layer, SongServiceLayer.layer, MicrophoneServiceLayer.layer, + MixServiceLayer.layer, ).pipe( Layer.provideMerge(Layer.mergeAll(Ids.layer, ShowRepository.layer)), Layer.provide(makeDatabaseTestLayer(home)), @@ -65,6 +68,7 @@ describe("SongService", () => { artist: "" as SongArtist, mixAssignments: [], microphoneNames: [], + mixNames: [], }); }).pipe(Effect.provide(makeLayer(home))), ); @@ -92,6 +96,7 @@ describe("SongService", () => { notes: " Opening cue ", mixAssignments: [{ mixId: "mix_main" as never, microphoneIds: [microphone.id] }], microphoneNames: [], + mixNames: [], }); const reordered = yield* songs.reorder({ showId: show.id, @@ -175,6 +180,7 @@ describe("SongService", () => { artist: song.artist, mixAssignments: [], microphoneNames: [{ microphoneId: microphone.id, name: "Keys" }], + mixNames: [], }); const inherited = yield* songs.edit({ showId: show.id, @@ -183,6 +189,7 @@ describe("SongService", () => { artist: song.artist, mixAssignments: [], microphoneNames: [{ microphoneId: microphone.id, name: " Lead " }], + mixNames: [], }); return { overridden, inherited }; }).pipe(Effect.provide(makeLayer(home))), @@ -194,6 +201,45 @@ describe("SongService", () => { expect(result.inherited.microphoneNames).toBeUndefined(); }); + it("stores a song-specific mix name and removes it when it matches the inherited name", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-")); + tempHomes.add(home); + const result = await Effect.runPromise( + Effect.gen(function* () { + const shows = yield* ShowService; + const songs = yield* SongService; + const mixes = yield* MixService; + const show = yield* shows.create({ name: "Festival", color: "sky" }); + const song = yield* songs.create({ showId: show.id, ...songInput("First") }); + const main = (yield* mixes.list(show.id))[0]!; + const overridden = yield* songs.edit({ + showId: show.id, + id: song.id, + name: song.name, + artist: song.artist, + mixAssignments: [], + microphoneNames: [], + mixNames: [{ mixId: main.id, name: "House" }], + }); + const persisted = (yield* songs.list(show.id))[0]!; + const inherited = yield* songs.edit({ + showId: show.id, + id: song.id, + name: song.name, + artist: song.artist, + mixAssignments: [], + microphoneNames: [], + mixNames: [{ mixId: main.id, name: " Main " }], + }); + return { overridden, persisted, inherited }; + }).pipe(Effect.provide(makeLayer(home))), + ); + + expect(result.overridden.mixNames).toEqual([{ mixId: "mix_main", name: "House" }]); + expect(result.persisted.mixNames).toEqual([{ mixId: "mix_main", name: "House" }]); + expect(result.inherited.mixNames).toBeUndefined(); + }); + it("rejects incomplete reorder payloads without changing the setlist", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-")); tempHomes.add(home); diff --git a/packages/backend/src/songs/SongService.ts b/packages/backend/src/songs/SongService.ts index a7d6367..beab07a 100644 --- a/packages/backend/src/songs/SongService.ts +++ b/packages/backend/src/songs/SongService.ts @@ -8,6 +8,7 @@ import { type Song, type SongArtist, type SongId, + type SongMixName, type SongMicrophoneName, type SongMixAssignment, type SongName, @@ -31,6 +32,7 @@ interface SongServiceShape { readonly notes?: string; readonly mixAssignments: ReadonlyArray; readonly microphoneNames: ReadonlyArray; + readonly mixNames: ReadonlyArray; }) => Effect.Effect; readonly reorder: (params: { readonly showId: ShowId; @@ -139,6 +141,14 @@ const make = Effect.fnUntraced(function* () { new RpcError({ message: "A named microphone is invalid or no longer exists." }), ); } + if ( + new Set(params.mixNames.map((item) => item.mixId)).size !== params.mixNames.length || + params.mixNames.some((item) => !activeMixIds.has(item.mixId)) + ) { + return yield* Effect.fail( + new RpcError({ message: "A named mix is invalid or no longer exists." }), + ); + } const requestedByMix = new Map(params.mixAssignments.map((item) => [item.mixId, item])); const mixAssignments: Array = found.mixes @@ -161,6 +171,15 @@ const make = Effect.fnUntraced(function* () { ? [{ microphoneId: microphone.id, name: requestedName }] : []; }); + const mixNames = found.mixes + .filter((mix) => mix.deletedAt === undefined) + .flatMap((mix) => { + const requestedName = params.mixNames.find((item) => item.mixId === mix.id)?.name.trim(); + const inheritedName = mix.name?.trim() ?? ""; + return requestedName && requestedName !== inheritedName + ? [{ mixId: mix.id, name: requestedName }] + : []; + }); const name = yield* decodeSongName(params.name.trim()).pipe( Effect.mapError(toRpcError("Invalid song name.")), ); @@ -190,7 +209,8 @@ const make = Effect.fnUntraced(function* () { !currentMixIds.has(assignment.mixId) || assignment.microphoneIds.some((id) => !currentMicrophoneIds.has(id)), ) || - microphoneNames.some((item) => !currentMicrophoneIds.has(item.microphoneId)) + microphoneNames.some((item) => !currentMicrophoneIds.has(item.microphoneId)) || + mixNames.some((item) => !currentMixIds.has(item.mixId)) ) { throw new Error("A referenced mix or microphone no longer exists."); } @@ -201,6 +221,7 @@ const make = Effect.fnUntraced(function* () { artist, mixAssignments, ...(microphoneNames.length ? { microphoneNames } : {}), + ...(mixNames.length ? { mixNames } : {}), createdAt: current.createdAt, updatedAt: now, ...(notes ? { notes } : {}), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1dd3d22..edb2ab4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -7,6 +7,7 @@ import { SongArtist, SongId, SongMicrophoneName, + SongMixName, SongMixAssignment, SongName, } from "./song.js"; @@ -251,6 +252,7 @@ export const ShowtimeRpcs = EffectRpcGroup.make( notes: Schema.optional(Schema.String), mixAssignments: Schema.Array(SongMixAssignment), microphoneNames: Schema.Array(SongMicrophoneName), + mixNames: Schema.Array(SongMixName), }, success: Song, error: RpcError, diff --git a/packages/contracts/src/song.test.ts b/packages/contracts/src/song.test.ts index 8c9ec68..3653249 100644 --- a/packages/contracts/src/song.test.ts +++ b/packages/contracts/src/song.test.ts @@ -8,6 +8,7 @@ const valid = { artist: "Coldplay", mixAssignments: [{ mixId: "mix_main", microphoneIds: ["mic_0123456789abcdef"] }], microphoneNames: [{ microphoneId: "mic_0123456789abcdef", name: "Lead" }], + mixNames: [{ mixId: "mix_main", name: "House" }], createdAt: "2026-07-10T20:00:00.000Z", updatedAt: "2026-07-10T20:00:00.000Z", }; @@ -30,6 +31,12 @@ describe("Song", () => { mixAssignments: [valid.mixAssignments[0], valid.mixAssignments[0]], }), ).toThrow(); + expect(() => + decode({ + ...valid, + mixNames: [valid.mixNames[0], valid.mixNames[0]], + }), + ).toThrow(); expect(() => decode({ ...valid, diff --git a/packages/contracts/src/song.ts b/packages/contracts/src/song.ts index ab5caa7..fb6b073 100644 --- a/packages/contracts/src/song.ts +++ b/packages/contracts/src/song.ts @@ -41,6 +41,12 @@ export const SongMicrophoneName = Schema.Struct({ }); export type SongMicrophoneName = typeof SongMicrophoneName.Type; +export const SongMixName = Schema.Struct({ + mixId: MixId, + name: Schema.String, +}); +export type SongMixName = typeof SongMixName.Type; + const UniqueMicrophoneNames = Schema.Array(SongMicrophoneName).pipe( Schema.check( Schema.makeFilter( @@ -50,6 +56,14 @@ const UniqueMicrophoneNames = Schema.Array(SongMicrophoneName).pipe( ), ); +const UniqueMixNames = Schema.Array(SongMixName).pipe( + Schema.check( + Schema.makeFilter((names) => new Set(names.map((item) => item.mixId)).size === names.length, { + expected: "unique mix name IDs", + }), + ), +); + const UniqueMixAssignments = Schema.Array(SongMixAssignment).pipe( Schema.check( Schema.makeFilter( @@ -67,6 +81,7 @@ export const Song = Schema.Struct({ notes: Schema.optional(Schema.String), mixAssignments: UniqueMixAssignments, microphoneNames: Schema.optional(UniqueMicrophoneNames), + mixNames: Schema.optional(UniqueMixNames), createdAt: Schema.DateTimeUtcFromString, updatedAt: Schema.DateTimeUtcFromString, deletedAt: Schema.optional(Schema.DateTimeUtcFromString), diff --git a/packages/frontend/src/live/LiveSongView.test.ts b/packages/frontend/src/live/LiveSongView.test.ts index 6f8236a..093a06c 100644 --- a/packages/frontend/src/live/LiveSongView.test.ts +++ b/packages/frontend/src/live/LiveSongView.test.ts @@ -35,6 +35,7 @@ const song = { { mixId: unused.id, microphoneIds: [] }, ], microphoneNames: [{ microphoneId: leadId, name: "Chris" }], + mixNames: [{ mixId: monitor.id, name: "Alex vocal" }], } as unknown as Song; describe("projectLiveSong", () => { @@ -55,6 +56,11 @@ describe("projectLiveSong", () => { ]); }); + it("resolves song-specific mix names", () => { + const view = projectLiveSong(song, 3, 12, [monitor], [guitar]); + expect(view.mixes[0]?.name).toBe("Alex vocal"); + }); + it("trims optional display content while preserving note line breaks", () => { const view = projectLiveSong(song, 3, 12, [main], [lead]); expect(view.artist).toBe("Coldplay"); diff --git a/packages/frontend/src/live/LiveSongView.ts b/packages/frontend/src/live/LiveSongView.ts index 941ffbf..25b32ea 100644 --- a/packages/frontend/src/live/LiveSongView.ts +++ b/packages/frontend/src/live/LiveSongView.ts @@ -45,6 +45,7 @@ export function projectLiveSong( const overrides = new Map( (song.microphoneNames ?? []).map((item) => [item.microphoneId, item.name.trim()]), ); + const mixOverrides = new Map((song.mixNames ?? []).map((item) => [item.mixId, item.name.trim()])); const orderedMixes = mixes .filter((mix) => !mix.deletedAt) .map((mix, sourceIndex) => ({ mix, sourceIndex })) @@ -76,7 +77,8 @@ export function projectLiveSong( id: mix.id, number: mix.number, color: mix.color, - name: mix.name?.trim() || (mix.id === mainMixId ? "Main" : "Mix"), + name: + mixOverrides.get(mix.id) || mix.name?.trim() || (mix.id === mainMixId ? "Main" : "Mix"), microphones: assigned, }, ]; diff --git a/packages/frontend/src/songs/SongAtoms.ts b/packages/frontend/src/songs/SongAtoms.ts index d2c7107..4c96897 100644 --- a/packages/frontend/src/songs/SongAtoms.ts +++ b/packages/frontend/src/songs/SongAtoms.ts @@ -60,6 +60,7 @@ export const makeSongAtoms = (RpcClient: ShowtimeRpcClient, options?: StreamingR artist: input.payload.artist.trim() as Song["artist"], mixAssignments: input.payload.mixAssignments, microphoneNames: input.payload.microphoneNames, + mixNames: input.payload.mixNames, updatedAt, ...(notes ? { notes } : { notes: undefined }), } From 9c98d6ea421f535c23cd2947640f6854d645ce55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:02:16 +0200 Subject: [PATCH 2/2] Harden song-specific mix name saving - Queue song edits so rapid changes preserve the latest draft - Ignore pending or deleted mixes in song-specific names - Report duplicate and missing mix names accurately --- .../routes/shows/$showId/setlist/$songId.tsx | 146 ++++++++++++------ packages/backend/src/shows/ShowRepository.ts | 7 +- .../backend/src/songs/SongService.test.ts | 59 +++++++ packages/backend/src/songs/SongService.ts | 12 +- 4 files changed, 166 insertions(+), 58 deletions(-) diff --git a/apps/web/src/routes/shows/$showId/setlist/$songId.tsx b/apps/web/src/routes/shows/$showId/setlist/$songId.tsx index 9284a87..274ca5e 100644 --- a/apps/web/src/routes/shows/$showId/setlist/$songId.tsx +++ b/apps/web/src/routes/shows/$showId/setlist/$songId.tsx @@ -49,7 +49,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { microphoneColorClassNames } from "@/components/microphones/microphone-color"; import { cn } from "@/lib/utils"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { mixAtoms } from "@/client"; +import { mixAtoms, type MixListItem } from "@/client"; import { microphoneAtoms } from "@/client"; import { songAtoms, songsRpcReactivityKey } from "@/client"; import { asyncResultValueOrElse, rpcErrorMessageFromCause } from "@/client"; @@ -153,7 +153,7 @@ function SongDetail({ readonly showId: ShowId; readonly song: Song; readonly number: number; - readonly mixes: ReadonlyArray; + readonly mixes: ReadonlyArray; readonly microphones: ReadonlyArray; }) { const edit = useAtomSet(songAtoms(showId).edit, { mode: "promiseExit" }); @@ -169,6 +169,17 @@ function SongDetail({ const [mixNames, setMixNames] = React.useState>(song.mixNames ?? []); const [isSaving, setIsSaving] = React.useState(false); const isSavingRef = React.useRef(false); + const queuedSaveCountRef = React.useRef(0); + const preserveDraftRef = React.useRef(false); + const saveQueueRef = React.useRef>(Promise.resolve()); + const draftRef = React.useRef({ + name: song.name as string, + artist: song.artist as string, + notes: song.notes ?? "", + assignments: song.mixAssignments, + microphoneNames: song.microphoneNames ?? [], + mixNames: song.mixNames ?? [], + }); const [saveError, setSaveError] = React.useState(); const [deleteOpen, setDeleteOpen] = React.useState(false); const notesRef = React.useRef(null); @@ -178,6 +189,15 @@ function SongDetail({ const hasUnpairedMix = orderedMixes.filter((mix) => mix.id !== mainMixId).length % 2 === 1; React.useEffect(() => { + if (queuedSaveCountRef.current > 0 || preserveDraftRef.current) return; + draftRef.current = { + name: song.name, + artist: song.artist, + notes: song.notes ?? "", + assignments: song.mixAssignments, + microphoneNames: song.microphoneNames ?? [], + mixNames: song.mixNames ?? [], + }; setName(song.name); setArtist(song.artist); setNotes(song.notes ?? ""); @@ -215,60 +235,76 @@ function SongDetail({ }, blockUi = true, ) => { - const nextName = (next?.name ?? name).trim(); - const nextArtist = (next?.artist ?? artist).trim(); if (blockUi && isSavingRef.current) return false; - const activeMixIds = new Set(mixes.map((mix) => mix.id)); + const draft = { ...draftRef.current, ...next }; + const activeMixIds = new Set(mixes.filter((mix) => !mix.pending).map((mix) => mix.id)); const activeMicrophoneIds = new Set(microphones.map((microphone) => microphone.id)); - const normalizedAssignments = (next?.assignments ?? assignments).flatMap((assignment) => { + const normalizedAssignments = draft.assignments.flatMap((assignment) => { if (!activeMixIds.has(assignment.mixId)) return []; const microphoneIds = assignment.microphoneIds.filter((id) => activeMicrophoneIds.has(id)); return microphoneIds.length > 0 ? [{ ...assignment, microphoneIds }] : []; }); - const normalizedMicrophoneNames = (next?.microphoneNames ?? microphoneNames).filter((item) => + const normalizedMicrophoneNames = draft.microphoneNames.filter((item) => activeMicrophoneIds.has(item.microphoneId), ); - const normalizedMixNames = (next?.mixNames ?? mixNames).filter((item) => - activeMixIds.has(item.mixId), + const normalizedMixNames = draft.mixNames.filter((item) => activeMixIds.has(item.mixId)); + const normalizedDraft = { + ...draft, + name: draft.name.trim(), + artist: draft.artist.trim(), + assignments: normalizedAssignments, + microphoneNames: normalizedMicrophoneNames, + mixNames: normalizedMixNames, + }; + draftRef.current = normalizedDraft; + queuedSaveCountRef.current += 1; + + const run = async () => { + if (blockUi) { + isSavingRef.current = true; + setIsSaving(true); + } + setSaveError(undefined); + const result = await edit({ + payload: { + showId, + id: song.id, + name: normalizedDraft.name as SongName, + artist: normalizedDraft.artist as SongArtist, + notes: normalizedDraft.notes, + mixAssignments: normalizedDraft.assignments, + microphoneNames: normalizedDraft.microphoneNames, + mixNames: normalizedDraft.mixNames, + }, + reactivityKeys: songsRpcReactivityKey(showId), + }); + if (blockUi) { + isSavingRef.current = false; + setIsSaving(false); + } + if (Exit.isFailure(result)) { + preserveDraftRef.current = true; + setSaveError(rpcErrorMessageFromCause(result.cause)); + return false; + } + preserveDraftRef.current = false; + return true; + }; + + const result = saveQueueRef.current.then(run, run); + saveQueueRef.current = result.then( + () => undefined, + () => undefined, ); - if (blockUi) { - isSavingRef.current = true; - setIsSaving(true); - } - setSaveError(undefined); - const result = await edit({ - payload: { - showId, - id: song.id, - name: nextName as SongName, - artist: nextArtist as SongArtist, - notes: next?.notes ?? notes, - mixAssignments: normalizedAssignments, - microphoneNames: normalizedMicrophoneNames, - mixNames: normalizedMixNames, - }, - reactivityKeys: songsRpcReactivityKey(showId), + return result.finally(() => { + queuedSaveCountRef.current -= 1; }); - if (blockUi) { - isSavingRef.current = false; - setIsSaving(false); - } - if (Exit.isFailure(result)) { - setSaveError(rpcErrorMessageFromCause(result.cause)); - setName(song.name); - setArtist(song.artist); - setNotes(song.notes ?? ""); - setAssignments(song.mixAssignments); - setMicrophoneNames(song.microphoneNames ?? []); - setMixNames(song.mixNames ?? []); - return false; - } - return true; }; const toggleMicrophone = (mixId: Mix["id"], microphoneId: MicrophoneId) => { if (isSavingRef.current) return; - const existing = assignments.find((assignment) => assignment.mixId === mixId); + const currentAssignments = draftRef.current.assignments; + const existing = currentAssignments.find((assignment) => assignment.mixId === mixId); const selected = new Set(existing?.microphoneIds ?? []); if (selected.has(microphoneId)) { selected.delete(microphoneId); @@ -279,7 +315,7 @@ function SongDetail({ .filter((microphone) => selected.has(microphone.id)) .map((microphone) => microphone.id); const next = [ - ...assignments.filter((assignment) => assignment.mixId !== mixId), + ...currentAssignments.filter((assignment) => assignment.mixId !== mixId), ...(microphoneIds.length ? [{ mixId, microphoneIds }] : []), ]; setAssignments(next); @@ -299,7 +335,11 @@ function SongDetail({ placeholder="New song" value={name} disabled={isSaving} - onChange={(event) => setName(event.currentTarget.value)} + onChange={(event) => { + const value = event.currentTarget.value; + draftRef.current = { ...draftRef.current, name: value }; + setName(value); + }} onBlur={() => { if (name.trim() !== song.name) void save({ name }); }} @@ -315,7 +355,11 @@ function SongDetail({ placeholder="Artist" value={artist} disabled={isSaving} - onChange={(event) => setArtist(event.currentTarget.value)} + onChange={(event) => { + const value = event.currentTarget.value; + draftRef.current = { ...draftRef.current, artist: value }; + setArtist(value); + }} onBlur={() => { if (artist.trim() !== song.artist) void save({ artist }); }} @@ -331,7 +375,11 @@ function SongDetail({ disabled={isSaving} placeholder="Notes" rows={1} - onChange={(event) => setNotes(event.currentTarget.value)} + onChange={(event) => { + const value = event.currentTarget.value; + draftRef.current = { ...draftRef.current, notes: value }; + setNotes(value); + }} onBlur={() => { if (notes.trim() !== (song.notes ?? "")) void save({ notes }); }} @@ -407,7 +455,7 @@ function SongDetail({ } placeholder={mix.id === mainMixId ? "Main mix" : "Add name"} ariaLabel={`Name override for mix ${mix.number}`} - disabled={isSaving} + disabled={isSaving || mix.pending === true} inputClassName="min-w-0 pl-1 pr-2.5 text-left" persistentInput onSave={(value) => { @@ -415,7 +463,7 @@ function SongDetail({ const override = trimmed && trimmed !== (mix.name?.trim() ?? "") ? trimmed : undefined; const nextMixNames = [ - ...mixNames.filter((item) => item.mixId !== mix.id), + ...draftRef.current.mixNames.filter((item) => item.mixId !== mix.id), ...(override ? [{ mixId: mix.id, name: override }] : []), ]; setMixNames(nextMixNames); @@ -468,7 +516,7 @@ function SongDetail({ ? trimmed : undefined; const nextMicrophoneNames = [ - ...microphoneNames.filter( + ...draftRef.current.microphoneNames.filter( (item) => item.microphoneId !== microphone.id, ), ...(override diff --git a/packages/backend/src/shows/ShowRepository.ts b/packages/backend/src/shows/ShowRepository.ts index 973d9ca..c8cbdb9 100644 --- a/packages/backend/src/shows/ShowRepository.ts +++ b/packages/backend/src/shows/ShowRepository.ts @@ -164,8 +164,11 @@ const make = Effect.fn("ShowRepository.make")(function* () { Result: MixNameRow, execute: (showId) => sql`SELECT n.song_id AS songId, n.mix_id AS mixId, n.name, n.position - FROM song_mix_names n INNER JOIN songs s ON s.id = n.song_id - WHERE s.show_id = ${showId} ORDER BY n.song_id, n.position`, + FROM song_mix_names n + INNER JOIN songs s ON s.id = n.song_id + INNER JOIN mixes m ON m.show_id = n.show_id AND m.id = n.mix_id + WHERE s.show_id = ${showId} AND m.deleted_at IS NULL + ORDER BY n.song_id, n.position`, }); const loadDocument = Effect.fn("ShowRepository.loadDocument")(function* ( diff --git a/packages/backend/src/songs/SongService.test.ts b/packages/backend/src/songs/SongService.test.ts index 3a5d49a..3e7a17b 100644 --- a/packages/backend/src/songs/SongService.test.ts +++ b/packages/backend/src/songs/SongService.test.ts @@ -240,6 +240,65 @@ describe("SongService", () => { expect(result.inherited.mixNames).toBeUndefined(); }); + it("does not return a song-specific name for a deleted mix", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-")); + tempHomes.add(home); + const result = await Effect.runPromise( + Effect.gen(function* () { + const shows = yield* ShowService; + const songs = yield* SongService; + const mixes = yield* MixService; + const show = yield* shows.create({ name: "Festival", color: "sky" }); + const song = yield* songs.create({ showId: show.id, ...songInput("First") }); + const mix = yield* mixes.create({ showId: show.id, color: "rose" }); + yield* songs.edit({ + showId: show.id, + id: song.id, + name: song.name, + artist: song.artist, + mixAssignments: [], + microphoneNames: [], + mixNames: [{ mixId: mix.id, name: "Vocal" }], + }); + yield* mixes.delete({ showId: show.id, id: mix.id }); + return (yield* songs.list(show.id))[0]!; + }).pipe(Effect.provide(makeLayer(home))), + ); + + expect(result.mixNames).toBeUndefined(); + }); + + it("reports duplicate song-specific mix names accurately", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-")); + tempHomes.add(home); + const error = await Effect.runPromise( + Effect.gen(function* () { + const shows = yield* ShowService; + const songs = yield* SongService; + const mixes = yield* MixService; + const show = yield* shows.create({ name: "Festival", color: "sky" }); + const song = yield* songs.create({ showId: show.id, ...songInput("First") }); + const main = (yield* mixes.list(show.id))[0]!; + return yield* Effect.flip( + songs.edit({ + showId: show.id, + id: song.id, + name: song.name, + artist: song.artist, + mixAssignments: [], + microphoneNames: [], + mixNames: [ + { mixId: main.id, name: "House" }, + { mixId: main.id, name: "PA" }, + ], + }), + ); + }).pipe(Effect.provide(makeLayer(home))), + ); + + expect(error.message).toBe("A mix was named more than once."); + }); + it("rejects incomplete reorder payloads without changing the setlist", async () => { const home = await mkdtemp(path.join(os.tmpdir(), "showtime-home-")); tempHomes.add(home); diff --git a/packages/backend/src/songs/SongService.ts b/packages/backend/src/songs/SongService.ts index beab07a..e3fa231 100644 --- a/packages/backend/src/songs/SongService.ts +++ b/packages/backend/src/songs/SongService.ts @@ -141,13 +141,11 @@ const make = Effect.fnUntraced(function* () { new RpcError({ message: "A named microphone is invalid or no longer exists." }), ); } - if ( - new Set(params.mixNames.map((item) => item.mixId)).size !== params.mixNames.length || - params.mixNames.some((item) => !activeMixIds.has(item.mixId)) - ) { - return yield* Effect.fail( - new RpcError({ message: "A named mix is invalid or no longer exists." }), - ); + if (new Set(params.mixNames.map((item) => item.mixId)).size !== params.mixNames.length) { + return yield* Effect.fail(new RpcError({ message: "A mix was named more than once." })); + } + if (params.mixNames.some((item) => !activeMixIds.has(item.mixId))) { + return yield* Effect.fail(new RpcError({ message: "A named mix no longer exists." })); } const requestedByMix = new Map(params.mixAssignments.map((item) => [item.mixId, item]));