Files
crewli/dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md
bert.hausmans a57437a4b7 audit(timetable): complete prototype audit for RFC v0.2
Capture inventory, data model, component architecture, interaction
patterns, pure logic algorithms (with verbatim excerpts), design tokens,
and 20 RFC v0.2 observations from the standalone React prototype at
resources/Crewli - Artist  Timetable Management/.

Read-only audit; no prototype files modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:04:00 +02:00

63 KiB
Raw Permalink Blame History

title, status, audited-at, audited-by, prototype-source, follows-up, feeds-into
title status audited-at audited-by prototype-source follows-up feeds-into
Artist Timetable Prototype Audit complete 2026-05-08 Claude Code ./resources/Crewli - Artist Timetable Management/ dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md (v0.1) dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md (v0.2 — to be written)

Artist Timetable Prototype Audit

Read-only audit of the standalone React+Babel prototype shipped in ./resources/Crewli - Artist Timetable Management/. The prototype is a single-page demo (no build, no backend, in-memory state) that explores the UX/UI for the Artist Timetable module. This document captures what is in the prototype so RFC-TIMETABLE v0.2 can adopt, adapt, or reject each piece deliberately.


§1 File inventory

The prototype is loaded by a single HTML page that pulls React 18, ReactDOM, and Babel-standalone from unpkg, then loads five JSX/JS scripts in order.

Group File LOC One-line description
App entry Crewli Timetable.html 28 HTML shell — loads React 18 / Babel-standalone via unpkg, then data.js, helpers.js, and the four JSX files.
Data / fixtures data.js 159 window.CrewliData IIFE — EVENT, STAGES, STAGE_DAYS, ADVANCE_SECTIONS, ARTISTS, GENRES, PERFORMANCES, PARKED, PENDING, TIME constants.
Pure logic / lib helpers.js 144 window.CrewliHelpersSTATUS palette, fmtTime, snap, findConflicts, findB2B, isCapacityWarn, assignLanes, advanceCount.
Components timetable.jsx 1133 Block, TimeAxis, GridBg, StatusMultiSelect, ParkingColumn, Timetable. Owns lane assignment, cascade-bump, drag/resize/create logic.
Components popover.jsx 264 Popover (timetable block), QueuePopover (parking item), StatusDropdown, PopoverBody. Off-screen-safe pickPos.
Components modals.jsx 379 PerformanceModal (add/edit), StageEditor (create/edit/delete stage), LineupMatrix (stages×days bulk toggle), Backdrop, Field.
Components app.jsx 517 App root — owns all state via React.useState, dispatches reducer-style actions, renders Sidebar, Header, Timetable, popovers, modals, FooterToolbar, TweaksPanel.
Components (dev) tweaks-panel.jsx 759 Generic vendor-style live-tweak panel: zoom slider, density radio, advancing-% toggle. Not part of the module surface — design-time only.
Styles styles.css ~1000 Light-mode shell, navy sidebar, teal accent. CSS custom properties on :root. Status colours live in helpers.js, not CSS.
Docs docs/RFC-TIMETABLE - Artist Timetable Module.md 529 A copy of v0.1 RFC checked into the prototype folder. Identical to (intended) dev-docs/RFC-TIMETABLE-... v0.1.
Docs docs/timetable-module.md 470 Companion narrative doc — overlap with RFC, less authoritative.
Other .DS_Store, uploads/pasted-...png Mac filesystem cruft + a screenshot embedded for design context. Ignore.

The prototype has no node_modules, no package.json, no bundler config, no tests. State is in-memory and resets on reload.


§2 Data model

The prototype's authoritative data shapes live in data.js. Field names match Crewli SCHEMA.md §3.5.7 conventions where possible (snake_case, ULID-shaped string IDs prefixed by entity letter — s_, a_, p_, pk_, pa_, d_).

§2.1 EVENT

Field Type Sample Nullable Notes
id string "ezf_2026" no Free-form string in prototype; production uses ULID.
name string "Echt Zomer Feesten" no
edition string "2026" no Prototype-only convenience; not in SCHEMA.
sub_event_label string "dag" no i18n knob — header reads "Stages per {sub_event_label}".
days array<Day> no Inline pivot (no event_id FK in fixture; single-event PoC).

§2.2 Day (inline in EVENT.days)

Field Type Sample Nullable Notes
id string "d_fr" no
date ISO date "2026-07-10" no
label string "Vrijdag" no Long form.
short string "Vr" no Two-char form (unused in current UI; carried for future tabs).

§2.3 STAGES

Field Type Sample Nullable Notes
id string "s_hardstyle" no
name string "Hardstyle District" no
color string (hex) "#e85d75" no Drives left-edge swatch on stage row + popover header tint.
capacity int 4500 no Drives isCapacityWarn(artist, stage) = artist.draw > stage.capacity * 1.1.
__draft bool true yes Transient flag set by handleAddStage so StageEditor knows it is committing a brand-new row vs editing an existing one. Stripped in app.jsx:323 before dispatch.

§2.4 STAGE_DAYS (pivot)

Field Type Sample Notes
stage_id string FK "s_hardstyle"
day_id string FK "d_fr" Prototype joins to EVENT.days[].id, not date.

No PK column — pure pivot. Production schema (SCHEMA §3.5.7) uses (stage_id, day_date) with an int AI PK and unique constraint.

§2.5 ADVANCE_SECTIONS

Field Type Sample Notes
key string "tour" Map key, not ULID.
label string "Tourmanager" Displayed in popover checklist.

Closed list of 5 in prototype: tour, hosp, travel, flight, rider. In production these are advance_sections rows per artist (not a global list).

§2.6 GENRES

["Hardstyle", "Techno", "House", "Hollands", "Pop", "Urban", "Disco", "Aprés"]

A flat string array, used to (a) populate the genre <select> in PerformanceModal, (b) drive the genre-pill row in the Wachtrij filter, (c) label the inline genre on each block when wide enough.

§2.7 ARTISTS

Field Type Sample Nullable Notes
id string "a_1" no
name string "D-Block & S-te-Fan" no
initials string (2 ch) "DS" no Pre-computed for avatar circle. Modal regenerates on artist creation.
genre string "Hardstyle" no Free-form vs GENRES; modal restricts to the closed list.
draw int 4200 no Expected pull. Drives capacity-warn comparison.
advance object {tour:true, hosp:true, …} no One bool per ADVANCE_SECTIONS.key. Prototype uses this to compute the advancing %.

§2.8 PERFORMANCES

Field Type Sample Nullable Notes
id string "p_1" no
artist_id string FK "a_3" no
stage_id string FK | null "s_hardstyle" | null yes null means parked — the prototype uses the same shape for parked rows.
day_id string FK "d_fr" no
start int (minutes since 14:00 grid anchor) 240 (= 18:00) yes for parked Crucial: not minutes-since-midnight. The grid spans 14:00 → 03:00 next day, 780 min total.
end int (minutes since anchor) 360 (= 20:00) yes for parked
status enum "confirmed" no One of concept, requested, option, confirmed, contracted, cancelled.
lane int (≥0) 0, 1 yes Not in fixtures — appears only after a drag/drop assigns it explicitly via the cascade-bump algorithm. Items without lane get auto-packed by assignLanes.
dur int (minutes) 60 yes Only on parked items that lack start/end (e.g. wachtrij-only items created via "+ Performance"). Used as fallback width on first drop.

