Ports the prototype's helpers.js + cascade-bump algorithm into typed
TypeScript modules in apps/app/src/lib/timetable/:
- snap.ts — 5-minute snap (RFC D7) + 15-min minimum duration
- time-grid.ts — pixel ↔ minute ↔ ISO-8601 coordinate conversions
- conflict.ts — same-stage same-lane overlap detection (RFC D5)
- b2b.ts — back-to-back marker links, 3-min threshold (RFC D26)
- capacity.ts — 110% over-capacity warn level (RFC D25)
- lane.ts — two-pass resolver + drag-preview cascade (D13/D18/D19,
client-side preview only; server is authoritative)
All functions are pure (no Vue, no DOM). Tested in Phase C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
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<string, number>
|
||
|
||
/** Width of the stage row in lanes (max lane + 1, min 1). */
|
||
laneCount: number
|
||
}
|
||
|
||
export function resolveLanes(items: LaneSubject[]): LaneAssignment {
|
||
const laneOf: Record<string, number> = {}
|
||
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 },
|
||
])
|
||
}
|