diff --git a/apps/web/src/components/ImportShowFlowModal.tsx b/apps/web/src/components/ImportShowFlowModal.tsx index fea9bb7..8ea71b5 100644 --- a/apps/web/src/components/ImportShowFlowModal.tsx +++ b/apps/web/src/components/ImportShowFlowModal.tsx @@ -50,7 +50,7 @@ Each item in the \`items\` array must follow this format: "id": "unique-id-here", "type": "item", "itemNumber": "1", - "startTime": "00:00:00", + "startTime": "19:00:00", // Actual show time (e.g., 7:00 PM) "preset": "Scene or preset name", "duration": "00:30", "privateNotes": "Internal notes not shown to audience", @@ -68,7 +68,7 @@ Each item in the \`items\` array must follow this format: "id": "unique-id-here", "type": "header", "itemNumber": "", // Leave empty for headers - "startTime": "00:00:00", // Optional for headers + "startTime": "19:00:00", // Actual show time when section starts "headerTitle": "Section Name", "highlightColor": "#0000FF" // Optional, but recommended for visual organization } @@ -79,7 +79,7 @@ Each item in the \`items\` array must follow this format: - **id**: Generate a unique identifier for each item (e.g., "item-1", "header-1", or UUID) - **type**: Either "item" for regular entries or "header" for section dividers - **itemNumber**: Sequential number for items (string). Leave empty ("") for headers -- **startTime**: Time in HH:MM:SS format (e.g., "00:15:30"). Calculate based on previous items +- **startTime**: The actual absolute time when this item/header starts in HH:MM:SS format (e.g., "19:30:00" for 7:30 PM). This is NOT relative to show start - it's the real wall-clock time - **duration**: Duration in MM:SS format (e.g., "05:30" for 5 minutes 30 seconds) - **preset**: The scene, preset, or segment name (descriptive and clear) - **privateNotes**: Internal notes for operators (technical details, warnings, reminders) @@ -116,7 +116,7 @@ For specialized productions, add custom fields: ## Conversion Guidelines: -1. **Time Calculation**: Start at "00:00:00" and calculate each subsequent startTime by adding the previous item's duration +1. **Time Calculation**: Use actual absolute wall-clock times for all start times. If the show starts at 7:00 PM (19:00), the first item should be "19:00:00", not "00:00:00". Calculate each subsequent startTime by adding the previous item's duration to its start time, plus any intentional gaps/buffers 2. **Section Organization**: Use headers to break the show into logical sections 3. **Color Coding**: Use highlight colors to help operators quickly identify different types of content 4. **Technical Details**: Include specific technical information that operators would need: @@ -148,7 +148,7 @@ Q&A session "id": "header-1", "type": "header", "itemNumber": "", - "startTime": "00:00:00", + "startTime": "18:30:00", "headerTitle": "Pre-Show", "highlightColor": "#4A90E2" }, @@ -156,7 +156,7 @@ Q&A session "id": "item-1", "type": "item", "itemNumber": "1", - "startTime": "00:00:00", + "startTime": "18:30:00", "preset": "Walk-in Music & House Open", "duration": "30:00", "privateNotes": "Loop playlist, check all wireless mics", @@ -169,7 +169,7 @@ Q&A session "id": "item-2", "type": "item", "itemNumber": "2", - "startTime": "00:30:00", + "startTime": "19:00:00", "preset": "CEO Opening Speech", "duration": "10:00", "privateNotes": "CEO enters from stage left, mic check complete", diff --git a/apps/web/src/pages/RunOfShowEditor.tsx b/apps/web/src/pages/RunOfShowEditor.tsx index e7c53c1..927caf4 100644 --- a/apps/web/src/pages/RunOfShowEditor.tsx +++ b/apps/web/src/pages/RunOfShowEditor.tsx @@ -519,32 +519,152 @@ const RunOfShowEditor: React.FC = () => { if (targetIndex >= items.length) return; } - // Swap items - [items[currentIndex], items[targetIndex]] = [items[targetIndex], items[currentIndex]]; + // Step 1: Calculate gaps BEFORE reordering + // This captures the gap each item has relative to its IMMEDIATE previous item + const itemsWithGaps = items.map((item, index) => { + let gap = 0; - // Recalculate item numbers and start times - let itemCount = 1; - let cumulativeTimeSeconds = 0; - - const recalculatedItems = items.map((item) => { if (item.type === "item") { - // Update item number - item.itemNumber = itemCount.toString(); - itemCount++; + const itemStartTime = parseTimeToSeconds(item.startTime || ""); + + if (itemStartTime === null) { + // Without a valid start time we cannot compute a reliable gap + return { ...item, calculatedGap: 0 }; + } + + // Walk backwards to find the previous item's end time, skipping headers + let prevEndTime: number | null = null; + for (let i = index - 1; i >= 0; i--) { + const prev = items[i]; + if (prev.type !== "item") continue; + + const prevStart = parseTimeToSeconds(prev.startTime || ""); + const prevDuration = parseDurationToSeconds(prev.duration || ""); + + if (prevStart !== null) { + prevEndTime = prevStart + (prevDuration ?? 0); + break; + } - // Update start time based on cumulative time - item.startTime = formatSecondsToTime(cumulativeTimeSeconds); + // If no start, but we have duration, keep looking further back + // However, don't add duration without a known anchor start. + if (prevStart === null) continue; + } - // Add this item's duration to cumulative time - const durationSeconds = parseDurationToSeconds(item.duration || ""); - if (durationSeconds !== null) { - cumulativeTimeSeconds += durationSeconds; + if (prevEndTime !== null) { + gap = itemStartTime - prevEndTime; + if (gap < 0) gap = 0; + } else { + // No previous item with a valid start; treat as no gap to preserve + gap = 0; } - } else if (item.type === "header") { - // Headers can have a start time matching the next item - item.startTime = formatSecondsToTime(cumulativeTimeSeconds); } - return item; + + return { ...item, calculatedGap: gap }; + }); + + // Step 2: Swap items + [itemsWithGaps[currentIndex], itemsWithGaps[targetIndex]] = [ + itemsWithGaps[targetIndex], + itemsWithGaps[currentIndex], + ]; + + // Establish initial cumulative time from earliest anchored start (header or item) + const firstAnchoredStart = (() => { + for (const it of itemsWithGaps) { + const t = parseTimeToSeconds(it.startTime || ""); + if (t !== null) return t; + } + return 0; + })(); + + // Step 3: Recalculate item numbers and start times using stored gaps + let itemCount = 1; + let cumulativeEndTimeSeconds = firstAnchoredStart; + + const recalculatedItems = itemsWithGaps.map((item) => { + if (item.type === "header") { + const existingStartTime = parseTimeToSeconds(item.startTime || ""); + let newHeaderStart: string; + + if (existingStartTime !== null) { + // Advance pointer to header time if it's ahead, clamp if behind + if (existingStartTime > cumulativeEndTimeSeconds) { + cumulativeEndTimeSeconds = existingStartTime; + newHeaderStart = item.startTime!; + } else { + newHeaderStart = formatSecondsToTime(cumulativeEndTimeSeconds); + } + } else { + newHeaderStart = formatSecondsToTime(cumulativeEndTimeSeconds); + } + + // Return header without calculatedGap property + return { + id: item.id, + type: item.type, + itemNumber: item.itemNumber, + startTime: newHeaderStart, + highlightColor: item.highlightColor, + headerTitle: item.headerTitle, + } as RunOfShowItem; + } + + // Items - extract gap and other properties + const itemWithGap = item as RunOfShowItem & { calculatedGap?: number }; + const gap = itemWithGap.calculatedGap || 0; + const newStartTime = cumulativeEndTimeSeconds + gap; + const durationSeconds = parseDurationToSeconds(item.duration || ""); + + itemCount++; + cumulativeEndTimeSeconds = newStartTime; + if (durationSeconds !== null) { + cumulativeEndTimeSeconds += durationSeconds; + } + + // Return item without calculatedGap property + return { + id: item.id, + type: item.type, + itemNumber: itemCount.toString(), + startTime: formatSecondsToTime(newStartTime), + highlightColor: item.highlightColor, + preset: item.preset, + duration: item.duration, + privateNotes: item.privateNotes, + productionNotes: item.productionNotes, + audio: item.audio, + video: item.video, + lights: item.lights, + // Include custom column values + ...Object.keys(item) + .filter( + (key) => + ![ + "id", + "type", + "itemNumber", + "startTime", + "highlightColor", + "preset", + "duration", + "privateNotes", + "productionNotes", + "audio", + "video", + "lights", + "calculatedGap", + "headerTitle", + ].includes(key), + ) + .reduce( + (acc, key) => { + acc[key] = item[key as keyof RunOfShowItem]; + return acc; + }, + {} as Record, + ), + } as RunOfShowItem; }); setRunOfShow({ ...runOfShow, items: recalculatedItems });