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>
This commit is contained in:
147
apps/app/src/lib/timetable/lane.ts
Normal file
147
apps/app/src/lib/timetable/lane.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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 },
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user