Files
crewli/apps/app/src/lib/timetable/lane.ts
bert.hausmans 36525e729a feat(timetable): pure logic ports — snap, lane, conflict, b2b, capacity, time-grid (Session 4 step 2)
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>
2026-05-09 01:39:14 +02:00

148 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 },
])
}