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:
2026-05-09 01:39:14 +02:00
parent 0a533a65fd
commit 36525e729a
7 changed files with 466 additions and 0 deletions

View 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 },
])
}