Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions client/src/js/blockOrphanUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Block computation and orphan detection utilities for stage crew assignments.
*
* A "block" is a consecutive sequence of scenes (within an act) where an item
* is allocated. The first scene is the SET boundary; the last is the STRIKE
* boundary. These pure functions mirror the backend logic in
* server/utils/show/block_computation.py.
*/

/**
* Compute allocation blocks from ordered scenes and a set of allocated scene IDs.
*
* Blocks break at act boundaries and allocation gaps.
*
* @param {Array<{id: number, act: number}>} orderedScenes - Scenes in display order
* @param {Set<number>} allocatedSceneIds - Scene IDs where the item is allocated
* @returns {Array<{actId: number, sceneIds: number[], setSceneId: number, strikeSceneId: number}>}
*/
export function computeBlocks(orderedScenes, allocatedSceneIds) {
if (
!orderedScenes ||
orderedScenes.length === 0 ||
!allocatedSceneIds ||
allocatedSceneIds.size === 0
) {
return [];
}

const blocks = [];
let currentBlockScenes = [];
let currentActId = null;

for (const scene of orderedScenes) {
// Act boundary breaks the current block
if (currentActId !== null && scene.act !== currentActId) {
if (currentBlockScenes.length > 0) {
blocks.push({
actId: currentActId,
sceneIds: [...currentBlockScenes],
setSceneId: currentBlockScenes[0],
strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1],
});
currentBlockScenes = [];
}
}
currentActId = scene.act;

if (allocatedSceneIds.has(scene.id)) {
currentBlockScenes.push(scene.id);
} else if (currentBlockScenes.length > 0) {
blocks.push({
actId: currentActId,
sceneIds: [...currentBlockScenes],
setSceneId: currentBlockScenes[0],
strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1],
});
currentBlockScenes = [];
}
}

// Flush last block
if (currentBlockScenes.length > 0) {
blocks.push({
actId: currentActId,
sceneIds: [...currentBlockScenes],
setSceneId: currentBlockScenes[0],
strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1],
});
}

return blocks;
}

/**
* Find crew assignments that would become orphaned by adding or removing
* a scene allocation.
*
* @param {Object} params
* @param {Array<{id: number, act: number}>} params.orderedScenes - All scenes in order
* @param {Array<{scene_id: number}>} params.currentAllocations - Current allocations for the item
* @param {Array<{id: number, scene_id: number, assignment_type: string, crew_id: number}>} params.crewAssignments - Current crew assignments for the item
* @param {'add'|'remove'} params.changeType - Whether a scene allocation is being added or removed
* @param {number} params.changeSceneId - The scene ID being added/removed
* @returns {Array<{id: number, scene_id: number, assignment_type: string, crew_id: number}>} Orphaned assignments
*/
export function findOrphanedAssignments({
orderedScenes,
currentAllocations,
crewAssignments,
changeType,
changeSceneId,
}) {
if (!crewAssignments || crewAssignments.length === 0) {
return [];
}

// Build current allocated set
const currentSet = new Set(currentAllocations.map((a) => a.scene_id));

// Simulate the change
const newSet = new Set(currentSet);
if (changeType === 'add') {
newSet.add(changeSceneId);
} else if (changeType === 'remove') {
newSet.delete(changeSceneId);
}

// Compute blocks before and after
const oldBlocks = computeBlocks(orderedScenes, currentSet);
const newBlocks = computeBlocks(orderedScenes, newSet);

// Build valid boundary sets from new blocks
const validSetScenes = new Set(newBlocks.map((b) => b.setSceneId));
const validStrikeScenes = new Set(newBlocks.map((b) => b.strikeSceneId));

// Also check which assignments were valid before — only flag ones that
// become invalid (were on a valid boundary before, but aren't after)
const oldValidSetScenes = new Set(oldBlocks.map((b) => b.setSceneId));
const oldValidStrikeScenes = new Set(oldBlocks.map((b) => b.strikeSceneId));

return crewAssignments.filter((assignment) => {
if (assignment.assignment_type === 'set') {
const wasValid = oldValidSetScenes.has(assignment.scene_id);
const isValid = validSetScenes.has(assignment.scene_id);
return wasValid && !isValid;
} else if (assignment.assignment_type === 'strike') {
const wasValid = oldValidStrikeScenes.has(assignment.scene_id);
const isValid = validStrikeScenes.has(assignment.scene_id);
return wasValid && !isValid;
}
return false;
});
}
241 changes: 241 additions & 0 deletions client/src/js/blockOrphanUtils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import { computeBlocks, findOrphanedAssignments } from './blockOrphanUtils';