§2.9 PARKED

Same shape as PERFORMANCES but with stage_id: null and (sometimes) start/end retained from before parking. The prototype carries time data through the parked state so re-dropping restores roughly the prior duration.

§2.10 PENDING

Field Type Sample Notes
id string "pa_1"
artist_id string FK "a_6"
day_id string FK "d_fr"
requested_on ISO date "2026-04-12"
note string "Wachten op terugkoppeling agent"

Pending items have no status field of their own — the parking column synthesises a virtual "requested" status for them. The prototype explicitly comments this (timetable.jsx:220): "requested is mapped to pending items (they don't have an explicit status yet).". Promoting a pending status converts the pending row into a parked row (app.jsx:115124).

§2.11 TIME

Field Value Notes
startHour 14 Grid anchor — start=0 ⇔ 14:00; start=600 ⇔ 00:00.
totalMinutes 780 13-hour grid (14:00 → 03:00).
snapMinutes 15 Drag/resize snap step.
cellMinutes 30 Visual half-hour gridline interval.

§2.12 Lane semantics

The prototype's lane model is mixed: a small subset of items carry an explicit lane: int column (set by drag/drop), and everything else is lane-packed at render time by assignLanes (helpers.js:104). Lanes are 0-indexed and grow downward inside a stage row.

Mutation rule: when an item is dropped on a busy lane, the prototype writes the new lane explicitly onto the dragged item AND onto every conflicting sibling (cascade-bump), persisting them all via dispatch({ type: "update_perf", … }). Once the cascade settles, all participants in the chain have explicit lane values; their neighbours that did not conflict keep lane: undefined and continue to be auto-lane-packed.

§2.13 Status enum

Six values, ordered for display in dropdowns (popover.jsx:46):

concept → requested → option → confirmed → contracted → cancelled

This matches SCHEMA.md §3.5.7 artists.booking_status and RFC v0.1 D9 1:1. The prototype uses single-state status (no separate workflow guards) — any status can transition to any other, including ↔ cancelled.

§2.14 Wachtrij / parking semantics

The prototype uses three coexisting collections in app state:

  • performances — has stage_id != null, sits on the grid.
  • parked — has stage_id == null, sits in the wachtrij column.
  • pending — has neither stage nor times; only artist + day + note.

Transitions (all in app.jsx dispatch):

From To Trigger Action type
performances parked drag block onto wachtrij park_perf
parked performances drag wachtrij card onto canvas unpark_perf (auto-fired by app.jsx:211 post-update)
pending performances drag wachtrij card onto canvas (modal first) schedule_pending
pending parked change status in queue popover update_pending_status (synthesises a new parked id)
performances parked parent stage deleted delete_stage cascade (app.jsx:60)

§2.15 Multi-day handling

The prototype models the day as a direct FK column on every booking- related row (day_id). Filtering for the active tab is a simple p.day_id === activeDayId predicate. There is no derived-from-start_time logic. Cross-day moves are not supported by the drag handler — vertical drag only re-stages within the active day.

§2.16 Capacity / draw / advancing aggregate

  • Capacity warning: derived per-block on render via H.isCapacityWarn(artist, stage)artist.draw > stage.capacity * 1.1 (10 % over capacity tolerance). Shows the orange-triangle SVG icon on the block's row 1 warn cluster.
  • Advancing aggregate: derived per-block via H.advanceCount(artist, sections) returning { done, total }. Rendered in two places: (a) the percentage pill cw-block-pct and the inline progress bar cw-block-adv-bar on the block, gated on width > 86 px and non-compact density; (b) the popover progress bar. Pure derivation — not cached.

§2.17 Invariants enforced in code

Invariant Where enforced
Artist name 160 characters modals.jsx:65 (PerformanceModal.nameError)
Performance min duration 15 min on resize timetable.jsx:637 (Math.max(perf.start + 15, …))
Drag time clamped to [0, totalMin - dur] timetable.jsx:560
Cancelled performances do not participate in conflicts helpers.js:57 (if (p.status === "cancelled") continue)
Cancelled performances do not participate in B2B helpers.js:78
Stage deletion cascades to wachtrij, never deletes bookings app.jsx:5464
Newly created stages are not committed until StageEditor.onSave handleAddStage sets __draft flag, no dispatch fires until save
Status filter with all-off shows informative empty state timetable.jsx:367
Click-after-drag suppression timetable.jsx:544546, 632634, 677679 (per drag handler)

§2.18 Schema deltas vs SCHEMA.md §3.5.7

