import type { Performance } from '@/types/timetable' /** * Client-side preview of the server's lane resolution (RFC v0.2 D13/D19). * Server is authoritative; this implementation runs only during drag so * the user sees the eventual lane assignment before the PATCH lands. * * Two-pass placement (mirrors the LaneResolver service in the backend): * * Pass 1 — items with explicit `lane` go to that lane (bumped down on * time-overlap). Sorted by lane asc then start asc. * Pass 2 — items without explicit lane go to the lowest free lane. * Sorted by start asc, ties broken by id. * * The cohort is "every performance on the same stage in the same event". * Cancelled engagements are excluded from collision checks. */ export interface LaneSubject { id: string /** Raw persisted lane (or `null` for auto-pack candidates). */ lane: number | null start_at: string end_at: string /** True if this performance should be excluded from conflict checks. */ cancelled?: boolean } export interface LaneAssignment { /** Map of perf id → resolved lane (0-indexed). */ laneOf: Record /** Width of the stage row in lanes (max lane + 1, min 1). */ laneCount: number } export function resolveLanes(items: LaneSubject[]): LaneAssignment { const laneOf: Record = {} let maxLane = 0 // Sort once for deterministic tie-breaking. const sorted = items.slice().sort((a, b) => { const sa = Date.parse(a.start_at) const sb = Date.parse(b.start_at) if (sa !== sb) return sa - sb return a.id.localeCompare(b.id) }) const overlapsAt = (it: LaneSubject, lane: number): boolean => { if (it.cancelled) return false const itStart = Date.parse(it.start_at) const itEnd = Date.parse(it.end_at) for (const o of sorted) { if (o.id === it.id) continue if (o.cancelled) continue if (laneOf[o.id] !== lane) continue const oStart = Date.parse(o.start_at) const oEnd = Date.parse(o.end_at) if (oStart < itEnd && itStart < oEnd) return true } return false } // Pass 1 — explicit lanes, sorted by requested lane asc then start asc. const explicit = sorted .filter(i => Number.isInteger(i.lane)) .sort((a, b) => { if (a.lane !== b.lane) return (a.lane as number) - (b.lane as number) return Date.parse(a.start_at) - Date.parse(b.start_at) }) for (const it of explicit) { let lane = Math.max(0, it.lane as number) while (overlapsAt(it, lane)) lane++ laneOf[it.id] = lane if (lane > maxLane) maxLane = lane } // Pass 2 — implicit lanes, lowest free. for (const it of sorted) { if (it.id in laneOf) continue let lane = 0 while (overlapsAt(it, lane)) lane++ laneOf[it.id] = lane if (lane > maxLane) maxLane = lane } return { laneOf, laneCount: maxLane + 1 } } /** * Drag-preview cascade: simulate dropping `dragged` at the candidate * (stage, lane, start, end) and return the resulting lane mapping * for the cohort. Server runs the same logic transactionally per RFC D18. * * Pure: never mutates inputs. The dragged item carries its desired lane * via `lane`; cohort items keep their persisted lane. */ export function previewCascade( dragged: LaneSubject, cohort: Performance[], ): LaneAssignment { const subjects: LaneSubject[] = cohort .filter(p => p.id !== dragged.id && p.start_at !== null && p.end_at !== null) .map(p => ({ id: p.id, lane: p.lane, start_at: p.start_at as string, end_at: p.end_at as string, cancelled: p.engagement?.booking_status?.value === 'cancelled', })) // Find an existing item that already occupies the dragged item's // requested lane × time slot — bump dragged to oLane + 1 BEFORE running // the resolver so cascade-bump triggers naturally. const wanted = dragged.lane ?? 0 const occupant = subjects.find(s => !s.cancelled && s.lane === wanted && Date.parse(s.start_at) < Date.parse(dragged.end_at) && Date.parse(s.end_at) > Date.parse(dragged.start_at), ) const finalLane = occupant ? wanted + 1 : wanted return resolveLanes([ ...subjects, { ...dragged, lane: finalLane }, ]) }