describe('blockOrphanUtils', () => {
describe('computeBlocks', () => {
it('returns empty array for empty inputs', () => {
expect(computeBlocks([], new Set())).toEqual([]);
expect(computeBlocks([], new Set([1]))).toEqual([]);
expect(computeBlocks(null, null)).toEqual([]);
});

it('returns single block for one allocated scene', () => {
const scenes = [{ id: 1, act: 1 }];
const result = computeBlocks(scenes, new Set([1]));
expect(result).toEqual([{ actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 }]);
});

it('returns one block for consecutive scenes in one act', () => {
const scenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 1 },
];
const result = computeBlocks(scenes, new Set([1, 2, 3]));
expect(result).toEqual([{ actId: 1, sceneIds: [1, 2, 3], setSceneId: 1, strikeSceneId: 3 }]);
});

it('splits into two blocks when there is a gap in the middle', () => {
const scenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 1 },
{ id: 4, act: 1 },
];
const result = computeBlocks(scenes, new Set([1, 2, 4]));
expect(result).toEqual([
{ actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 },
{ actId: 1, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 },
]);
});

it('breaks blocks at act boundaries even for consecutive scenes', () => {
const scenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 2 },
{ id: 4, act: 2 },
];
const result = computeBlocks(scenes, new Set([1, 2, 3, 4]));
expect(result).toEqual([
{ actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 },
{ actId: 2, sceneIds: [3, 4], setSceneId: 3, strikeSceneId: 4 },
]);
});

it('handles multiple acts with multiple blocks each', () => {
const scenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 1 },
{ id: 4, act: 2 },
{ id: 5, act: 2 },
{ id: 6, act: 2 },
];
// Allocated: 1, 3 (act 1 gap), 4, 6 (act 2 gap)
const result = computeBlocks(scenes, new Set([1, 3, 4, 6]));
expect(result).toEqual([
{ actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 },
{ actId: 1, sceneIds: [3], setSceneId: 3, strikeSceneId: 3 },
{ actId: 2, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 },
{ actId: 2, sceneIds: [6], setSceneId: 6, strikeSceneId: 6 },
]);
});

it('ignores scenes not in the allocated set', () => {
const scenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 1 },
];
const result = computeBlocks(scenes, new Set([2]));
expect(result).toEqual([{ actId: 1, sceneIds: [2], setSceneId: 2, strikeSceneId: 2 }]);
});
});

describe('findOrphanedAssignments', () => {
// Reusable test data
const orderedScenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 1 },
{ id: 4, act: 1 },
];

it('returns empty array when there are no crew assignments', () => {
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }],
crewAssignments: [],
changeType: 'remove',
changeSceneId: 1,
});
expect(result).toEqual([]);
});

it('returns empty when boundary does not change', () => {
// Block is scenes 1-3, remove scene 2 (middle) → boundaries stay at 1 and 3
// Actually removing middle splits block: [1] and [3], boundaries change!
// Use: remove scene 2 from [1,2,3] → [1] block (set=1,strike=1) + [3] (set=3,strike=3)
// Original: [1,2,3] → set=1, strike=3
// So boundaries DO change for strike. Let's use a case where they don't change.
// Add scene 2 to [1,3] → no boundary change (set=1, strike=3 in both)
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 3 }],
crewAssignments: [
{ id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 },
{ id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 },
],
changeType: 'add',
changeSceneId: 2,
});
expect(result).toEqual([]);
});