Concept Prototype SCHEMA.md §3.5.7 (current) Divergence severity Notes
Artist scoping Implicit single-event in fixture; event_id not on rows event_id FK on artists None for v0.1 — both are event-scoped. RFC v0.2 may revisit org-scoped engagements.
Booking status enum concept|requested|option|confirmed|contracted|cancelled Same None RFC v0.1 D9 makes this a PHP enum.
Per-block lane stacking Mixed: explicit lane: int (set by drag) + auto-lane-pack (assignLanes) No lane column New column needed for explicit lane persistence — RFC v0.2 D13 to confirm. Auto-lane-pack can stay computed at API/frontend layer.
Performance time start, end minutes-since-grid-anchor (14:00) date, start_time, end_time (separate fields) High Prototype model is purely numeric for cleaner math; production must store wall-clock. Conversion is straightforward but RFC must specify which side (front vs back) does it.
Day mapping day_id FK on every row (event.days fixture) date column (no day pivot table on perf) Medium Production has stages.day_days pivot but no days table for events; perfs join to date directly. RFC v0.2 must reconcile.
Wachtrij Three separate collections: performances, parked, pending No representation in §3.5.7 High — new RFC v0.2 must decide: nullable stage_id (one table, prototype's runtime shape) vs separate parked_performances table. Pending → likely availability_requests mini-table.
Status on pending Synthesised "requested" virtually N/A Conceptual gap If pending is its own table, status either column-defined or implicit.
Capacity warn threshold draw > capacity * 1.1 (hard-coded 10 % grace) Capacity not used Low Per RFC v0.1 §3 OOS for v1; warning icon stays prototype-only.
Advancing aggregate Computed {done, total} from artist.advance flat object Cached advancing_completed_count + _total_count columns (RFC v0.1 D2) Low — encoding diverges, semantics align Prototype's flat advance: {tour: true, …} is a fixture shortcut for the SCHEMA advance_sections table.
Star rating, project_leader_id, milestones (8 cols) Not modelled Present in §3.5.7 None Out of scope for the timetable surface.
Advance section types/submissions Not modelled advance_sections.type, advance_submissions.* None Timetable shows aggregate only.
Soft delete on artist/perf No SD; in-memory dispatch only deleted_at on artists; perfs TBD None Prototype is in-memory; no relevance.
Stage colour Stored on stage Stored on stage None
Stage cascade on delete Performances → wachtrij (parked), stage rows hard-removed Not specified RFC v0.2 must spec Prototype's choice (cascade to wachtrij) is the safer default.
Lineup matrix Bulk replace of stage_days for entire event API not specified RFC v0.2 must spec Prototype's set_stage_days_all action implies a PUT /events/{event}/lineup endpoint that replaces the matrix wholesale.
Stage reorder Persistent — fires reorder_stages action Not specified RFC v0.2 must spec column Implies a sort_order int on stages.
Genre on artist String column Not in §3.5.7 artists (no genre) New column needed Prototype treats genre as a closed list; SCHEMA does not model it.
Draw on artist Int column Not in §3.5.7 artists Optional new column Capacity warning is OOS for v1; column can wait.

§3 Component architecture

§3.1 Component tree

App                                           (app.jsx:9)
├── Sidebar                                    (app.jsx:369)   — static nav, navy chrome
├── main.cw-main
│   ├── Header                                 (app.jsx:436)
│   │   ├── (top) back, title pill, Delen, Registratie open
│   │   ├── (tabs) Overzicht / … / Timetable*
│   │   └── (bar)  DayTabs · ConflictPill · "+ Performance" · "+ Stage" · "Stages per dag"
│   ├── div.cw-tt-wrap
│   │   └── Timetable                          (timetable.jsx:404)
│   │       ├── div.cw-tt-corner               — "Stages · N actief"
│   │       ├── div.cw-tt-axis-wrap → TimeAxis (timetable.jsx:110)
│   │       ├── div.cw-tt-stages
│   │       │   └── div.cw-tt-stage × N        (drag-handle, swatch, name, cap, conflict count, "Bewerken")
│   │       ├── div.cw-tt-canvas (scroll host)
│   │       │   └── div.cw-tt-canvas-inner
│   │       │       ├── GridBg                  (timetable.jsx:127)
│   │       │       ├── div.cw-tt-row × N (stage rows on canvas)
│   │       │       │   └── Block × M           (timetable.jsx:15)
│   │       │       ├── (ghost) move-target-row   — when re-staging across rows
│   │       │       ├── (ghost) create-drag        — yellow dotted block while dragging out a new perf
│   │       │       └── (ghost) from-parking       — preview block while dragging from wachtrij
│   │       ├── ParkingColumn (Wachtrij)        (timetable.jsx:217)
│   │       │   ├── header (title + count)
│   │       │   ├── div.cw-pq-filters
│   │       │   │   ├── search input + group-by toggle
│   │       │   │   ├── StatusMultiSelect       (timetable.jsx:151)
│   │       │   │   └── genre pill row (horizontal scroll)
│   │       │   ├── div.cw-parking-body → grouped sections of cw-parking-item
│   │       │   └── footer (drag hint)
│   │       └── (floating chip) cw-floating-chip × 2 contexts (drag-to-park, drag-from-wachtrij outside canvas)
│   └── FooterToolbar                          (app.jsx:503)   — counts: stages, performances, conflicten
├── Popover (perf)                             (popover.jsx:145)   — conditionally rendered
├── QueuePopover                               (popover.jsx:205)   — conditionally rendered
├── PerformanceModal                           (modals.jsx:44)
├── StageEditor                                (modals.jsx:215)
├── LineupMatrix                               (modals.jsx:290)
└── TweaksPanel (dev only — zoom / density / advancing % toggle)

§3.2 Component contracts

Block (timetable.jsx:15)

  • Responsibility: render a single performance block at its computed pixel position; emit drag/resize/select events; show genre, time, advancing %, capacity-warn icon, conflict ring, B2B dots, and cancelled hatch.
  • Props: perf, artist, stage, sections, pxPerMin, laneTop, laneHeight, conflicting, capWarn, b2bRight, b2bLeft, isSelected, showPercent, onSelect, onStartDragMove, onStartDragResize.
  • Emits: onSelect(perf, rect), onStartDragMove(e, perf), onStartDragResize(e, perf). No internal state.
  • Local state: none.
  • Renders width-conditional details: time string > 64 px, genre > 110 px, % pill > 86 px and not compact density.

TimeAxis (timetable.jsx:110)

  • Pure render of hourly tick labels across the 780-min grid; receives pxPerMin, totalMin, startHour. No drag interaction.

GridBg (timetable.jsx:127)

  • Background-only — gradient lines at 30 min and 60 min intervals; row divider strip per stage row.

StatusMultiSelect (timetable.jsx:151)

  • Internal: open state for dropdown.
  • Closes on outside mousedown via window listener bound while open.
  • Provides "Alle aan" / "Alle uit" links and per-item count chips.
  • Props: statuses (ordered keys), STATUS (palette ref), statusOn (map), counts (map), onSet (replaces map). The legacy onToggle prop is passed by the parent but unused by the child — see §7 Note.

ParkingColumn (timetable.jsx:217)

  • State owned: search, filterGenre, statusOn (map keyed by status, cancelled defaults off), groupBy ("status"|"none"), genreOpen.
  • Derives: normalized items array (pending first synthesised then parked), filtered, groups, hiddenByStatus.
  • Drop target: receives isDropTarget and isDragOrigin as visual flags; the actual drop math sits in Timetable.startDragMove / startDragFromParking (parent-owned).

Timetable (timetable.jsx:404)

  • State owned:
    • selectedId — last-clicked perf (drives dashed selection ring).
    • drag — discriminated union: {mode:"move"|"resize"|"create"|"from_parking", …}.
    • stageDrag{fromIndex, toIndex, dy} while reordering stage rows.
  • Refs: canvasRef (scroll host), axisInnerRef (axis transform).
  • Effects: sync horizontal scroll between canvas and axis via transform: translateX.
  • Memoised: displayedStages (live re-ordered while dragging).
  • Pure derivations on every render: conflicts, b2bLinks, b2bRightSet, b2bLeftSet, laneByStage, rowHeights, rowTops, totalH, stageRowIndex.

Popover / QueuePopover (popover.jsx)

  • Use pickPos(anchorRect, preferSide) to flip side and clamp vertically. Recompute on resize and capture-phase scroll. Close on outside mousedown.
  • Width 340 px, fixed by CSS variable.
  • Constants: POP_W = 340, POP_H = 460, MARGIN = 12 (popover.jsx:14).

Modals (modals.jsx)

  • All wrapped by Backdrop which:
    • Closes on Escape (key code 27).
    • Closes on backdrop click (event.target === currentTarget guard).
    • Stops propagation on modal mousedown.
  • All three use the same chrome: cw-modal-hd, cw-modal-body, cw-modal-ft. Width 480 px (PerformanceModal, StageEditor) or 640 px (LineupMatrix).

§3.3 State management

There is no Redux/Zustand/Pinia store. The entire app state lives in App (app.jsx) as 11 useState hooks:

  • Domain: stages, stageDays, artists, performances, parked, pending, activeDayId.
  • UI: popover, drawer (initialised, never used in current flow), perfModal, stageModal, matrixOpen.
  • Plus useTweaks (devtool).

Mutations route through a single dispatch(action) function (app.jsx:45 132) that switches on action.type. There are 17 action types; this is a hand-rolled reducer with no library backing it. There is no optimistic-update pattern, no server sync, no undo stack.

A peculiar piece: app.jsx:135140 useEffect watches performances and, when a parked id reappears as a non-null stage_id in performances, it removes the now-orphaned entry from parked. This effect papers over the fact that the update_perf action does not itself manage parked. RFC v0.2 should explicitly model this transition rather than rely on a reactive side-effect.

§3.4 Implied API contract (for backend RFC sections)

The prototype's dispatch actions imply the following endpoint shapes (HTTP verbs/path are the RFC's call; here we capture the payload contract):

