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:
70
apps/app/src/lib/timetable/time-grid.ts
Normal file
70
apps/app/src/lib/timetable/time-grid.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user