it('orphans SET assignments when block start is removed', () => {
// Block [1,2,3] → remove scene 1 → block becomes [2,3], SET moves to 2
const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 };
const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 };
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }],
crewAssignments: [setAssignment, strikeAssignment],
changeType: 'remove',
changeSceneId: 1,
});
expect(result).toEqual([setAssignment]);
});

it('orphans STRIKE assignments when block end is removed', () => {
// Block [1,2,3] → remove scene 3 → block becomes [1,2], STRIKE moves to 2
const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 };
const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 };
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }],
crewAssignments: [setAssignment, strikeAssignment],
changeType: 'remove',
changeSceneId: 3,
});
expect(result).toEqual([strikeAssignment]);
});

it('orphans both SET and STRIKE when a single-scene block is removed', () => {
// Block [2] only → remove scene 2 → block disappears
const setAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 };
const strikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 };
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 2 }],
crewAssignments: [setAssignment, strikeAssignment],
changeType: 'remove',
changeSceneId: 2,
});
expect(result).toContainEqual(setAssignment);
expect(result).toContainEqual(strikeAssignment);
expect(result).toHaveLength(2);
});

it('does not orphan when removing a middle scene (split keeps original boundaries)', () => {
// Block [1,2,3] → remove scene 2 → blocks [1] and [3]
// SET scene 1 stays valid (set of block [1]), STRIKE scene 3 stays valid (strike of block [3])
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }],
crewAssignments: [
{ id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 },
{ id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 },
],
changeType: 'remove',
changeSceneId: 2,
});
expect(result).toEqual([]);
});

it('orphans old SET when adding a scene before block start', () => {
// Block [2,3] → add scene 1 → block becomes [1,2,3], SET moves to 1
const oldSetAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 };
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 2 }, { scene_id: 3 }],
crewAssignments: [
oldSetAssignment,
{ id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 },
],
changeType: 'add',
changeSceneId: 1,
});
expect(result).toEqual([oldSetAssignment]);
});

it('orphans old STRIKE when adding a scene after block end', () => {
// Block [1,2] → add scene 3 → block becomes [1,2,3], STRIKE moves to 3
const oldStrikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 };
const result = findOrphanedAssignments({
orderedScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }],
crewAssignments: [
{ id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 },
oldStrikeAssignment,
],
changeType: 'add',
changeSceneId: 3,
});
expect(result).toEqual([oldStrikeAssignment]);
});

it('does not affect assignments in a different act', () => {
const multiActScenes = [
{ id: 1, act: 1 },
{ id: 2, act: 1 },
{ id: 3, act: 2 },
{ id: 4, act: 2 },
];
// Act 1 block [1,2], Act 2 block [3,4]
// Remove scene 1 (act 1 boundary change) → Act 2 should be unaffected
const act1Set = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 };
const act2Set = { id: 20, scene_id: 3, assignment_type: 'set', crew_id: 1 };
const act2Strike = { id: 21, scene_id: 4, assignment_type: 'strike', crew_id: 2 };
const result = findOrphanedAssignments({
orderedScenes: multiActScenes,
currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }, { scene_id: 4 }],
crewAssignments: [act1Set, act2Set, act2Strike],
changeType: 'remove',
changeSceneId: 1,
});
// Only act1 SET is orphaned
expect(result).toEqual([act1Set]);
});
});
});
12 changes: 10 additions & 2 deletions client/src/mixins/timelineMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,19 @@ export default {
const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id);

if (hasAllocation) {
if (currentSegment) {
// Extend current segment
const sameAct = currentSegment
? scene.act === this.scenes[currentSegment.startIndex].act
: true;

if (currentSegment && sameAct) {
// Extend current segment within same act
currentSegment.endIndex = sceneIndex;
currentSegment.endScene = scene.name;
} else {
// Close previous segment if act boundary crossed
if (currentSegment) {
segments.push(currentSegment);
}
// Start new segment
currentSegment = {
startIndex: sceneIndex,
Expand Down
Loading
Loading