Dispatch action Implied API call Payload
update_perf PATCH /performances/{id} full perf body incl. optional lane, stage_id, start, end, status. Cascade-bump emits N additional PATCHes.
delete_perf DELETE /performances/{id}
add_perf POST /performances new perf.
update_stage PATCH /stages/{id} name, color, capacity.
delete_stage DELETE /stages/{id} server must (a) detach stage_days, (b) move scheduled perfs to wachtrij, not delete. Prototype does both client-side.
add_stage POST /stages + POST /stages/{id}/days atomically add stage and seed initial day list.
reorder_stages PATCH /events/{event}/stages/order {stageIds: ULID[]}.
set_stage_days_for_stage PUT /stages/{id}/days {dayIds: ULID[]} replaces.
set_stage_days_all PUT /events/{event}/lineup [{stage_id, day_id}, …] replaces matrix.
park_perf POST /performances/{id}/park (or PATCH stage_id=null)
unpark_perf covered by update_perf with new stage/start/end/lane The prototype dispatches this explicitly to avoid a stuck-in-parked race; in practice it overlaps with update_perf.
schedule_pending POST /performances + DELETE /pending/{id} likely a single composite endpoint.
delete_pending, update_pending_status DELETE /availability_requests/{id}, PATCH /availability_requests/{id}
update_parked_status PATCH /performances/{id} unchanged endpoint.
add_parked POST /performances with stage_id=null
add_artist POST /artists invoked inline on "+ Performance" with new act name.

The cascade-bump algorithm fires multiple sequential update_perf actions (prototype: app.jsx:611612 for (const u of cascadeUpdates) dispatch(...)). Production must collapse this into a server-side bulk PATCH or a transaction to avoid intermediate-state visibility (see §7).


§4 Interaction patterns

§4.1 Drag-and-drop

Initiation

A mousedown on the block body fires onStartDragMove (timetable.jsx:52). Movement detection uses a 3 px Manhattan threshold (Math.abs(dx) > 3 || Math.abs(dy) > 3, timetable.jsx:530). The grab handle is the entire block surface; the right-edge resize handle (cw-block-resize) stops propagation and triggers onStartDragResize separately.

Visual feedback during drag

  • The original block continues to render with start/end shifted by the current dx (live preview at the source row, timetable.jsx:901).
  • If the cursor crosses into a different stage row, the source-row block hides (renderInThisRow = false, timetable.jsx:900) and a ghost Block is rendered in the target row (timetable.jsx:980).
  • The cursor-anchored target lane is computed live every frame so the user sees the eventual lane assignment, including cascade-bump previews.
  • If the cursor enters the parking column rectangle, the source block hides (timetable.jsx:887889) and a cw-floating-chip follows the cursor with the artist initials and "Loslaten = parkeren" hint.

Snap behaviour

  • Time snap: 15 minutes (window.CrewliData.TIME.snapMinutes). Applied via H.snap(dx / pxPerMin, 15) (timetable.jsx:559, 636).
  • Lane snap: lanes are integer slots; cursor Y is rounded to the nearest lane via Math.round(yInRow / LANE_STEP) for moves (timetable.jsx:582), and Math.floor for create-drag (timetable.jsx:764) — the floor variant means "anywhere within a lane = that lane".

Drop targets

Drop on Result
Empty cell same stage move (start/end shift) + lane = round-to-cursor; no cascade if free.
Occupied cell same lane (overlap) finalLane = oLane + 1; cascade-bump fires recursively.
Occupied cell different lane finalLane = round-to-cursor (the existing item is left alone if no time-overlap).
Different stage row re-stage + re-time + re-lane in one gesture (timetable.jsx:566).
Different day not supported — drag handler is bounded to the active day's stage list.
Wachtrij column dispatch({type:"park_perf"}); block becomes parked card.
Outside canvas + outside wachtrij drop is a no-op (drag state is reset, position reverts on next render — the block was never persisted mid-drag).

Cascade-bump

The cascade-bump algorithm lives in timetable.jsx:573610 (block move) and is duplicated in timetable.jsx:715734 (parking-to-canvas drop) and timetable.jsx:962977 (the move-ghost preview). RFC v0.2 should consolidate.

Verbatim algorithm (block move version, timetable.jsx:573610):

// Drop logic: pick target lane from Y. If an existing block on that lane
// OVERLAPS the new time → that's a "drop ON block": go to oLane + 1.
// If target lane is free at that time → drop there exactly (no cascade).
const targetStageId = newStageId;
const targetLanes = laneByStage[targetStageId];
const rowTop = rowTops[newRow];
const yInRow = targetY - rowTop - LANE_PAD;
// Round so a half-step movement snaps to the nearest lane.
const rawLane = Math.max(0, Math.min(targetLanes.laneCount, Math.round(yInRow / LANE_STEP)));
const stagePerfs = performances.filter(p => p.stage_id === targetStageId && p.id !== perf.id);
const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (targetLanes.laneOf[o.id] || 0);
const blockAtLaneTime = stagePerfs.find(o =>
  laneOfOther(o) === rawLane &&
  o.start < newEnd && o.end > newStart);

let finalLane = rawLane;
if (blockAtLaneTime) finalLane = laneOfOther(blockAtLaneTime) + 1;
updated.lane = finalLane;

// Cascade only kicks in if the chosen lane has a conflict (rare:
// could happen if multiple items share the lane below).
const queue = [{ lane: finalLane, start: newStart, end: newEnd }];
const queued = new Set([perf.id]);
while (queue.length > 0) {
  const cur = queue.shift();
  for (const o of stagePerfs) {
    if (queued.has(o.id)) continue;
    const oLane = laneOfOther(o);
    if (oLane !== cur.lane) continue;
    if (!(o.start < cur.end && o.end > cur.start)) continue;
    const bumpLane = oLane + 1;
    cascadeUpdates.push({ ...o, lane: bumpLane });
    queued.add(o.id);
    queue.push({ lane: bumpLane, start: o.start, end: o.end });
  }
}

Walk-through of dropping on a busy lane:

  1. Cursor Y is rounded to lane index rawLane.
  2. The first sibling on rawLane whose time overlaps the new [start,end) is found (blockAtLaneTime).
  3. The dropped block goes to rawLane + 1 (finalLane), and cascade begins from there.
  4. The cascade is a BFS over the lane-graph: for each occupied lane found, bump conflicting siblings down by exactly 1, enqueue them with their own time range, and repeat. The queued set prevents loops.
  5. Each cascadeUpdates row is dispatched as a separate update_perf action; there is no transaction.

Click-after-drag suppression

The prototype installs a one-shot capture-phase click listener after mouseup in three places (timetable.jsx:544546, 632634, 677679):

const suppress = (e) => {
  e.stopPropagation(); e.preventDefault();
  window.removeEventListener("click", suppress, true);
};
window.addEventListener("click", suppress, true);
setTimeout(() => window.removeEventListener("click", suppress, true), 0);

This catches the synthetic click the browser fires after a mouseup that followed mousedown+drag. The setTimeout(..., 0) covers the case where no click ever fires (so the listener does not leak).

§4.2 Resize

  • Trigger: mousedown on cw-block-resize (the right-edge 7 px-wide strip) fires onStartDragResize and stops propagation so move drag is not initiated.
  • Snap: 15 minutes via the same H.snap.
  • Minimum duration: 15 minutes — Math.max(perf.start + 15, …) clamps end time (timetable.jsx:637).
  • Conflict on resize: the algorithm does not pre-validate. The new end is saved unconditionally; the next render's findConflicts will mark the block red if it now overlaps a sibling. No cascade-bump on resize.

