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>
63 KiB
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.CrewliHelpers — STATUS 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:115–124).
§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— hasstage_id != null, sits on the grid.parked— hasstage_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 pillcw-block-pctand the inline progress barcw-block-adv-baron 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 1–60 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:54–64 |
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:544–546, 632–634, 677–679 (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:
openstate for dropdown. - Closes on outside
mousedownvia 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 legacyonToggleprop 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,cancelleddefaults off),groupBy("status"|"none"),genreOpen. - Derives: normalized
itemsarray (pending first synthesised then parked),filtered,groups,hiddenByStatus. - Drop target: receives
isDropTargetandisDragOriginas visual flags; the actual drop math sits inTimetable.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 onresizeand capture-phasescroll. Close on outsidemousedown. - 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
Backdropwhich:- 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:135–140 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:611–612 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/endshifted by the currentdx(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:887–889) and acw-floating-chipfollows the cursor with the artist initials and "Loslaten = parkeren" hint.
Snap behaviour
- Time snap: 15 minutes (
window.CrewliData.TIME.snapMinutes). Applied viaH.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), andMath.floorfor 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:573–610 (block move) and
is duplicated in timetable.jsx:715–734 (parking-to-canvas drop) and
timetable.jsx:962–977 (the move-ghost preview). RFC v0.2 should consolidate.
Verbatim algorithm (block move version, timetable.jsx:573–610):
// 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:
- Cursor Y is rounded to lane index
rawLane. - The first sibling on
rawLanewhose time overlaps the new[start,end)is found (blockAtLaneTime). - The dropped block goes to
rawLane + 1(finalLane), and cascade begins from there. - 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
queuedset prevents loops. - Each
cascadeUpdatesrow is dispatched as a separateupdate_perfaction; 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:544–546, 632–634, 677–679):
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) firesonStartDragResizeand 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
findConflictswill 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)→Appopens the perf popover anchored on the block's bounding rect. - Single click on a wachtrij card → opens
QueuePopoveranchored on the card. - Double-click: not handled.
- Position logic (
popover.jsx:18–41): prefer right side, fall back to left, else clamp into viewport withMARGIN = 12 px. Vertical: tryanchorRect.top - 8; if it would overflow bottom, push up; never aboveMARGIN. - Popover layout (340 px wide):
- Header: avatar (initials) + name + meta line (genre · stage dot · stage name OR "In wachtrij" tag) + × close.
- Time card:
monofont,start–end, sub-line with duration. - Section "Status" + custom dropdown (
StatusDropdown). - Section "Advancing" + percentage in section meta + progress bar +
checklist of
ADVANCE_SECTIONSwith tick on done rows. - Footer: full-width primary button "Open detailpagina →" (the
prototype
alert()s a stubbed route).
- Dismissal: outside
mousedown(capture-phase listenerpopover.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) | startCreateDrag → onClickEmptyCell(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:227–231 |
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:1009–1018). 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
artistrecord with derived initials and a zeroedadvancemap. 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
Duurpicker (8 preset durations) which is stored on the parked row asdur, used as the drop width when the user later drags it onto the grid.
§4.5 Wachtrij interaction
Card rendering (timetable.jsx:272–298)
- 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-cancelledstrikes through the name and grays it.is-selectedadds the accent-soft background and outline.
Filter UX
- Search input (left) + group-by-status toggle (right) on row 1.
- StatusMultiSelect dropdown (multi-select,
cancelleddefaults 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:648–750)
- 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:135effect 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:332–338)
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_dayscollection (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:54–64)
} 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-conflictclass addsborder-color: #d63d4bandborder-width: 1.5px(styles.css:445). A small red dot SVG with inset exclamation glyph appears in the warn-row cluster (timetable.jsx:71–79). - Stage row badge: number of conflicts on the stage shown in a small
red pill on the stage row (
timetable.jsx:838–848). 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 fromH.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:
autoFocuson first input inPerformanceModal. - ✅ 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-invalidandaria-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 withrole="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
stepis always 15 andminis 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 bystart, 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 onlist[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 >= 0predicate — overlap is forfindConflicts.
§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: intare 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
starttie viaid.localeCompare. - Explicit-lane items respected unless that exact lane is taken at that time, in which case bumped down.
laneCountis derived asmaxLane + 1so 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 tobaseRowHeight).
- Stable ordering inside a
- Edge cases NOT handled:
- Negative
laneis clamped to 0, but float lanes are not coerced —Number.isIntegerfilters 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
overlapsAtshort-circuit requires bothlanematch AND time overlap).
- Negative
§5.5 Cascade-bump (drop algorithm)
- File:
timetable.jsx:573–610(block move),timetable.jsx:715–734(parking-to-canvas drop),timetable.jsx:962–977(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 tooLane + 1and recurse.
Verbatim code already quoted in §4.1.
- Edge cases handled:
queuedset prevents infinite loops if the cascade re-visits an item.- Items with
Number.isInteger(lane) === falsefall back totargetLanes.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.jsfunction. - The function does not handle different-day moves at all (current UX does not allow them).
- The cascade is monotonically downward (always bumps to
§5.6 advanceCount(artist, sections)
- File:
helpers.js:137. - Signature:
(artist: Artist, sections: Section[]) → {done, total}. - Algorithm: sum truthy entries in
artist.advancekeyed 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.advanceundefined 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 onlyacceptedshould 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:5–42 (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:39–43).
The conflict border is #d63d4b (also the global --danger token).
§6.2 Global CSS tokens (styles.css:3–36)
: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.5–2.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:498–505 |
| 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).
-
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: addperformances.lane(unsigned tinyint, default null, nullable — null = auto-pack). Cascade-bump must be a transactional bulk update server-side. -
assignLanesshould 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 alane_resolvedfield on the API response that bakes Pass-2 results in. -
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. -
There is no transaction or atomic bulk-PATCH for cascade. The prototype dispatches N sequential
update_perfactions. In production this means: optimistic UI updates work fine, but any one PATCH failing mid-cascade leaves the state half-applied. Consider aPOST /events/{event}/timetable/moveendpoint that takes{perf_id, target_stage_id, target_start, target_lane}and returns a list of cascaded perfs as a single transaction. -
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_idnullable;stage_id IS NULL ⇔ parked. Pros: fewest tables, transitions are simpleUPDATE. Cons: every query that sums stage stats must filter null. - Separate
parked_performancestable. 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.
- (Prototype's choice)
-
pending(availability requests) is a distinct concept the SCHEMA does not model. The prototype synthesises virtual statusrequestedfor them. RFC v0.2 should either:- Promote pending to its own
availability_requeststable withid, 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.
- Promote pending to its own
-
Day tabs map to
event.days[]inline pivot. SCHEMA models date directly on perf. RFC v0.2 must reconcile: do we add anevent_daystable, or do we derivedaysfromMIN/MAX(performances.date) + stage_days.day_date? The prototype'sEVENT.days[]carries label, short, and date — these need a home if they leave the fixture. -
Stage reorder is persistent in the prototype but not in SCHEMA.
reorder_stagesaction implies asort_ordercolumn. Recommend RFC v0.2 addstages.sort_order: intand document thePATCH /events/{event}/stages/orderendpoint. -
The genre concept is on
artistin the prototype but not in SCHEMA. The closed-listGENRESand the genre-filter pill row are functional features of the wachtrij. RFC v0.2 must decide whether genre joins theartiststable 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. -
Stage-delete cascade origin is not stamped. RFC v0.1 mentions
origin=stage_deletedfor 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). -
The drag/drop architecture is mouse-event-driven and not a11y- capable. The Vue port cannot just translate the prototype's
mousedown/mousemove/mouseuphandlers — 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. -
Click-after-drag suppression is reinvented at three sites. The Vue port should provide a single
useDragOrClickcomposable that handles the threshold + suppression contract. Alternative: use PointerEvents (pointercancel) which makes this explicit. -
Status colours live in JSX, not CSS. The prototype keeps the status palette in
helpers.jsso JSX can applypillBg,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. -
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.
-
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).
-
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.
-
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.
-
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_updatedentries. -
Two concepts present in prototype but consciously NOT to carry over:
- The hand-rolled
dispatchreducer inapp.jsx(Pinia + TanStack Query handle this in production). - The vendor-style
TweaksPanel(purely a Claude Design devtool; Vuexy/Vuetify is the production design language).
- The hand-rolled
-
Form Builder integration (ARCH-FORM-BUILDER §3.2.5
artist_advancepurpose). The prototype's flatartist.advance: {tour: bool, …}map is an aesthetic stand-in for the productionadvance_sections + advance_submissionstables. RFC v0.2 must decide whether the timetable popover queriesadvance_sectionsdirectly (one query per visible block — N+1 risk) or relies on the cachedartists.advancing_completed_count/_total_countcolumns (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.