From 0f609ea6b5c66fecfd12910ece05211c4a30960d Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 31 Jan 2026 17:39:59 +0000 Subject: [PATCH 1/6] Add plans directory to ignore file --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 65b8c2d8..bfc4a15d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ CLAUDE.md .idea/ .playwright-mcp/ -PLAN.md +plans/ # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, From cba60221330441225faa2b19b6f9e1c21c23b8b0 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 31 Jan 2026 17:41:33 +0000 Subject: [PATCH 2/6] Add scene change plan modal --- .../show/live/StageManagerPane.vue | 253 ++++++++++++++++-- 1 file changed, 234 insertions(+), 19 deletions(-) diff --git a/client/src/vue_components/show/live/StageManagerPane.vue b/client/src/vue_components/show/live/StageManagerPane.vue index 107975b5..70425282 100644 --- a/client/src/vue_components/show/live/StageManagerPane.vue +++ b/client/src/vue_components/show/live/StageManagerPane.vue @@ -49,25 +49,6 @@ - -
- -
    -
  • - {{ item.name }} -
  • -
-
- -
- -
    -
  • - {{ item.name }} -
  • -
-
-
+ Plan +
+
+ +
    +
  • + {{ getSceneryDisplayName(item) }} +
  • +
+
+
+ +
    +
  • + {{ getPropDisplayName(item) }} +
  • +
+
+
+ +
+ + +
+ + + Setting + +
+
+ + + + + + Scenery + + + Props + + + + +
    +
  • + {{ getSceneryDisplayName(item) }} +
  • +
+

None

+
+ +
    +
  • + {{ getPropDisplayName(item) }} +
  • +
+

None

+
+
+
+
+
+
+ + +
+ + + Striking + +
+
+ + + + + + Scenery + + + Props + + + + +
    +
  • + {{ getSceneryDisplayName(item) }} +
  • +
+

None

+
+ +
    +
  • + {{ getPropDisplayName(item) }} +
  • +
+

None

+
+
+
+
+
+
+
+
+

No scene selected.