§4.3 Click + popover

  • Single click on a block (no drag) → onSelect(perf, rect)App opens the perf popover anchored on the block's bounding rect.
  • Single click on a wachtrij card → opens QueuePopover anchored on the card.
  • Double-click: not handled.
  • Position logic (popover.jsx:1841): prefer right side, fall back to left, else clamp into viewport with MARGIN = 12 px. Vertical: try anchorRect.top - 8; if it would overflow bottom, push up; never above MARGIN.
  • Popover layout (340 px wide):
    • Header: avatar (initials) + name + meta line (genre · stage dot · stage name OR "In wachtrij" tag) + × close.
    • Time card: mono font, startend, sub-line with duration.
    • Section "Status" + custom dropdown (StatusDropdown).
    • Section "Advancing" + percentage in section meta + progress bar + checklist of ADVANCE_SECTIONS with tick on done rows.
    • Footer: full-width primary button "Open detailpagina →" (the prototype alert()s a stubbed route).
  • Dismissal: outside mousedown (capture-phase listener popover.jsx:158); × button; status dropdown's own outside-click handler is independent. Escape is not handled at the popover level (only modals close on Escape — see §4.9).

§4.4 Add performance flow

Three entry points all funnel to PerformanceModal:

Trigger Handler Pre-fill
Header "+ Performance toevoegen" app.jsx:201 no stage, no time, status requested, lands in wachtrij
Click on empty grid cell (mousedown without drag) startCreateDragonClickEmptyCell(stage, lo, lo+60, lane) stage, start, end (60 min), status concept
Drag out a region on empty grid same startCreateDrag, end = drag end stage, start, end, status concept
Drop pending artist on canvas with openModal: true app.jsx:227231 artist_id, stage, start, end (60 min), status requested, retains pendingId

Drag-on-empty-grid produces a yellow dotted ghost block (cw-tt-create- ghost) that grows with the cursor and shows the live duration in the center (timetable.jsx:10091018). On mouseup the modal opens; the modal commits the data on save. The ghost is purely transient — nothing is written to state until PerformanceModal.onSave.

PerformanceModal quirks:

  • "Add" mode lets the user type a new act name → the modal fabricates a new artist record with derived initials and a zeroed advance map. The "Edit" mode disables name/genre input (only status is editable in the prototype's modal — full edit is supposedly handled on the artist detail page).
  • Without a stage/start (header entry), it shows a Duur picker (8 preset durations) which is stored on the parked row as dur, used as the drop width when the user later drags it onto the grid.

§4.5 Wachtrij interaction

Card rendering (timetable.jsx:272298)

  • 24 px avatar with artist initials, no photo support.
  • Two-line body: name on row 1; on row 2 a status dot + status label · genre · duration hint (e.g. 1u 30m).
  • is-cancelled strikes through the name and grays it.
  • is-selected adds the accent-soft background and outline.

Filter UX

  • Search input (left) + group-by-status toggle (right) on row 1.
  • StatusMultiSelect dropdown (multi-select, cancelled defaults off, shows per-status counts and "Alle aan / Alle uit" links).
  • Genre pill row — horizontally scrollable, single-select, each pill shows its count badge.

Drag from wachtrij onto grid (timetable.jsx:648750)

  • Movement threshold 4 px. If never crosses threshold → treated as a click that opens the queue popover.
  • Pending → schedules into performances (or opens add-modal for confirmation; not the default path in current code, retained for flexibility).
  • Parked → restored as a regular performance with new stage/start/end/lane, the app.jsx:135 effect strips the orphaned parked entry.
  • Drop within parking → stays parked (timetable.jsx:684).
  • Drop outside both canvas and parking → no-op.
  • Cascade-bump runs identically to the block-move handler.

Park action from grid

Trigger: drag a performance block, release with cursor inside the parking column rectangle. There is no button-based "park" affordance on the popover or the block context menu — drag is the only way to park.

§4.6 Stage management

StageEditor modal (modals.jsx:215)

Fields:

  • Name (text)
  • Capaciteit (number, hint about capacity-warning relevance)
  • Kleur (10-swatch row, no free-form colour picker)
  • Actief op dagen (day pill multi-select)

In create mode (__draft), default day is the active day. In edit mode, default days are the existing stage_days entries for the stage.

Delete affordance is in-modal (red "Verwijder stage" button, modal-foot left). Confirmation is a vanilla window.confirm (app.jsx:332338) with copy that distinguishes the "N acts move to wachtrij" case from the empty-stage case.

LineupMatrix modal (modals.jsx:290)

  • Table: rows = stages, columns = days. Each cell is a custom <button class="cw-check"> toggle.
  • Save replaces the entire stage_days collection (set_stage_days_all, prototype just spreads to a flat list).
  • Helpful hint at bottom about cross-day stages.

Cascade behaviour on stage delete (app.jsx:5464)

} else if (action.type === "delete_stage") {
  setStages(prev => prev.filter(s => s.id !== action.id));
  setStageDays(prev => prev.filter(sd => sd.stage_id !== action.id));
  // Move scheduled performances on this stage to the wachtrij (parked)
  // i.p.v. ze te verwijderen.
  setPerformances(prev => prev.filter(p => p.stage_id !== action.id));
  setParked(prev => [
    ...prev,
    ...performances
      .filter(p => p.stage_id === action.id)
      .map(p => ({ ...p, stage_id: null, dur: (p.end - p.start) || 60 })),
  ]);
}

This matches the RFC v0.1 spec ("cascade to wachtrij with origin= stage_deleted") in spirit but does not stamp an origin field on the parked rows. RFC v0.2 should specify whether such provenance is needed for the activity log.

§4.7 Status filtering

The status filter in the wachtrij is closed-list; same six values as the status enum, same display order:

concept · requested · option · confirmed · contracted · cancelled

Defaults: all on except cancelled. The dropdown shows per-status counts in real time. There is no equivalent filter on the timetable canvas itself — filters apply only to the wachtrij list. RFC v0.2 should decide whether to extend this to the canvas (e.g. fade non-matching blocks).

§4.8 Conflict visualisation

  • Block visual: is-conflict class adds border-color: #d63d4b and border-width: 1.5px (styles.css:445). A small red dot SVG with inset exclamation glyph appears in the warn-row cluster (timetable.jsx:7179).
  • Stage row badge: number of conflicts on the stage shown in a small red pill on the stage row (timetable.jsx:838848). Hover title: "N conflicten op deze stage".
  • Header pill: total conflicts across the active day shown in cw-pill-warn (app.jsx:478). Counts come from H.findConflicts(dayPerformances).size.
  • Footer toolbar stat: <b>{conflicts}</b> conflicten (app.jsx:509).
  • No tooltip on conflict click. Hovering the warn icon gives the one-liner title; clicking the block opens the popover (which has no conflict explanation panel).

§4.9 Keyboard / accessibility

