Files
crewli/apps/app/src/lib/timetable/time-grid.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

71 lines
2.1 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.
/**
* 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
}