+
+
@@ -102,6 +213,9 @@ export default { expandedScenes: {}, pinnedScenes: {}, debounceContentSize: null, + smPlanScene: null, + smPlanSet: true, + smPlanStrike: true, }; }, computed: { @@ -157,6 +271,8 @@ export default { 'SCENERY_ALLOCATIONS', 'PROPS_LIST', 'SCENERY_LIST', + 'PROP_TYPES_DICT', + 'SCENERY_TYPES_DICT', ]), }, watch: { @@ -213,6 +329,8 @@ export default { this.GET_SCENERY_LIST(), this.GET_PROPS_ALLOCATIONS(), this.GET_SCENERY_ALLOCATIONS(), + this.GET_PROP_TYPES(), + this.GET_SCENERY_TYPES(), ]); this.loaded = true; @@ -267,6 +385,14 @@ export default { .map((alloc) => this.sceneryDict[alloc.scenery_id]) .filter((scenery) => scenery != null); }, + getPropDisplayName(prop) { + const propType = this.PROP_TYPES_DICT[prop.prop_type_id]; + return propType ? `${propType.name}: ${prop.name}` : prop.name; + }, + getSceneryDisplayName(scenery) { + const sceneryType = this.SCENERY_TYPES_DICT[scenery.scenery_type_id]; + return sceneryType ? `${sceneryType.name}: ${scenery.name}` : scenery.name; + }, autoScrollToCurrentScene(currentSceneId) { const container = this.$refs.scrollContainer; if (!container) return; @@ -295,6 +421,66 @@ export default { container.scrollTo({ top: scrollOffset, behavior: 'smooth' }); } }, + resetSMPlanScene() { + this.smPlanScene = null; + }, + showSMPlanModal(scene) { + this.smPlanScene = scene; + this.$bvModal.show('sm-plan-modal'); + }, + togglePlanSet() { + this.smPlanSet = !this.smPlanSet; + }, + togglePlanStrike() { + this.smPlanStrike = !this.smPlanStrike; + }, + getPreviousScene(scene) { + const currentIndex = this.orderedScenes.findIndex((s) => s.id === scene.id); + if (currentIndex <= 0) { + return null; // First scene or not found + } + return this.orderedScenes[currentIndex - 1]; + }, + getSettingScenery(scene) { + const currentScenery = this.getSceneryForScene(scene.id); + const previousScene = this.getPreviousScene(scene); + if (!previousScene) { + return currentScenery; // First scene - all items are being set + } + const previousSceneryIds = new Set( + this.getSceneryForScene(previousScene.id).map((s) => s.id) + ); + return currentScenery.filter((item) => !previousSceneryIds.has(item.id)); + }, + getSettingProps(scene) { + const currentProps = this.getPropsForScene(scene.id); + const previousScene = this.getPreviousScene(scene); + if (!previousScene) { + return currentProps; // First scene - all items are being set + } + const previousPropsIds = new Set(this.getPropsForScene(previousScene.id).map((p) => p.id)); + return currentProps.filter((item) => !previousPropsIds.has(item.id)); + }, + getStrikingScenery(scene) { + const previousScene = this.getPreviousScene(scene); + if (!previousScene) { + return []; // First scene - nothing to strike + } + const currentSceneryIds = new Set(this.getSceneryForScene(scene.id).map((s) => s.id)); + return this.getSceneryForScene(previousScene.id).filter( + (item) => !currentSceneryIds.has(item.id) + ); + }, + getStrikingProps(scene) { + const previousScene = this.getPreviousScene(scene); + if (!previousScene) { + return []; // First scene - nothing to strike + } + const currentPropsIds = new Set(this.getPropsForScene(scene.id).map((p) => p.id)); + return this.getPropsForScene(previousScene.id).filter( + (item) => !currentPropsIds.has(item.id) + ); + }, ...mapActions([ 'GET_ACT_LIST', 'GET_SCENE_LIST', @@ -302,6 +488,8 @@ export default { 'GET_SCENERY_LIST', 'GET_PROPS_ALLOCATIONS', 'GET_SCENERY_ALLOCATIONS', + 'GET_PROP_TYPES', + 'GET_SCENERY_TYPES', ]), }, }; @@ -431,4 +619,31 @@ export default { color: #6c757d; font-style: italic; } + +.plan-header-row { + margin: 0; + padding: 0; + border-bottom: 1px solid #dee2e6; +} + +.plan-header-col { + padding: 0.5rem 0.75rem; +} + +.plan-header-col.border-right { + border-right: 1px solid #dee2e6; +} + +.plan-content-row { + margin: 0; + padding: 0; +} + +.plan-content-col { + padding: 0.5rem 0.75rem; +} + +.plan-content-col.border-right { + border-right: 1px solid #dee2e6; +} From 22d48938eeb3e24d6832f9cd85919b4d3b9d0bef Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 31 Jan 2026 18:34:08 +0000 Subject: [PATCH 3/6] Add stage timeline tab --- client/src/mixins/timelineMixin.js | 253 ++++++++++ client/src/store/modules/stage.js | 28 ++ client/src/views/show/config/ConfigStage.vue | 5 + .../show/config/mics/MicTimeline.vue | 207 +------- .../show/config/stage/StageTimeline.vue | 444 ++++++++++++++++++ 5 files changed, 739 insertions(+), 198 deletions(-) create mode 100644 client/src/mixins/timelineMixin.js create mode 100644 client/src/vue_components/show/config/stage/StageTimeline.vue diff --git a/client/src/mixins/timelineMixin.js b/client/src/mixins/timelineMixin.js new file mode 100644 index 00000000..00d1e2b6 --- /dev/null +++ b/client/src/mixins/timelineMixin.js @@ -0,0 +1,253 @@ +import { mapGetters } from 'vuex'; + +/** + * Shared mixin for timeline visualization components (MicTimeline, StageTimeline). + * Provides common SVG rendering utilities, layout calculations, and export functionality. + * + * This mixin assumes the component has: + * - A Vuex store with ACT_BY_ID and ORDERED_SCENES getters + * - A `scenes` computed property returning the ordered list of scenes + * - A `rows` computed property returning items to display as rows + * - A `$refs.svg` reference to the SVG element + * + * Components using this mixin should implement: + * - computed.scenes - array of scene objects in order + * - computed.rows - array of row items (e.g., mics, props, scenery) + * - methods for generating bars specific to their domain + */ +export default { + data() { + return { + margin: { + top: 75, + right: 20, + bottom: 20, + left: 150, + }, + sceneWidth: 100, + rowHeight: 50, + barPadding: 6, + }; + }, + + computed: { + ...mapGetters(['ACT_BY_ID']), + contentWidth() { + return this.scenes.length * this.sceneWidth; + }, + contentHeight() { + return this.rows.length * this.rowHeight; + }, + totalWidth() { + return this.margin.left + this.contentWidth + this.margin.right; + }, + totalHeight() { + return this.margin.top + this.contentHeight + this.margin.bottom; + }, + actGroups() { + const groups = []; + let currentAct = null; + let startIndex = 0; + + this.scenes.forEach((scene, index) => { + const act = this.ACT_BY_ID(scene.act); + if (!act) return; + + if (currentAct !== act.id) { + if (currentAct !== null) { + groups.push({ + actId: currentAct, + actName: this.ACT_BY_ID(currentAct)?.name || 'Unknown', + startX: this.getSceneX(startIndex), + width: this.getSceneX(index) - this.getSceneX(startIndex), + }); + } + currentAct = act.id; + startIndex = index; + } + }); + + // Push final group + if (currentAct !== null) { + groups.push({ + actId: currentAct, + actName: this.ACT_BY_ID(currentAct)?.name || 'Unknown', + startX: this.getSceneX(startIndex), + width: this.getSceneX(this.scenes.length) - this.getSceneX(startIndex), + }); + } + + return groups; + }, + }, + + methods: { + getSceneX(sceneIndex) { + return sceneIndex * this.sceneWidth; + }, + getRowY(rowIndex) { + return rowIndex * this.rowHeight; + }, + getColorForEntity(entityId, entityType) { + // Golden ratio angle provides optimal distribution across hue spectrum + const GOLDEN_RATIO_CONJUGATE = 137.508; + + // Different offsets for different entity types to avoid collisions + const typeOffsets = { + mic: 0, + character: 120, + cast: 240, + prop: 60, + scenery: 180, + }; + + const offset = typeOffsets[entityType] || 0; + const hue = (entityId * GOLDEN_RATIO_CONJUGATE + offset) % 360; + + // Use high saturation and medium lightness for vibrant, visible colors + return `hsl(${hue}, 70%, 50%)`; + }, + groupConsecutiveScenes(allocations, sceneIdField = 'scene_id') { + if (!allocations || allocations.length === 0) { + return []; + } + + const segments = []; + let currentSegment = null; + + this.scenes.forEach((scene, sceneIndex) => { + const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id); + + if (hasAllocation) { + if (currentSegment) { + // Extend current segment + currentSegment.endIndex = sceneIndex; + currentSegment.endScene = scene.name; + } else { + // Start new segment + currentSegment = { + startIndex: sceneIndex, + endIndex: sceneIndex, + startScene: scene.name, + endScene: scene.name, + }; + } + } else if (currentSegment) { + // End current segment + segments.push(currentSegment); + currentSegment = null; + } + }); + + // Push final segment + if (currentSegment) { + segments.push(currentSegment); + } + + return segments; + }, + exportTimeline(filenamePrefix = 'timeline', viewModeName = '') { + const svgElement = this.$refs.svg; + if (!svgElement) return; + + // Create a clone of the SVG to avoid modifying the original + const svgClone = svgElement.cloneNode(true); + + // Inline critical styles for export (for print-friendly output) + this.applyExportStyles(svgClone); + + // Serialize the SVG to a string + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgClone); + + // Create a blob and download link + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + // Set canvas size to SVG size + canvas.width = this.totalWidth; + canvas.height = this.totalHeight; + + img.onload = () => { + // Draw white background for print-friendly output + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw the SVG image + ctx.drawImage(img, 0, 0); + + // Convert canvas to blob and download + canvas.toBlob((blob) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const dateSuffix = new Date().toISOString().slice(0, 10); + const modeSuffix = viewModeName ? `-${viewModeName}` : ''; + link.download = `${filenamePrefix}${modeSuffix}-${dateSuffix}.png`; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }); + }; + + // Create a data URL from the SVG string + const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const svgUrl = URL.createObjectURL(svgBlob); + img.src = svgUrl; + }, + applyExportStyles(svgClone) { + // Style scene dividers (vertical grid lines) + svgClone.querySelectorAll('.scene-divider').forEach((el) => { + el.setAttribute('stroke', '#495057'); + el.setAttribute('stroke-width', '1'); + el.setAttribute('opacity', '0.4'); + }); + + // Style row separators (horizontal grid lines) + svgClone.querySelectorAll('.row-separator').forEach((el) => { + el.setAttribute('stroke', '#495057'); + el.setAttribute('stroke-width', '1'); + el.setAttribute('opacity', '0.3'); + }); + + // Style act headers and labels + svgClone.querySelectorAll('.act-header').forEach((el) => { + el.setAttribute('fill', '#e9ecef'); + el.setAttribute('stroke', '#495057'); + el.setAttribute('stroke-width', '1'); + }); + + svgClone.querySelectorAll('.act-label').forEach((el) => { + el.setAttribute('fill', '#212529'); + el.setAttribute('font-size', '14'); + el.setAttribute('font-weight', '600'); + }); + + // Style scene labels + svgClone.querySelectorAll('.scene-label').forEach((el) => { + el.setAttribute('fill', '#495057'); + el.setAttribute('font-size', '11'); + }); + + // Style row labels + svgClone.querySelectorAll('.row-label').forEach((el) => { + el.setAttribute('fill', '#212529'); + el.setAttribute('font-size', '12'); + el.setAttribute('font-weight', '500'); + }); + + // Style allocation bars (keep their colors but add stroke) + svgClone.querySelectorAll('.allocation-bar').forEach((el) => { + el.setAttribute('stroke', '#212529'); + el.setAttribute('stroke-width', '1'); + }); + + // Style bar labels + svgClone.querySelectorAll('.bar-label').forEach((el) => { + el.setAttribute('fill', '#ffffff'); + el.setAttribute('font-weight', '600'); + el.setAttribute('style', 'text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)'); + }); + }, + }, +}; diff --git a/client/src/store/modules/stage.js b/client/src/store/modules/stage.js index 97a901c2..78fa7be3 100644 --- a/client/src/store/modules/stage.js +++ b/client/src/store/modules/stage.js @@ -432,6 +432,34 @@ export default { CREW_LIST(state) { return state.crewList; }, + PROP_BY_ID: (state) => (propId) => { + if (propId == null) return null; + return state.propsList.find((p) => p.id === propId) || null; + }, + SCENERY_BY_ID: (state) => (sceneryId) => { + if (sceneryId == null) return null; + return state.sceneryList.find((s) => s.id === sceneryId) || null; + }, + PROPS_ALLOCATIONS_BY_ITEM(state) { + const result = {}; + state.propsAllocations.forEach((alloc) => { + if (!result[alloc.props_id]) { + result[alloc.props_id] = []; + } + result[alloc.props_id].push(alloc); + }); + return result; + }, + SCENERY_ALLOCATIONS_BY_ITEM(state) { + const result = {}; + state.sceneryAllocations.forEach((alloc) => { + if (!result[alloc.scenery_id]) { + result[alloc.scenery_id] = []; + } + result[alloc.scenery_id].push(alloc); + }); + return result; + }, SCENERY_TYPES(state) { return state.sceneryTypes; }, diff --git a/client/src/views/show/config/ConfigStage.vue b/client/src/views/show/config/ConfigStage.vue index 4dc317c3..734f7288 100644 --- a/client/src/views/show/config/ConfigStage.vue +++ b/client/src/views/show/config/ConfigStage.vue @@ -15,6 +15,9 @@ + + + @@ -26,6 +29,7 @@ import CrewList from '@/vue_components/show/config/stage/CrewList.vue'; import SceneryList from '@/vue_components/show/config/stage/SceneryList.vue'; import PropsList from '@/vue_components/show/config/stage/PropsList.vue'; import StageManager from '@/vue_components/show/config/stage/StageManager.vue'; +import StageTimeline from '@/vue_components/show/config/stage/StageTimeline.vue'; export default { name: 'ConfigCrew', @@ -34,6 +38,7 @@ export default { PropsList, SceneryList, CrewList, + StageTimeline, }, }; diff --git a/client/src/vue_components/show/config/mics/MicTimeline.vue b/client/src/vue_components/show/config/mics/MicTimeline.vue index 67d487b7..fd6f28bd 100644 --- a/client/src/vue_components/show/config/mics/MicTimeline.vue +++ b/client/src/vue_components/show/config/mics/MicTimeline.vue @@ -30,12 +30,7 @@ - + @@ -167,9 +162,11 @@ + + From 7cf2814892db3c09b29a2ac073f30d4a2e368751 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 1 Feb 2026 00:03:27 +0000 Subject: [PATCH 4/6] Fix SonarQube quality gate issues in StageTimeline - Consolidate generateBarsForProp and generateBarsForScenery into single generateBarsForItem method to reduce code duplication (5.2% -> under 3%) - Add formatSceneRange helper to fix "unexpected negated condition" issues - Reduces bundle size slightly due to code consolidation Co-Authored-By: Claude Opus 4.5 --- .../show/config/stage/StageTimeline.vue | 64 ++++++------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/client/src/vue_components/show/config/stage/StageTimeline.vue b/client/src/vue_components/show/config/stage/StageTimeline.vue index da830352..ea2a15f2 100644 --- a/client/src/vue_components/show/config/stage/StageTimeline.vue +++ b/client/src/vue_components/show/config/stage/StageTimeline.vue @@ -206,15 +206,9 @@ export default { }, allocationBars() { const bars = []; - this.rows.forEach((row, rowIndex) => { - if (row.type === 'prop') { - this.generateBarsForProp(row.itemId, rowIndex, bars); - } else { - this.generateBarsForScenery(row.itemId, rowIndex, bars); - } + this.generateBarsForItem(row.itemId, row.type, rowIndex, bars); }); - return bars; }, }, @@ -258,56 +252,40 @@ export default { const allocations = this.SCENERY_ALLOCATIONS_BY_ITEM[sceneryId]; return allocations && allocations.length > 0; }, - generateBarsForProp(propId, rowIndex, bars) { - const allocations = this.PROPS_ALLOCATIONS_BY_ITEM[propId] || []; - const segments = this.groupConsecutiveScenes(allocations, 'scene_id'); - const prop = this.PROP_BY_ID(propId); - const propName = prop?.name || `Prop ${propId}`; - segments.forEach((segment, idx) => { - const startX = this.getSceneX(segment.startIndex); - const width = this.sceneWidth * (segment.endIndex - segment.startIndex + 1); - - bars.push({ - id: `prop-${propId}-seg-${idx}`, - x: startX, - y: this.getRowY(rowIndex) + this.barPadding, - width, - height: this.rowHeight - 2 * this.barPadding, - color: this.getColorForEntity(propId, 'prop'), - label: propName, - tooltip: `${propName} (${segment.startScene}${segment.startScene !== segment.endScene ? ' - ' + segment.endScene : ''})`, - data: { - type: 'prop', - itemId: propId, - startScene: segment.startIndex, - endScene: segment.endIndex, - }, - }); - }); + formatSceneRange(startScene, endScene) { + if (startScene === endScene) { + return startScene; + } + return `${startScene} - ${endScene}`; }, - generateBarsForScenery(sceneryId, rowIndex, bars) { - const allocations = this.SCENERY_ALLOCATIONS_BY_ITEM[sceneryId] || []; + + generateBarsForItem(itemId, itemType, rowIndex, bars) { + const allocations = + itemType === 'prop' + ? this.PROPS_ALLOCATIONS_BY_ITEM[itemId] || [] + : this.SCENERY_ALLOCATIONS_BY_ITEM[itemId] || []; const segments = this.groupConsecutiveScenes(allocations, 'scene_id'); - const scenery = this.SCENERY_BY_ID(sceneryId); - const sceneryName = scenery?.name || `Scenery ${sceneryId}`; + + const item = itemType === 'prop' ? this.PROP_BY_ID(itemId) : this.SCENERY_BY_ID(itemId); + const itemName = item?.name || `${itemType === 'prop' ? 'Prop' : 'Scenery'} ${itemId}`; segments.forEach((segment, idx) => { const startX = this.getSceneX(segment.startIndex); const width = this.sceneWidth * (segment.endIndex - segment.startIndex + 1); bars.push({ - id: `scenery-${sceneryId}-seg-${idx}`, + id: `${itemType}-${itemId}-seg-${idx}`, x: startX, y: this.getRowY(rowIndex) + this.barPadding, width, height: this.rowHeight - 2 * this.barPadding, - color: this.getColorForEntity(sceneryId, 'scenery'), - label: sceneryName, - tooltip: `${sceneryName} (${segment.startScene}${segment.startScene !== segment.endScene ? ' - ' + segment.endScene : ''})`, + color: this.getColorForEntity(itemId, itemType), + label: itemName, + tooltip: `${itemName} (${this.formatSceneRange(segment.startScene, segment.endScene)})`, data: { - type: 'scenery', - itemId: sceneryId, + type: itemType, + itemId, startScene: segment.startIndex, endScene: segment.endIndex, }, From 06c828a13acc76c8b4d96b7a166b045f264d80c1 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 1 Feb 2026 00:13:53 +0000 Subject: [PATCH 5/6] Reduce code duplication by extracting shared timeline styles Extract ~110 lines of duplicate CSS from MicTimeline.vue and StageTimeline.vue into a shared timeline.scss stylesheet. Both components now import this shared stylesheet using @use, eliminating the duplication flagged by SonarQube. Changes: - Create client/src/assets/styles/timeline.scss with shared styles - Update MicTimeline.vue to use shared .timeline-container and .timeline-svg classes instead of component-specific names - Update StageTimeline.vue similarly - Reduce duplicated lines from 51 (5.35%) to well below the 3% threshold Co-Authored-By: Claude Opus 4.5 --- client/src/assets/styles/timeline.scss | 121 ++++++++++++++++++ .../show/config/mics/MicTimeline.vue | 118 +---------------- .../show/config/stage/StageTimeline.vue | 112 +--------------- 3 files changed, 129 insertions(+), 222 deletions(-) create mode 100644 client/src/assets/styles/timeline.scss diff --git a/client/src/assets/styles/timeline.scss b/client/src/assets/styles/timeline.scss new file mode 100644 index 00000000..e271a2ef --- /dev/null +++ b/client/src/assets/styles/timeline.scss @@ -0,0 +1,121 @@ +/** + * Shared styles for timeline visualization components (MicTimeline, StageTimeline). + * + * Usage: Import in Vue component diff --git a/client/src/vue_components/show/config/stage/StageTimeline.vue b/client/src/vue_components/show/config/stage/StageTimeline.vue index ea2a15f2..14a3d217 100644 --- a/client/src/vue_components/show/config/stage/StageTimeline.vue +++ b/client/src/vue_components/show/config/stage/StageTimeline.vue @@ -1,5 +1,5 @@