The prototype is mouse-first. Honest summary:

  • Modals close on Escape (key code 27, modals.jsx:9).
  • Focus management: autoFocus on first input in PerformanceModal.
  • Some buttons carry aria-label (close ×, status checkboxes, filter search ×).
  • role="tab" on day tabs, role="listbox" on status dropdown, role="radiogroup" on genre pill row, role="alert" on field error.
  • Field errors expose aria-invalid and aria-describedby.
  • No keyboard activation of blocks (mousedown-only handlers).
  • No tab order through the canvas; blocks are not focusable.
  • No keyboard equivalent for drag/resize/lane-change.
  • No screen-reader narration of conflicts, B2B markers, capacity warnings.
  • Popover does not close on Escape.
  • No "skip to canvas" landmark.
  • The status dropdown uses pure <button> elements with role="listbox" but does not implement the listbox keyboard pattern (arrow-key navigation, type-ahead).

Crewli production code requires a11y; the RFC v0.2 must spec a keyboard model (likely: focusable blocks with arrow-key time/lane nudges + context menu for stage move + status change). The prototype provides no foundation for this — the drag-handler architecture must be redesigned, not retrofitted.


§5 Pure business logic

All five pure functions live in helpers.js (a single 144-line IIFE). Both timetable.jsx and app.jsx access them via window.CrewliHelpers.

§5.1 snap(min, step)

  • File: helpers.js:51.
  • Signature: (min: number, step: number) → number.
  • Algorithm: pure rounding to nearest multiple of step.
function snap(min, step) { return Math.round(min / step) * step; }
  • Edge cases handled: Negative inputs round symmetrically (Math.round truncates ties toward +∞ for positives, but for our usage step is always 15 and min is always non-negative after clamp).
  • Edge cases NOT handled: No bounds clamp — callers do that themselves.

§5.2 findConflicts(performances)

  • File: helpers.js:53.
  • Signature: (performances: Perf[]) → Set<string> (set of perf IDs that participate in at least one overlap).
  • Algorithm: group by stage_id (skipping cancelled), sort by start, then pairwise compare adjacent + every later pair within the group. O(n²) worst case per stage.
