From 36525e729a951045d3cf29ed6d5d9d22fc04355e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:39:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(timetable):=20pure=20logic=20ports=20?= =?UTF-8?q?=E2=80=94=20snap,=20lane,=20conflict,=20b2b,=20capacity,=20time?= =?UTF-8?q?-grid=20(Session=204=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/app/src/lib/timetable/b2b.ts | 75 ++++++++++++ apps/app/src/lib/timetable/capacity.ts | 57 +++++++++ apps/app/src/lib/timetable/conflict.ts | 84 ++++++++++++++ apps/app/src/lib/timetable/index.ts | 6 + apps/app/src/lib/timetable/lane.ts | 147 ++++++++++++++++++++++++ apps/app/src/lib/timetable/snap.ts | 27 +++++ apps/app/src/lib/timetable/time-grid.ts | 70 +++++++++++ 7 files changed, 466 insertions(+) create mode 100644 apps/app/src/lib/timetable/b2b.ts create mode 100644 apps/app/src/lib/timetable/capacity.ts create mode 100644 apps/app/src/lib/timetable/conflict.ts create mode 100644 apps/app/src/lib/timetable/index.ts create mode 100644 apps/app/src/lib/timetable/lane.ts create mode 100644 apps/app/src/lib/timetable/snap.ts create mode 100644 apps/app/src/lib/timetable/time-grid.ts diff --git a/apps/app/src/lib/timetable/b2b.ts b/apps/app/src/lib/timetable/b2b.ts new file mode 100644 index 00000000..54619596 --- /dev/null +++ b/apps/app/src/lib/timetable/b2b.ts @@ -0,0 +1,75 @@ +import type { Performance } from '@/types/timetable' +import { ArtistEngagementStatus } from '@/types/timetable' + +/** + * RFC v0.2 D26 — back-to-back marker rule. Two consecutive non-cancelled + * performances on the same stage with `p2.start_at - p1.end_at ∈ [0, 3]` + * minutes get a B2B marker. + * + * (RFC text says "≤5 min" in D6/D25 but the sprint-4 prompt's component + * spec calls for the prototype's stricter 3-minute threshold. Exposed as + * a constant so the threshold is one place.) + */ +export const B2B_THRESHOLD_MIN = 3 + +export interface B2BLink { + leftId: string + rightId: string + gapMin: number +} + +export function findB2BLinks(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): B2BLink[] { + const links: B2BLink[] = [] + const groups = new Map() + + for (const p of performances) { + if (isCancelled(p)) + continue + if (p.stage_id === null || p.start_at === null || p.end_at === null) + continue + + // B2B is per-lane (changeover relevant within a lane). + const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}` + const list = groups.get(key) ?? [] + + list.push(p) + groups.set(key, list) + } + + for (const list of groups.values()) { + if (list.length < 2) + continue + const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!)) + for (let i = 0; i < sorted.length - 1; i++) { + const left = sorted[i] + const right = sorted[i + 1] + const gapMs = Date.parse(right.start_at!) - Date.parse(left.end_at!) + const gapMin = gapMs / 60_000 + if (gapMin >= 0 && gapMin <= thresholdMin) + links.push({ leftId: left.id, rightId: right.id, gapMin }) + } + } + + return links +} + +/** + * Convenience: returns two sets — performance IDs that have a B2B link + * to the LEFT (earlier neighbour ends close), and to the RIGHT + * (later neighbour starts close). Drives the dot rendering on the block. + */ +export function findB2BSides(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): { leftSet: Set; rightSet: Set } { + const links = findB2BLinks(performances, thresholdMin) + const leftSet = new Set() + const rightSet = new Set() + for (const link of links) { + rightSet.add(link.leftId) + leftSet.add(link.rightId) + } + + return { leftSet, rightSet } +} + +function isCancelled(p: Performance): boolean { + return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED +} diff --git a/apps/app/src/lib/timetable/capacity.ts b/apps/app/src/lib/timetable/capacity.ts new file mode 100644 index 00000000..25c98539 --- /dev/null +++ b/apps/app/src/lib/timetable/capacity.ts @@ -0,0 +1,57 @@ +import type { ArtistEngagement, Performance, Stage } from '@/types/timetable' + +/** + * RFC v0.2 D25 — capacity warning when expected attendance exceeds + * the stage capacity by more than the tolerance (defaults to 10%). + * + * Inputs: + * `stage.capacity` (nullable; null = no constraint, no warn) + * `engagement.crew_count + engagement.guests_count` if present + * else `artist.default_draw` if present + * + * Returns null if no warning applies (incl. missing data). + */ +export const CAPACITY_TOLERANCE = 1.1 + +export type CapacityLevel = 'warn' | 'critical' + +export interface CapacityState { + level: CapacityLevel + expected: number + capacity: number + ratio: number +} + +export function evaluateCapacity( + performance: Performance, + stage: Stage | null | undefined, + engagement: ArtistEngagement | null | undefined, +): CapacityState | null { + if (!stage || stage.capacity === null || stage.capacity <= 0) + return null + + const expected = expectedAttendance(performance, engagement) + if (expected === null) + return null + + const ratio = expected / stage.capacity + if (ratio <= CAPACITY_TOLERANCE) + return null + + return { + level: ratio > 1.5 ? 'critical' : 'warn', + expected, + capacity: stage.capacity, + ratio, + } +} + +function expectedAttendance(_p: Performance, e: ArtistEngagement | null | undefined): number | null { + if (!e) + return null + const crewGuests = (e.crew_count ?? 0) + (e.guests_count ?? 0) + if (crewGuests > 0) + return crewGuests + + return e.artist?.default_draw ?? null +} diff --git a/apps/app/src/lib/timetable/conflict.ts b/apps/app/src/lib/timetable/conflict.ts new file mode 100644 index 00000000..b97f9460 --- /dev/null +++ b/apps/app/src/lib/timetable/conflict.ts @@ -0,0 +1,84 @@ +import type { Performance } from '@/types/timetable' +import { ArtistEngagementStatus } from '@/types/timetable' + +/** + * Same-stage, same-event, same-lane time overlap = conflict (RFC D5). + * + * Two intervals overlap iff `a.start < b.end && b.start < a.end`. Touching + * at endpoints (a.end === b.start) is NOT overlap. Cancelled engagements + * never participate. + * + * Returns the set of performance IDs involved in at least one conflict. + */ +export function findConflicts(performances: Performance[]): Set { + const conflicts = new Set() + const groups = new Map() + + for (const p of performances) { + if (isCancelled(p)) + continue + if (p.stage_id === null || p.start_at === null || p.end_at === null) + continue + const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}` + const list = groups.get(key) ?? [] + + list.push(p) + groups.set(key, list) + } + + for (const list of groups.values()) { + if (list.length < 2) + continue + const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!)) + for (let i = 0; i < sorted.length; i++) { + const ai = sorted[i] + const aEnd = Date.parse(ai.end_at!) + for (let j = i + 1; j < sorted.length; j++) { + const bj = sorted[j] + const bStart = Date.parse(bj.start_at!) + if (bStart >= aEnd) + break + + // bStart < aEnd → overlap (b.start < a.end && a.start < b.end is + // already guaranteed since sorted by start ascending). + conflicts.add(ai.id) + conflicts.add(bj.id) + } + } + } + + return conflicts +} + +/** + * True if two performances would conflict if placed at the given placement. + * Used in the drag-preview path to surface a conflict ring before commit. + */ +export function wouldConflict( + candidate: { id: string; stage_id: string | null; lane: number; start_at: string; end_at: string }, + others: Performance[], +): boolean { + if (candidate.stage_id === null) + return false + const cs = Date.parse(candidate.start_at) + const ce = Date.parse(candidate.end_at) + + for (const o of others) { + if (o.id === candidate.id || isCancelled(o)) + continue + if (o.stage_id !== candidate.stage_id || o.lane_resolved !== candidate.lane) + continue + if (o.start_at === null || o.end_at === null) + continue + const os = Date.parse(o.start_at) + const oe = Date.parse(o.end_at) + if (cs < oe && os < ce) + return true + } + + return false +} + +function isCancelled(p: Performance): boolean { + return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED +} diff --git a/apps/app/src/lib/timetable/index.ts b/apps/app/src/lib/timetable/index.ts new file mode 100644 index 00000000..9164ff3e --- /dev/null +++ b/apps/app/src/lib/timetable/index.ts @@ -0,0 +1,6 @@ +export * from './snap' +export * from './time-grid' +export * from './conflict' +export * from './b2b' +export * from './capacity' +export * from './lane' diff --git a/apps/app/src/lib/timetable/lane.ts b/apps/app/src/lib/timetable/lane.ts new file mode 100644 index 00000000..db56a2bf --- /dev/null +++ b/apps/app/src/lib/timetable/lane.ts @@ -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 + + /** 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 }, + ]) +} diff --git a/apps/app/src/lib/timetable/snap.ts b/apps/app/src/lib/timetable/snap.ts new file mode 100644 index 00000000..e7c4fdb8 --- /dev/null +++ b/apps/app/src/lib/timetable/snap.ts @@ -0,0 +1,27 @@ +/** + * Round a value to the nearest multiple of `step`. + * + * RFC-TIMETABLE v0.2 D7 — drag/resize snap is 5 minutes for v1 + * (the prototype audit §2.11 used 15; v1 RFC §D20 chose 5 to give the + * keyboard nudges finer control). Same primitive is reused for + * pixel-precision snaps via the pxPerMin coefficient at the call site. + */ +export function snap(value: number, step: number): number { + if (step <= 0) + return value + + return Math.round(value / step) * step +} + +/** The grid snap interval in minutes. RFC D7 + D20. */ +export const SNAP_MIN = 5 + +/** Minimum performance duration in minutes. Matches prototype audit §2.17. */ +export const MIN_DURATION_MIN = 15 + +/** + * Snap a minute count, then clamp into [min, max]. + */ +export function snapClamp(value: number, step: number, min: number, max: number): number { + return Math.max(min, Math.min(max, snap(value, step))) +} diff --git a/apps/app/src/lib/timetable/time-grid.ts b/apps/app/src/lib/timetable/time-grid.ts new file mode 100644 index 00000000..917c101e --- /dev/null +++ b/apps/app/src/lib/timetable/time-grid.ts @@ -0,0 +1,70 @@ +/** + * Pixel ↔ time conversions for the timetable canvas. + * + * The grid is anchored on a CarbonImmutable `gridStart` (typically + * the sub-event's start_at, possibly extended to the previous hour + * boundary). All other coordinates are derived from minutes-since- + * `gridStart` × `pxPerMin`. + * + * Pure functions — no DOM access. Input is ISO-8601 strings or epoch ms; + * the grid origin is captured once per render in the page entry. + */ + +export interface TimeGridConfig { + + /** Minute 0 of the canvas, as ISO-8601 (the sub-event start anchor). */ + gridStartIso: string + + /** Total minutes spanned by the canvas (e.g. 13h × 60 = 780). */ + totalMinutes: number + + /** Horizontal pixels per minute (drives stretch/zoom). */ + pxPerMin: number +} + +export function isoToMinutes(iso: string, gridStartIso: string): number { + const start = Date.parse(gridStartIso) + const at = Date.parse(iso) + + return Math.round((at - start) / 60_000) +} + +export function minutesToIso(minutes: number, gridStartIso: string): string { + const start = Date.parse(gridStartIso) + + return new Date(start + minutes * 60_000).toISOString() +} + +export function minutesToPx(minutes: number, pxPerMin: number): number { + return minutes * pxPerMin +} + +export function pxToMinutes(px: number, pxPerMin: number): number { + if (pxPerMin <= 0) + return 0 + + return px / pxPerMin +} + +/** + * The wall-clock label for a tick at `minute`. RFC v0.2 §D21 defaults + * to a 24h HH:MM format in nl-NL locale; days-spanning canvases + * preserve the wrap (00:00 + minutes since previous midnight). + */ +export function formatTickLabel(minute: number, gridStartIso: string): string { + const iso = minutesToIso(minute, gridStartIso) + const date = new Date(iso) + + return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false }) +} + +/** + * Generate evenly-spaced tick positions in [0, totalMinutes] at `intervalMin`. + */ +export function generateTicks(totalMinutes: number, intervalMin: number): number[] { + const ticks: number[] = [] + for (let m = 0; m <= totalMinutes; m += intervalMin) + ticks.push(m) + + return ticks +}