function findConflicts(performances) {
  const conflicts = new Set();
  const byStage = {};
  for (const p of performances) {
    if (p.status === "cancelled") continue;
    (byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
  }
  for (const sid in byStage) {
    const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
    for (let i = 0; i < list.length; i++) {
      for (let j = i + 1; j < list.length; j++) {
        if (list[j].start < list[i].end) {
          conflicts.add(list[i].id);
          conflicts.add(list[j].id);
        }
      }
    }
  }
  return conflicts;
}
  • Edge cases handled: cancelled excluded; identical bounds count as conflict (< strict on list[j].start < list[i].end — touching at endpoint = no conflict).
  • Edge cases NOT handled:
    • Cross-stage conflict (same artist on two stages) — explicitly OOS per RFC v0.1 D5; correctly omitted.
    • The pairwise loop early-exits implicitly when the sorted invariant holds, but does not break out of the inner loop when list[j].start >= list[i].end — this is a minor perf bug at large N. Acceptable for festival sizes.
    • Lane is not considered. Two blocks on different lanes but overlapping in time still flag as conflict. This is consistent with RFC v0.1 D5 ("same-stage same-day overlap only") which treats lanes as visual stacking, not parallel programming.

§5.3 findB2B(performances, gap=5)

  • File: helpers.js:74.
  • Signature: (performances: Perf[], gap?: number = 5) → {leftId, rightId, gap}[].
  • Algorithm: group by stage, sort by start, scan adjacent pairs, emit a link when gap is in [0, 5] minutes inclusive.
function findB2B(performances, gap = 5) {
  const links = [];
  const byStage = {};
  for (const p of performances) {
    if (p.status === "cancelled") continue;
    (byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
  }
  for (const sid in byStage) {
    const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
    for (let i = 0; i < list.length - 1; i++) {
      const gapMin = list[i + 1].start - list[i].end;
      if (gapMin >= 0 && gapMin <= gap) {
        links.push({ leftId: list[i].id, rightId: list[i + 1].id, gap: gapMin });
      }
    }
  }
  return links;
}
  • Edge cases handled: cancelled excluded; touching (gap=0) counts.
  • Edge cases NOT handled:
    • Lane is again ignored — two blocks on different lanes but with a small temporal gap still mark as B2B. Whether that is correct depends on whether B2B is interpreted as "stage manager has a tight changeover" (per-lane irrelevant) or "same speaker rolls into next" (per-lane relevant). The prototype takes the changeover view.
    • Negative gaps (overlap) are deliberately excluded by the gapMin >= 0 predicate — overlap is for findConflicts.

§5.4 assignLanes(items)

  • File: helpers.js:104.
  • Signature: (items: Perf[]) → {laneOf: Record<string,number>, laneCount: number}.
  • Algorithm: two-pass placement. Pass 1: items with explicit lane: int are placed at their requested lane (bumped down on conflict). Pass 2: items without explicit lane go to the lowest free lane.
function assignLanes(items) {
  const sorted = items.slice().sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
  const laneOf = {};
  let maxLane = 0;

  const overlapsAt = (it, lane) => sorted.some(other =>
    other.id !== it.id &&
    laneOf[other.id] === lane &&
    other.start < it.end && other.end > it.start
  );

  // Pass 1 — items with explicit lane (sorted by lane asc): try requested lane,
  // bump down on conflict to avoid overlap.
  const explicit = sorted.filter(i => Number.isInteger(i.lane))
                         .sort((a, b) => a.lane - b.lane || a.start - b.start);
  for (const it of explicit) {
    let lane = Math.max(0, it.lane);
    while (overlapsAt(it, lane)) lane++;
    laneOf[it.id] = lane;
    if (lane > maxLane) maxLane = lane;
  }

  // Pass 2 — items without explicit lane: lowest free lane.
  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 };
}
  • Edge cases handled:
    • Stable ordering inside a start tie via id.localeCompare.
    • Explicit-lane items respected unless that exact lane is taken at that time, in which case bumped down.
    • laneCount is derived as maxLane + 1 so 0 items gives lane count 1 (the renderer uses this to size the row, but the prototype does not actually render a 1-lane row when there are 0 items — it falls back to baseRowHeight).
  • Edge cases NOT handled:
    • Negative lane is clamped to 0, but float lanes are not coerced — Number.isInteger filters them out and they fall through to Pass 2.
    • Two items with the same explicit lane that DO NOT time-overlap correctly stay on that lane (the overlapsAt short-circuit requires both lane match AND time overlap).

§5.5 Cascade-bump (drop algorithm)

  • File: timetable.jsx:573610 (block move), timetable.jsx:715734 (parking-to-canvas drop), timetable.jsx:962977 (move-ghost preview).
  • Signature: (target: Perf, performances: Perf[], targetLanes: LaneAssignment, …) → updated: Perf, cascadeUpdates: Perf[].
  • Algorithm: BFS over the lane graph. Pick lane from cursor; if that lane × time slot is occupied, target lane = oLane + 1; for every conflicting sibling, bump to oLane + 1 and recurse.

Verbatim code already quoted in §4.1.

  • Edge cases handled:
    • queued set prevents infinite loops if the cascade re-visits an item.
    • Items with Number.isInteger(lane) === false fall back to targetLanes.laneOf[id] — so cascade composes with the auto-pack.
  • Edge cases NOT handled / known bugs:
    • The cascade is monotonically downward (always bumps to lane + 1). A re-pack pass that tries to lift items back up after a delete or a move-out is not implemented — lanes only grow.
    • The cascade dispatches updates one at a time; an observer could see a transient state with two items on the same lane. RFC v0.2 should bundle into one server-side bulk PATCH.
    • The duplicated copies in three call sites can drift. Already a candidate for extraction into a helpers.js function.
    • The function does not handle different-day moves at all (current UX does not allow them).

§5.6 advanceCount(artist, sections)

  • File: helpers.js:137.
  • Signature: (artist: Artist, sections: Section[]) → {done, total}.
  • Algorithm: sum truthy entries in artist.advance keyed by section keys.
function advanceCount(artist, sections) {
  let done = 0;
  for (const s of sections) if (artist.advance && artist.advance[s.key]) done++;
  return { done, total: sections.length };
}
  • Edge cases handled: artist.advance undefined returns {done:0, total: sections.length}.
  • Edge cases NOT handled: treats every section as accept-eligible; in production, sections have submission states (open|pending| submitted|approved|declined) and only accepted should count toward done (RFC v0.1 D2 spec). The prototype's encoding is a fixture shortcut.

§5.7 isCapacityWarn(artist, stage)

  • File: helpers.js:93.
  • Signature: (artist: Artist, stage: Stage) → bool.
  • Algorithm: artist.draw > stage.capacity * 1.1. The 1.1 is the 10 % over-capacity tolerance band (anything below the band is "fine" visually).

Capacity warn is OOS for v1 per RFC v0.1; included here for completeness.


§6 Design tokens

§6.1 Status palette

Defined in helpers.js:542 (NOT in CSS) so JSX can apply them inline.

Status label pillBg pillFg blockBg blockBorder blockFg dot
concept Concept #eceae6 #5a574e #f1efe9 #dcd9d1 #3a3830 #a09c92
requested Aangevraagd #fdf2dc #8a6a1d #fff6e0 #f0d99a #5d4612 #d9a93c
option Optie #ece6f6 #5d4a8a #f3eefa #d9c9ed #3f2f6a #9a82c7
confirmed Bevestigd #dff5ec #1f7a5e #e8f8f0 #a9e0c8 #125541 #3cc2a8
contracted Getekend #dcecfa #1f5a8a #e6f1fb #a8cfee #143b5d #3a8acc
cancelled Geannuleerd #f0eeea #8a8780 #f5f3ef #dedbd3 #7a7770 #a8a59d

Cancelled has special render: hatched repeating-linear-gradient overlay

  • red border #c4202f + red text #7a1219 (timetable.jsx:3943).

The conflict border is #d63d4b (also the global --danger token).

§6.2 Global CSS tokens (styles.css:336)

:root {
  --bg: #f7f6f1;           --surface: #ffffff;       --surface-2: #fafaf6;
  --ink: #1a2530;          --ink-2: #2c3744;
  --muted: #6c7682;        --muted-2: #98a0aa;
  --border: #e6e3da;       --border-strong: #d4d0c4;
  --accent: #3cc2a8;       --accent-deep: #2ba88e;   --accent-soft: #def4ec;
  --danger: #d63d4b;       --danger-soft: #fde9eb;
  --warn: #e89a3c;         --warn-soft: #fdf2dc;

  --side-bg: #0f1820;      --side-fg: #d8dde2;       --side-fg-muted: #7c8a96;
  --side-active-bg: #18242e; --side-active-fg: #ffffff;

  --r-sm: 6px;  --r: 10px;  --r-lg: 14px;
  --shadow-sm: 0 1px 2px rgba(20, 30, 45, .06);
  --shadow:    0 4px 16px rgba(20, 30, 45, .08), 0 1px 3px rgba(20, 30, 45, .06);
  --shadow-lg: 0 12px 40px rgba(20, 30, 45, .15), 0 2px 8px rgba(20, 30, 45, .08);

  --font: "Inter", ui-sans-serif, system-ui, ;
  --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}

§6.3 Layout dimensions

Property Value Source
Sidebar width 232 px (column 1 of cw-app grid) styles.css:54
Stage column width (timetable) 220 px (STAGE_COL_W) timetable.jsx:10
Parking column width 280 px (PARKING_W) timetable.jsx:11
Lane padding (top/bottom) 4 px (LANE_PAD) timetable.jsx:12
Lane step (row of one lane) 56 / 64 / 76 px (compact / regular / comfy) timetable.jsx:494
Lane block height LANE_STEP - 4 (= 52 / 60 / 72 px) timetable.jsx:495
Row height base 52 / 64 / 84 px (compact / regular / comfy) — actual row height = max(base, lanes * laneStep + 8) app.jsx:144
Pixels-per-minute 4 * zoom (zoom default 1.0, range 0.52.5×) app.jsx:143
Time axis height 36 px styles.css:313
Half-hour gridline every 30 * pxPerMin px timetable.jsx:128
Hour gridline every 60 * pxPerMin px timetable.jsx:129
Block border-radius 7 px styles.css:419
Block padding 6px 9px 5px 10px regular; 3px 9px 3px 10px compact styles.css:420, 430
Resize handle width 7 px styles.css:494
B2B dot 6 × 6 px, half-extruded at edges (right: -3px) styles.css:498505
Popover width 340 px (POP_W) popover.jsx:14
Popover height (hint for vertical clamp) 460 px (POP_H) popover.jsx:15
Popover viewport margin 12 px (MARGIN) popover.jsx:16
Modal width 480 px (PerformanceModal, StageEditor) / 640 px (LineupMatrix) modals.jsx:113, 239, 320
Avatar (popover) implied 32 px square (CSS class cw-pop-avatar)
Status dot small inline circle (CSS class cw-status-dot)

§6.4 Typography

Element Size Family Weight
Body 13 px --font (Inter) 400
Header title 22 px --font 600
Tab label 12.5 px --font 400 (active 500)
Day-tab label 13 px --font 500
Day-tab meta 10.5 px --font 400
Block name 12.5 px --font 600
Block time 10.5 px --mono (IBM Plex Mono) 400
Block % pill 10.5 px --mono 600
Popover time main 14 px --mono 400
Popover time sub 11 px --mono 400
Section label (popover) (default) --font 500
Section meta 11 px --font 500

§6.5 Spacing & gaps

  • Block warn-row gap: 4 px.
  • Block row1 gap: 6 px.
  • Block row2 gap: 8 px.
  • Wachtrij card list gap: 4 px.
  • Block adv-bar height: 4 px (radius 2 px).

§6.6 Animations & transitions

Surface Animation
Block hover transition: box-shadow 100ms, transform 80ms (styles.css:426)
Popover entry cw-pop-in keyframes 140ms ease-out (styles.css:980)
Stage row drag box-shadow: 0 8px 24px rgba(20,30,45,.10) while dragging (styles.css:726-727)
Drag-origin parking column box-shadow: inset 0 0 0 1px rgba(60, 194, 168, 0.18) and accent-soft tint while a drag is in flight (styles.css:621-622)

There is no pulse animation on conflict; the prototype is static red. RFC v0.2 may want to add a subtle pulse for accessibility (motion-reduce respect required).


§7 Notes for RFC v0.2

This section mixes observation (what the prototype reveals) and recommendation (what RFC v0.2 should formally decide).

  1. Lane is a first-class storage concept now. RFC v0.1 treats lane as a purely visual derivative; the prototype proves you cannot get cursor-anchored drop semantics without persisting lane (otherwise re-renders re-pack and the user's intent is lost). Recommendation: add performances.lane (unsigned tinyint, default null, nullable — null = auto-pack). Cascade-bump must be a transactional bulk update server-side.

  2. assignLanes should run on the server, not the client, on read. Otherwise two clients viewing the same timetable see different lane layouts when one of them edited explicit lanes. The prototype gets away with this because its state is single-user. RFC v0.2 should include a lane_resolved field on the API response that bakes Pass-2 results in.

  3. The cascade-bump algorithm is duplicated three times in timetable.jsx. Move-block, drop-from-parking, and the move-ghost preview each reimplement it. The Vue port must extract this to a single composable (useLaneCascade(targetPerf, siblings, …)). The ghost-preview version is an exact mirror of the commit version; that parallel maintenance is a bug factory.

  4. There is no transaction or atomic bulk-PATCH for cascade. The prototype dispatches N sequential update_perf actions. In production this means: optimistic UI updates work fine, but any one PATCH failing mid-cascade leaves the state half-applied. Consider a POST /events/{event}/timetable/move endpoint that takes {perf_id, target_stage_id, target_start, target_lane} and returns a list of cascaded perfs as a single transaction.

  5. The wachtrij is a third runtime collection but is not modelled in SCHEMA.md. RFC v0.1 mentions "park" cascade on stage-delete but does not formally model the parked state. Two viable encodings:

    • (Prototype's choice) performances.stage_id nullable; stage_id IS NULL ⇔ parked. Pros: fewest tables, transitions are simple UPDATE. Cons: every query that sums stage stats must filter null.
    • Separate parked_performances table. Pros: queryable wachtrij without polluting the main table. Cons: doubles transition surface. Recommend RFC v0.2 picks the nullable-stage_id approach explicitly, matching the prototype.
  6. pending (availability requests) is a distinct concept the SCHEMA does not model. The prototype synthesises virtual status requested for them. RFC v0.2 should either:

    • Promote pending to its own availability_requests table with id, event_id, artist_id, day_date, requested_on, note, OR
    • Treat them as pre-parked rows with a "no times yet" marker. The prototype's transition-on-status-change behaviour (pending → parked synthesises a new id) hints that they are conceptually different objects, favouring option A.
  7. Day tabs map to event.days[] inline pivot. SCHEMA models date directly on perf. RFC v0.2 must reconcile: do we add an event_days table, or do we derive days from MIN/MAX(performances.date) + stage_days.day_date? The prototype's EVENT.days[] carries label, short, and date — these need a home if they leave the fixture.

  8. Stage reorder is persistent in the prototype but not in SCHEMA. reorder_stages action implies a sort_order column. Recommend RFC v0.2 add stages.sort_order: int and document the PATCH /events/{event}/stages/order endpoint.

  9. The genre concept is on artist in the prototype but not in SCHEMA. The closed-list GENRES and the genre-filter pill row are functional features of the wachtrij. RFC v0.2 must decide whether genre joins the artists table as a string column (closed list? open?) or whether it is a tag relation. The prototype's pragmatic single-string approach is probably correct for v1.

  10. Stage-delete cascade origin is not stamped. RFC v0.1 mentions origin=stage_deleted for parked rows resulting from a stage deletion; the prototype does not write this anywhere. If the activity-log replay or an undo affordance ever wants to filter "why is this in the wachtrij", RFC v0.2 should formalise an origin enum on the parked row (or in the activity log).

  11. The drag/drop architecture is mouse-event-driven and not a11y- capable. The Vue port cannot just translate the prototype's mousedown/mousemove/mouseup handlers — Crewli's a11y requirement (per VUEXY_COMPONENTS.md and CLAUDE.md frontend rules) means RFC v0.2 must spec a keyboard model from scratch. Suggested primitives: focusable block (Tab); arrow keys = ±15 min / lane; Shift-arrow = ±1 hour; Enter = open popover; Escape = close; context menu (or [/]) = stage move; Cmd-Z = undo last move.

  12. Click-after-drag suppression is reinvented at three sites. The Vue port should provide a single useDragOrClick composable that handles the threshold + suppression contract. Alternative: use PointerEvents (pointercancel) which makes this explicit.

  13. Status colours live in JSX, not CSS. The prototype keeps the status palette in helpers.js so JSX can apply pillBg, pillFg, etc. inline. The Vue port should move this to CSS variables (e.g. --status-confirmed-bg) so styling is declarative and overridable per-tenant. RFC v0.2 should formalise a status-token naming convention and decide whether per-tenant brand overrides are a v1 feature.

  14. No optimistic-update / failure-rollback exists. RFC v0.1 D7 requires "Failed PATCH … reverts block to its origin position with a toast". The prototype has zero rollback machinery. The Vue port must introduce a TanStack Query mutation pattern with optimistic update

    • onError revert + toast — the prototype provides no foundation, so estimate fresh dev time.
  15. The popover does not close on Escape. Modals do; popover does not. Trivial bug. RFC v0.2 spec should clarify the keyboard model for popovers (likely: Escape closes popover but not the page).

  16. Capacity warning, conflict counts, and B2B dots are computed on every render in the client. For ~50 performances per day this is fine; for larger festivals this scales. RFC v0.2 should spec server-side computation OR memoise per-day.

  17. Multi-tenancy: the prototype has zero awareness of organisation scoping. Single fixture, single org. RFC v0.1 §3 covers this for backend; RFC v0.2 should explicitly call out that the Vue store must NOT cache cross-tenant data and that a tenant switch wipes the timetable cache.

  18. Observability: no events / metrics in the prototype. Per ARCH-OBSERVABILITY.md every meaningful mutation should emit an activity-log entry. RFC v0.1 §8 covers this; RFC v0.2 should make cascade-bump emit a single grouped entry rather than N individual performance_updated entries.

  19. Two concepts present in prototype but consciously NOT to carry over:

    • The hand-rolled dispatch reducer in app.jsx (Pinia + TanStack Query handle this in production).
    • The vendor-style TweaksPanel (purely a Claude Design devtool; Vuexy/Vuetify is the production design language).
  20. Form Builder integration (ARCH-FORM-BUILDER §3.2.5 artist_advance purpose). The prototype's flat artist.advance: {tour: bool, …} map is an aesthetic stand-in for the production advance_sections + advance_submissions tables. RFC v0.2 must decide whether the timetable popover queries advance_sections directly (one query per visible block — N+1 risk) or relies on the cached artists.advancing_completed_count / _total_count columns (RFC v0.1 D2). Recommendation: cache and recompute via observer, as v0.1 already specifies — but test the cache invalidation path, because the prototype's "advancing %" is a load-bearing decision cue and stale data will degrade trust quickly.


Closing summary

The prototype is high-fidelity for UX but low-fidelity for production plumbing. The interaction model (cursor-anchored drop, cascade-bump, mixed-explicit-and-auto lane assignment, parking column with status multi-select, lineup matrix) is the defining contribution and should be ported faithfully — preserving the snap math, the click-vs-drag threshold, and the cascade-bump algorithm verbatim. The data model is a useful prompt for RFC v0.2 questions but is not a schema proposal: twelve fields are needed (lane, sort_order, genre, draw, two cached advancing counters, etc.) and three transitions need real homes (parked, pending, stage-delete origin). The accessibility story is from-scratch work, not a port. The ~3-day-additional-frontend cost RFC v0.1 D8 attributes to "build custom" is realistic for the rendering core, but the a11y, observability, and rollback layers are not in the prototype and must be budgeted on top.