70 KiB
RFC-TIMETABLE — Artist Timetable & Engagement Module
1. Status
- State: Active
- Approved: 2026-05-08
- Created: 2026-05-08
- Version: v0.2
- Owner: Bert Hausmans
- Replaces: v0.1 (2026-05-08)
- Origin: v0.1 + brainstorm session 2026-05-08 (Claude Chat) +
prototype audit (commit
a57437a,dev-docs/audits/PROTOTYPE-AUDIT- ARTIST-TIMETABLE.md) + decisions Bert 2026-05-08 - Related:
SCHEMA.md§3.5.7 (will be substantially rewritten by this RFC)ARCH-PLANNED-MODULES.md§3.5.7 (will move to SCHEMA.md as it lands)BACKLOG.mdARCH-09 (resolved by Session 1), ART-03 (absorbed), new ART-04…ART-13, FIN-01, FIN-02ARCH-FORM-BUILDER.md§3.2.5 (artist_advancepurpose), §17.3 (purpose registry), §4 (form_schemas), §6 (binding semantics)AUTH_ARCHITECTURE.md§6 (portal-token flow)UX_SPEC_FESTIVAL_HIERARCHY.md(festival/sub-event invariants)CLAUDE.md"Order of work"VUEXY_COMPONENTS.md(frontend conventions)
2. Why this RFC exists
The Artist Timetable is the central planning surface for festival programming. It is operationally critical: production managers spend hours per week on it, last-minute changes are common, a bug here visibly breaks the show, and bookers will not adopt a tool that mishandles the deal-info layer (Buma, holds, contracts).
Five properties make this module non-trivial and warrant up-front spec:
- It depends on a model (Artist) that does not yet exist (ARCH-09).
- It introduces a new architectural layer —
ArtistEngagement— that did not exist in v0.1: artists are now org-level, engagements are per-event. This is a deliberate departure from SCHEMA §3.5.7's single-artists-per-event model and resolves backlog ART-03. - It bridges concerns festival operators conflate but Crewli must keep distinct: artists (master), engagements (per-event), stages (physical), sub-events (program units), advancing (per-engagement workflow), and Form Builder (the rider/advance data layer).
- It introduces interaction patterns (drag-drop Gantt with server-transactional cascade-bump, cross-day stage variance, popover with advancing aggregate, keyboard-accessible blocks) that do not exist off-the-shelf in the current frontend stack.
- The booker-experience features (Buma handling, option expiry, crew & guestlist contingent, fee deal info) are not optional polish — they are what make the module usable at festival scale. v0.1 underscoped these.
This RFC captures every architectural decision so implementation
does not re-litigate them under time pressure. The PoC iterations
(prototype at ./resources/Crewli - Artist Timetable Management/)
are visual references — this document is authoritative.
3. Scope & non-scope
3.1 In scope (v1)
Backend (Sessions 1–3):
- Org-level
artiststable +artist_engagementsper-event table - Org-level
genresconfigurable list stages(festival-level or flat-event-level),stage_days(pivot to sub-events or flat event),performances(sub-event-scoped)- 9-status enum on engagements (incl.
rejectedvsdeclined) - Buma + VAT + deal info (fee, deposit, payment status, crew/guests)
- Option-expiry mechanism (scheduled job)
- Form Builder integration via
artist_advancepurpose, section-level submit, portal_token per engagement - Server-side transactional cascade-bump endpoint
- Server-side lane resolution on read
- Optimistic locking via
versioncolumn - Conflict detection (same-stage, same-day, same-lane), B2B detection
(≤5 min gap), capacity warning (
draw > capacity * 1.1) - Wachtrij = nullable
stage_idfilter (no separate table) - Activity log on all mutations
- Multi-tenancy via
OrganisationScope(FK-chain)
Frontend (Sessions 4–6):
- Horizontal Gantt timetable with day-tabs (one tab per sub-event, no tabs for flat events)
- Drag/drop performance: re-time, re-stage, re-lane in one gesture
- Resize duration (snap 15 min, min 15 min)
- Click-to-add performance (modal) and drag-to-create (inline ghost)
- Detail popover with advancing aggregate, status switch, action buttons
- Wachtrij sidebar with search, status multi-select, genre filter
- "Stages per dag" matrix bulk editor
- Stage editor (single-stage CRUD with cascade-to-wachtrij on delete)
- Stage reorder (drag stage rows)
- Empty-day copy-from-other-day affordance
- Engagement detail page (
/events/{event}/artists/{engagement}) with tabs: Overview, Performances, Advancing, Contacts, Crew & Guests, Activity log - Portal page (
/p/artist/{token}) for tour manager - Master Artist Library (
/organisation/artists) — list with search, filter, sort, pagination, "Toon verwijderde" toggle - Master Artist Detail (
/organisation/artists/{artist}) — tabs: Overview (master fields), Engagements (cross-event history), Contacts (master CRUD), Activity log - Trashed-artist visual markers on all engagement / timetable surfaces (D27)
- Keyboard a11y model (focusable blocks, arrow nudges, etc.)
- CSS-tokens for status colours (theme-overridable)
- Optimistic updates with version-check rollback + toast
3.2 Out of scope (v1) — backlog items
| ID | Item | Notes |
|---|---|---|
| ART-04 | Tech-rider warnings vs stage-spec | Requires parsed-rider fields + stage tech-spec columns |
| ART-05 | PDF/print A3 running order | |
| ART-06 | Bulk shift on canvas (multi-select +30 min) | |
| ART-07 | Undo/redo stack | |
| ART-08 | Cross-stage artist double-booking warning | Surfaces on artist detail, not timetable |
| ART-10 | Venue-as-row variant (cultuurnacht model) | Schema accommodates this without changes |
| ART-11 | Mission Control artist check-in | Separate module on show-day |
| ART-12 | Counter-offers / negotiation log | |
| ART-13 | Routing intelligence (home-base clustering) | |
| ART-14 | Per-tenant status colour overrides | CSS-tokens enable this; activation deferred |
| ART-15 | Realtime broadcasts (Reverb) | Wait until notification framework |
| ART-16 | Hospitality auto-aggregation dashboard | Per-day catering totals |
| ART-17 | Mobile-optimised view | Read-only list on mobile |
| ART-18 | Stage templates across events | Parallel to ARCH-10 sectie templates |
| ART-19 | Public reveal scheduling (line-up phasing) | |
| ART-20 | A1 / work permit tracking for international acts | |
| ART-21 | Cost-control dashboard | Total fee budget vs draw projection |
| ART-22 | Cross-edition analytics | "Mau P 2025 vs 2026 metrics" |
| ART-23 | Mailbox-thread per booking | Agent correspondence in-app |
| ART-24 | GDPR hard-erasure flow voor artist + contacten | Alleen op explicit data subject request; runbook-driven |
| FIN-01 | Buma export per festival (CSV) | |
| FIN-02 | Buma forecast widget on org dashboard |
4. Locked design decisions
26 decisions, organised into architecture (D10–D17, D22), interaction (D1, D5, D7, D13, D18, D20), schema details (D9, D23, D24, D26), integration (D2, D3, D11, D15), rendering (D6, D8, D21, D25), and technical contracts (D4, D14, D16, D19).
D1 — Block visual: stage-stripe + status-fill
Each performance block carries:
- Background fill = engagement.booking_status colour (9 colours, see §10.1)
- 3 px left stripe =
stages.colorfor stage-grouping cue - Right grab handle for resize
Rationale unchanged from v0.1.
D2 — Advancing aggregate on artist_engagement, not artist
Revised from v0.1. The aggregate
{advancing_completed_count, advancing_total_count} is denormalised
on artist_engagements, not on artists. An artist can have multiple
engagements (org-level master); advancing is per-event.
Computed via observer on advance_section create/update/delete:
n = count(advance_sections WHERE engagement_id = X
AND submission_status = 'approved')
m = count(advance_sections WHERE engagement_id = X)
Stored as advancing_completed_count + advancing_total_count int
columns on artist_engagements. Avoids N+1 on timetable load.
D3 — Manage booking opens engagement page (not artist master)
Revised from v0.1. Click "Open detailpagina →" in popover →
navigate to /events/{event}/artists/{engagement}?return=timetable &day=fri&t=210000.
The page shows event-specific data: deal info + Buma, performances
within this event, advancing sections + status, contacts, crew &
guestlist, activity log. A small "→ Master profile" link in the
header goes to /organisation/artists/{artist} for cross-event view
(power-user only, not v1 daily driver).
The URL path uses /artists/ for SEO-friendly naming; the parameter
itself is the engagement ULID. Bookmarkability + cmd-click-to-new-tab
preserved as in v0.1.
D4 — Stage-day filtering enforced everywhere
Performance for sub-event D shows only stages where stage_days (stage_id, event_id=D) exists. Performances scheduled on
(stage_id, sub_event_id=D) where the pivot does not exist are
hidden but not deleted — toggling a stage off must be reversible
without data loss.
Enforced at: API list endpoint (SQL filter), frontend rendering
(defensive trust-but-verify), FormRequest on create
(StageActiveOnSubEvent rule rejects with 422).
D5 — Conflict detection scope: same-stage, same-day, same-lane
Refined. Two non-cancelled performances with the same
stage_id, same sub_event_id, same lane, and overlapping time
(p1.end_at > p2.start_at AND p1.start_at < p2.end_at) → conflict.
- Different lanes on same stage = NO conflict (lane semantics permit parallel acts; e.g. one DJ + one MC)
- Different stages = NO conflict (cross-stage warning sits on artist detail page per ART-08)
- Cancelled performances participate in NO conflict check
- Conflict produces a 200 response with
warnings: ["overlap"]— the booker decides whether to resolve
Warning rendering: red border + warn icon + count badge on stage row + total in header pill + footer stat (per prototype audit §4.8).
D6 — B2B marker rule
Two consecutive non-cancelled performances on the same stage with
p2.start_at - p1.end_at ∈ [0, 5] minutes get a B2B marker (small
dot at the boundary).
Lane-aware (changeover is per-lane, not per-stage). Pure rendering — no schema impact.
D7 — Drag-drop interaction model
- Drag block horizontally → re-time (snap to 15 min)
- Drag block vertically → re-lane within stage OR re-stage to adjacent row
- Drag right edge → resize duration (snap to 15 min, min 15 min)
- Drag onto wachtrij column → park (set
stage_id = null) - Drag from wachtrij card onto canvas → schedule (set
stage_id + start_at + end_at + lane) - Drag is committed on mouseup with single transactional API call (see D18)
- Failed call (validation error, version mismatch, transactional conflict) reverts block to its origin position with a toast and refetches the day's timetable
Click-after-drag suppression: 3 px Manhattan threshold in the move
case, 4 px in the wachtrij case. Capture-phase one-shot listener
suppresses the synthetic click. Implemented once as useDragOrClick
composable (not three times like the prototype).
D8 — Custom Vue components, not FullCalendar
Confirmed unchanged from v0.1. Three blockers: licence, render-hook fighting, cross-day stage filtering mismatch with FC's resource model. Custom Vue components on top of HTML5 PointerEvents give total control. Estimate: ~5-6 frontend days for the rendering core (prototype audit confirms this is realistic).
D9 — engagement.booking_status is 9-state PHP enum
Revised from v0.1's 6-state. Eight statuses + option_expires_at
mechanism:
namespace App\Enums\Artist;
enum ArtistEngagementStatus: string {
case Draft = 'draft'; // interne wens, nog geen contact
case Requested = 'requested'; // aangevraagd bij agent
case Option = 'option'; // hold gezet (require option_expires_at)
case Offered = 'offered'; // offer uit (fee + rider verstuurd)
case Confirmed = 'confirmed'; // mondeling/email bevestigd
case Contracted = 'contracted'; // contract getekend
case Cancelled = 'cancelled'; // was rond, nu niet meer
case Rejected = 'rejected'; // wij wijzen af (eindstatus)
case Declined = 'declined'; // agent zei nee (eindstatus)
}
Pending from the prototype = Requested in this enum (per D22).
Cancelled ≠ Declined ≠ Rejected — three distinct end-states
for reporting.
Options must have an option_expires_at datetime (validated by
UpdateArtistEngagementRequest when status=Option). Scheduled job
DemoteExpiredOptions runs daily, demotes expired options to
Draft with activity log entry + notification to project leader.
D10 — Org-level Artist + per-event ArtistEngagement
The fundamental architectural shift. Replaces v0.1's "artists.event_id" model.
artists # Master record (org-level)
id ULID PK
organisation_id ULID FK → organisations
name string
slug string (unique per org, used for URLs)
default_genre_id ULID FK nullable → genres
default_draw int nullable # expected pull
star_rating tinyint nullable (1–5)
home_base_country string(2) nullable # ISO 3166-1 alpha-2
agent_company_id ULID FK nullable → companies (type=agency)
notes text nullable
deleted_at timestamp nullable
artist_engagements # Per-event booking
id ULID PK
organisation_id ULID FK → organisations (denormalised)
artist_id ULID FK → artists
event_id ULID FK → events (festival or flat event)
booking_status enum (D9)
project_leader_id ULID FK nullable → users
fee_amount decimal(10,2) nullable
fee_currency string(3) default 'EUR'
fee_type enum nullable
buma_applicable bool default true
buma_percentage decimal(5,2) default 7.00
buma_handled_by enum (D26)
vat_applicable bool default true
vat_percentage decimal(5,2) default 21.00
deal_breakdown JSON nullable
deposit_percentage decimal(5,2) nullable
deposit_due_date date nullable
balance_due_date date nullable
payment_status enum default 'none'
crew_count int default 0
guests_count int default 0
requested_at datetime nullable
option_expires_at datetime nullable
advance_open_from datetime nullable
advance_open_to datetime nullable
portal_token ULID unique nullable # tour-manager portal
advancing_completed_count int default 0 # cached, observer-maintained
advancing_total_count int default 0 # cached, observer-maintained
notes text nullable
deleted_at timestamp nullable
Field-allocation rationale (master vs engagement):
- On
artists(master): name, default genre/draw, star_rating, agent company, home base, notes that travel across events - On
artist_engagements(per-event): booking_status, fee + deal info, milestones, advancing, portal_token, project leader, crew/ guests counts
milestone_flags from SCHEMA.md §3.5.7's planned design is
replaced by status enum transitions + activity log. The eight
boolean toggles in the original schema were a state-machine in
disguise.
UNIQUE(artist_id, event_id) — one engagement per artist per event.
Multiple performances under one engagement (D17).
D11 — Stages festival-level, performances sub-event-level
For a festival (event_type='festival', has children):
- Stages hang on
stages.event_id = festival.id(the parent) stage_days.event_idreferences sub-events (the children)- Performances hang on
performances.event_id = sub_event.id
For a flat event (event_type='event', no parent, no children):
- Stages hang on
stages.event_id = event.id(itself) stage_days.event_id = event.id(one row per stage activating the event itself — kept for schema uniformity)- Performances hang on
performances.event_id = event.id
For a series (event_type='series', has children): same as festival,
each edition is a "show host" that parallels a sub-event.
The timetable view route is always at the highest level
(/events/{festival}/timetable or /events/{event}/timetable for
flat). Day-tabs are an in-page filter UI element, not separate routes.
D12 — Wachtrij = nullable stage_id filter (no separate table)
Confirmed from prototype audit §7.5. performances.stage_id is
nullable. stage_id IS NULL ⇔ parked/wachtrij. Drag from wachtrij
to canvas = PATCH with stage_id + start_at + end_at + lane.
Drag from canvas to wachtrij = PATCH with stage_id = NULL.
Indexing: INDEX (event_id, stage_id, start_at, end_at) — works
with WHERE stage_id IS NULL for wachtrij query.
No separate parked_performances table. No origin field. The
activity log carries provenance.
D13 — Lanes IN v1 with cascade-bump
performances.lane (unsigned tinyint, default 0). 0-indexed,
grows downward inside a stage row.
Two-pass lane resolution:
- Pass 1 (explicit): items with non-null
lanego to that lane (bumped down on conflict) - Pass 2 (auto): items with null
lanego to lowest free lane
Both passes run server-side on read (per D19), result is
exposed as lane_resolved on the PerformanceResource. Client
runs Pass 2 only for optimistic-update preview during drag; the
authoritative value comes from the server response after PATCH.
Cascade-bump: when a block is dropped onto a busy lane × time slot,
the existing block bumps to lane + 1, recursively. Implemented
transactionally on the server (per D18) — not as N sequential
client PATCHes like the prototype.
D14 — Optimistic locking via version column
performances.version (int, default 0, bumped on every UPDATE).
Client sends version in PATCH body. Server returns:
- 200 + new resource (with bumped version) on success
- 409 Conflict with
{conflict: 'version_mismatch', current_version: N, server_data: {…}}on mismatch
Client on 409: shows toast "Iemand anders heeft dit zojuist aangepast — venster ververst"; refetches day timetable; reverts block to origin position.
Necessary because two programmers in the same timetable is reality at festival scale.
D15 — Form Builder integration via artist_advance purpose
The advance/rider data layer is not a new artist_riders table.
Reuse the existing artist_advance purpose (ARCH-FORM-BUILDER §3.2.5):
- One
form_schemaper organisation, purpose=artist_advance,section_level_submit=true, seeded fromArtistAdvanceDefault(Session 3) - Sections: General Info, Contacts, Production, Technical Rider, Hospitality (organisation may add custom sections)
- Per
artist_engagement: oneform_submissionkeyed byengagement.portal_token - Tour manager fills sections via
/p/artist/{token}portal advance_sectionsrows link toengagement_id(notartist_idas currently planned in §3.5.7) — this is the schema correctionadvancing_completed_counton engagement = count ofadvance_sections WHERE submission_status='approved'for that submission
form_submissions.subject_type='artist' + subject_id=artist.id
(master) is preserved. form_submissions.event_id (denormalised
per WS-4) provides the engagement context. The ArtistResolver:: fromPortalToken resolver looks up the engagement from
portal_token, returns the artist as subject, populates event_id
from engagement.
D16 — Realtime broadcasts out of v1
No Echo channels, no Reverb broadcasts in v1. Defer to ART-15 (after notification framework lands, after Accreditation Engine).
Single-user happy path is the v1 target. Multi-user collision is caught by D14 optimistic locking.
D17 — Engagement → Performance is 1:N
One artist_engagement can have multiple performances. Booker
decides whether multi-show bookings are one engagement (single deal,
one fee, one advancing trajectory) or multiple engagements (separate
deals, separate fees).
Common scenarios:
| Scenario | Engagements | Performances |
|---|---|---|
| Single set | 1 | 1 |
| Two sets same day, different stages, one weekend deal | 1 | 2 |
| Friday + Saturday under one combined deal | 1 | 2 |
| Friday via Mojo + Saturday via Friendly Fire (separate fees) | 2 | 2 |
Cancel engagement → all performances cascade soft-delete. Cancel single performance within active engagement → that performance soft-deleted, engagement keeps running.
Same-artist conflict warnings (per ART-08, deferred):
- Cross-stage overlap within same engagement → warn
- Cross-stage overlap across engagements with same artist_id → warn
D18 — Server-side transactional move endpoint
The cascade-bump algorithm runs in a single DB transaction on the backend, not as N sequential PATCHes. New endpoint:
POST /api/v1/events/{event}/timetable/move
Body: {
performance_id: ULID,
target_stage_id: ULID | null, # null = park
target_start_at: datetime | null,
target_end_at: datetime | null,
target_lane: int | null, # null = auto-pack server-side
version: int # optimistic lock on the dragged perf
}
Response 200: {
performance: PerformanceResource,
cascade: PerformanceResource[] # all bumped siblings (incl. new versions)
}
Response 409: { conflict: 'version_mismatch', current_version, server_data }
Response 422: validation errors
Server logic:
BEGIN TRANSACTIONSELECT … FOR UPDATEon the dragged perf and all stage-day siblings (lock to prevent concurrent cascades)- Validate
versionmatches → 409 if not - Apply
target_*fields, run cascade-bump algorithm - Bump
versionon every modified row - Activity-log entries: one parent
performance.movedevent with cascade-count metadata, linked child entries for bumped siblings COMMIT
Idempotency-Key required (per ARCH §10), 24-hex from UUID v4.
Ordinary PATCH /performances/{id} remains for status/notes/etc.
edits that do not affect placement.
D19 — Server-side lane resolution on read
PerformanceResource returns both lane (raw, persisted) and
lane_resolved (after Pass 1 + Pass 2 for the entire stage-day).
Two clients viewing the same timetable see identical layouts.
Client uses lane_resolved for rendering; lane only for editing
(in StageEditor advanced view, never v1 default).
D20 — Keyboard a11y model
The prototype is mouse-only. Crewli production requires a11y. The v1 keyboard model:
| Key | Action |
|---|---|
| Tab | Move focus through stages → blocks → wachtrij → header controls |
| Arrow ←/→ | Selected block: nudge ±15 min |
| Shift+Arrow ←/→ | Nudge ±60 min |
| Arrow ↑/↓ | Selected block: change lane within stage; at lane boundary, jump to adjacent stage row (skip if not active on day) |
| Enter / Space | Selected block: open popover |
| Escape | Close popover OR cancel drag (if active) OR blur block focus |
| Delete | Selected block: confirm dialog → delete |
[ / ] |
Selected block: move to previous/next stage (preserves time + lane) |
| Cmd/Ctrl+Z | Undo last move (deferred to ART-07; v1 toast says "Undo: Cmd+Z (binnenkort)") |
aria-label on blocks: "{artist}, {stage}, {start_time}–{end_time}, status {status}, advancing {n}/{m}".
role="application" on the canvas with screen-reader instructions
hidden via cw-sr-only. Focus ring uses --accent colour, 2 px
solid, 2 px offset.
Keyboard-driven drag is implemented as a discrete "move mode": Enter on a focused block enters move mode (announced via aria-live); arrows move block; Enter commits, Escape cancels.
D21 — Status colours via CSS tokens
Per-status colours live in apps/app/src/styles/tokens/timetable.scss
as CSS custom properties:
:root {
--tt-status-draft-bg: #f1efe9;
--tt-status-draft-border: #dcd9d1;
--tt-status-draft-fg: #3a3830;
--tt-status-draft-dot: #a09c92;
--tt-status-requested-bg: #fff6e0;
--tt-status-requested-border:#f0d99a;
--tt-status-requested-fg: #5d4612;
--tt-status-requested-dot: #d9a93c;
--tt-status-option-bg: #f3eefa;
/* … */
--tt-status-confirmed-bg: #e8f8f0;
/* … */
--tt-status-contracted-bg: #e6f1fb;
/* … */
--tt-status-cancelled-bg: #f5f3ef;
/* hatched overlay via CSS gradient */
/* … */
--tt-status-rejected-bg: #fbeaec;
/* … */
--tt-status-declined-bg: #f7eee9;
/* … */
--tt-status-offered-bg: #fef5e7;
/* … */
}
PerformanceBlock.vue consumes via style="background-color: var (--tt-status-{{status}}-bg)". Per-tenant override (ART-14) deferred:
the system can later inject org-specific colours by setting the
custom properties on a parent [data-org-id] selector.
D22 — Pending = booking_status='requested' (no separate table)
The prototype's pending collection (a third runtime set besides
performances and parked) is NOT modelled in production. What the
prototype calls "pending" is conceptually identical to "we have
asked the agent, awaiting answer" — that is exactly status
requested in the 9-state enum (D9).
requested_at datetime + notes text on artist_engagements
captures everything the prototype's pending row carried (id,
artist_id, requested_on, note).
Wachtrij filtering: a requested-status engagement with no
performances yet shows up in wachtrij as a card. Drag onto canvas =
PATCH adds the first performance with stage_id + start_at + end_at + lane. Status stays requested until booker manually
moves it to option/confirmed.
D23 — stages.sort_order for persistent reorder
Stages can be drag-reordered (drag the stage row's left handle).
Reorder persists via stages.sort_order (int, default 0). Endpoint:
PATCH /events/{event}/stages/order with {stage_ids: ULID[]}
replaces the order atomically.
Default order: by creation timestamp. Bookers reorder to match physical walk-through, headliner priority, or genre clustering.
D24 — Genre via org-level genres table
genres
id ULID PK
organisation_id ULID FK → organisations
name string(40)
color string(7) nullable # optional accent
sort_order int default 0
is_active bool default true
UNIQUE(organisation_id, name)
Each organisation maintains its own genre list. System-seeds nothing
— org chooses what is relevant (Hardstyle / Techno / Hollands /
classical / cabaret / DJ-set / live-band / podcast-recording).
Configurable via /organisation/settings/genres.
artists.default_genre_id is the master assignment. Engagements do
NOT override genre per event (an artist's genre doesn't change
between bookings).
Modal "Add performance" pre-fills genre from the master artist; the modal does not allow genre editing here (genre edits happen on the artist master page).
D25 — Capacity warning + B2B detection IN v1
Both included. They are visible features in the prototype; bookers will miss them otherwise.
- Capacity warning: computed client-side per block via
artist.default_draw > stage.capacity * 1.1. Renders an orange triangle icon in the block's warn cluster + tooltip "Verwachte trekkracht (X) overschrijdt zaalcapaciteit (Y)". - B2B marker: computed client-side per stage-day via the
findB2Balgorithm (≤5 min gap between consecutive performances same lane). Renders a small dot at the boundary edge.
Both are derivations only — no schema impact, no API additions. For festivals with 100+ performances per day, computation runs in ~1ms (per prototype audit §7.16). Server-side computation deferred to ART-04 if performance issues arise.
D26 — Buma + VAT handling per engagement
Buma (rights collection society NL) is 7% of fee. VAT is 21%. Three configurations matter:
namespace App\Enums\Artist;
enum BumaHandledBy: string {
case Organisation = 'organisation'; // wij dragen 7% af
case BookingAgency = 'booking_agency'; // boekingskantoor doet het
case NotApplicable = 'not_applicable'; // non-music acts
}
Defaults on engagement creation: buma_applicable=true,
buma_percentage=7.00, buma_handled_by=Organisation,
vat_applicable=true, vat_percentage=21.00.
Auto-flip rule: when agent_company_id on the artist points to
a companies row with handles_buma=true, the engagement form
defaults buma_handled_by=BookingAgency. Booker can override.
Derived calculations (live, not stored):
buma_amount = fee_amount × buma_percentage / 100
(only if buma_applicable AND buma_handled_by=Organisation)
vat_grondslag = fee_amount + (buma_amount if Organisation else 0)
vat_amount = vat_grondslag × vat_percentage / 100 (if vat_applicable)
total_cost = fee_amount + buma_amount + vat_amount + sum(deal_breakdown)
companies table gets a new column handles_buma (bool, default
false). Migration adds it; existing rows default to false.
Backlog: FIN-01 (Buma export per festival), FIN-02 (forecast widget).
D27 — Soft-delete preserves engagements; visual marker
Soft-deleting a master Artist (set deleted_at) does NOT cascade
to artist_engagements. Historical engagements remain fully visible
and functional — those bookings happened in the past, that history
must be preserved. Future engagements of a soft-deleted artist also
remain visible and editable, but render with a clear "verwijderd"
banner so the booker knows the master record has been archived.
Behavior matrix:
| Surface | Trashed artist behaviour |
|---|---|
| Master Artist Library list | Default hidden. Toggle "Toon verwijderde" reveals with red strike-through name + "Verwijderd op {date}" badge. |
| Master Artist Detail page | Accessible via direct URL. Prominent banner: "Deze artiest is verwijderd op {date} door {user}. {N} historische boekingen blijven beschikbaar." |
| Engagement detail page (organizer) | Banner above artist info: "⚠ Deze artiest is verwijderd uit de organisatie-bibliotheek." Engagement remains fully editable. |
| Performance block (timetable) | Trashed-artist marker: dashed border + small archive-icon overlay in the warn cluster. Hover tooltip explains. |
| Add Engagement modal — artist dropdown | Filter WHERE deleted_at IS NULL. No checkbox to include trashed (deliberate friction; restore via Master Library is the correct path). |
| Cross-event analytics, "active roster" counts | Trashed artists excluded. |
| Cross-event analytics, historical aggregates | Trashed artists included (the bookings happened). |
Restore: POST /organisations/{org}/artists/{artist}/restore
unsets deleted_at. Permission: organisations.manage_artists.
Activity log captures both delete and restore events. Once restored,
all banners disappear immediately on next render.
Hard-delete blocking: there is no hard-delete endpoint for artists
in v1. deleted_at is the terminal state. GDPR erasure is a separate
flow (handled via the existing Person erasure runbook pattern, scoped
to the artist's contact rows when needed — backlog ART-24).
5. Schema impact
This is a substantial migration. Sessions 1 sets up new tables; the
existing planned §3.5.7 schema is partly replaced.
5.1 New tables
artists # NEW — org-level master
artist_engagements # NEW — per-event booking
genres # NEW — org-level configurable list
stage_days # NEW — pivot stage × event (sub-event or flat)
performances # NEW — per-engagement scheduled show
advance_sections # NEW — engagement-scoped (was artist-scoped in §3.5.7 plan)
advance_submissions # NEW
artist_contacts # NEW — master contacts (FK to artist, not engagement)
Tables NOT created (from §3.5.7's planned design):
artist_riders — replaced by Form Builder (artist_advance purpose)
itinerary_items — out of scope v1 (could become Form Builder later)
5.2 Modified tables
companies
+ handles_buma bool default false
(existing 'agency' value in type enum is reused unchanged)
5.3 Detailed table specs
artists (replaces planned artists in §3.5.7)
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
organisation_id |
ULID FK | → organisations |
name |
string(120) | |
slug |
string(120) | for URL paths |
default_genre_id |
ULID FK nullable | → genres |
default_draw |
int nullable | expected pull (capacity-warn input) |
star_rating |
tinyint nullable | 1–5 |
home_base_country |
string(2) nullable | ISO 3166-1 alpha-2 |
agent_company_id |
ULID FK nullable | → companies (type=agency) |
notes |
text nullable | |
created_at, updated_at, deleted_at |
soft delete |
Unique: UNIQUE(organisation_id, slug)
Indexes: (organisation_id, name), (default_genre_id),
(agent_company_id)
Soft delete: yes
Scope: OrganisationScope (direct FK)
artist_engagements
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
organisation_id |
ULID FK | → organisations (denormalised for scope) |
artist_id |
ULID FK | → artists |
event_id |
ULID FK | → events (festival or flat event) |
booking_status |
enum | D9 — 9 values |
project_leader_id |
ULID FK nullable | → users |
fee_amount |
decimal(10,2) nullable | |
fee_currency |
string(3) default 'EUR' | |
fee_type |
enum nullable | flat/door_split/guarantee_plus_split |
buma_applicable |
bool default true | |
buma_percentage |
decimal(5,2) default 7.00 | |
buma_handled_by |
enum | D26 — 3 values |
vat_applicable |
bool default true | |
vat_percentage |
decimal(5,2) default 21.00 | |
deal_breakdown |
JSON nullable | opaque line-items |
deposit_percentage |
decimal(5,2) nullable | |
deposit_due_date |
date nullable | |
balance_due_date |
date nullable | |
payment_status |
enum default 'none' | none/deposit_paid/paid_in_full |
crew_count |
int default 0 | |
guests_count |
int default 0 | |
requested_at |
datetime nullable | when status set to Requested |
option_expires_at |
datetime nullable | required when status=Option |
advance_open_from |
datetime nullable | |
advance_open_to |
datetime nullable | |
portal_token |
ULID unique nullable | tour-manager portal |
advancing_completed_count |
int default 0 | observer-maintained |
advancing_total_count |
int default 0 | observer-maintained |
notes |
text nullable | |
created_at, updated_at, deleted_at |
soft delete |
Unique: UNIQUE(artist_id, event_id), UNIQUE(portal_token)
Indexes: (organisation_id), (event_id, booking_status),
(option_expires_at) (for the demote-job)
Soft delete: yes (cascades to performances, advance_sections via observer)
Scope: OrganisationScope (direct FK on organisation_id)
stages (replaces planned stages in §3.5.7)
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
event_id |
ULID FK | → events (festival or flat event) |
name |
string(120) | |
color |
string(7) | hex |
capacity |
int nullable | for capacity-warn |
sort_order |
int default 0 | D23 |
created_at, updated_at |
Unique: UNIQUE(event_id, name)
Indexes: (event_id, sort_order)
Soft delete: no (per v0.1 §5; deleting a stage is rare and
destructive; soft-delete adds query complexity without safety win)
Scope: OrganisationScope (FK-chain via event)
stage_days (revised: event_id instead of day_date)
| Column | Type | Notes |
|---|---|---|
id |
int AI | PK — integer for join performance |
stage_id |
ULID FK | → stages |
event_id |
ULID FK | → events (sub-event or flat event = "show host") |
Unique: UNIQUE(stage_id, event_id)
Indexes: (event_id)
Soft delete: no (pure pivot)
The pivot semantics: for a festival, each stage has multiple
stage_days rows (one per active sub-event). For a flat event,
each stage has exactly one stage_days row referencing the event
itself. This uniformity simplifies the query layer.
performances (replaces planned performances in §3.5.7)
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
engagement_id |
ULID FK | → artist_engagements |
event_id |
ULID FK | → events (sub-event or flat event = "show host") |
stage_id |
ULID FK nullable | → stages; NULL = parked/wachtrij |
lane |
unsigned tinyint default 0 | D13 |
start_at |
datetime | ⩾ event.start_at, < event.end_at |
end_at |
datetime | > start_at, ⩽ event.end_at |
version |
int default 0 | D14 — optimistic lock |
notes |
text nullable | |
created_at, updated_at, deleted_at |
soft delete |
Indexes: (event_id, stage_id, start_at, end_at),
(engagement_id), (stage_id, start_at) for the lane resolver
Soft delete: yes (cascade with engagement via observer)
Scope: OrganisationScope (FK-chain via event → engagement)
genres
Spec in D24.
advance_sections (revised: engagement_id, not artist_id)
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
engagement_id |
ULID FK | → artist_engagements (was artist_id) |
name |
string(80) | |
type |
enum | guest_list/contacts/production/custom |
is_open |
bool default false | |
open_from |
datetime nullable | |
open_to |
datetime nullable | |
sort_order |
int default 0 | |
submission_status |
enum | open/pending/submitted/approved/declined |
last_submitted_at |
timestamp nullable | |
last_submitted_by |
string nullable | tour manager name from form |
submission_diff |
JSON nullable |
Indexes: (engagement_id, is_open), (engagement_id, submission_status)
advance_submissions
Unchanged from §3.5.7 plan, FK adjusted to advance_section_id
which now traces to engagement, not artist.
artist_contacts (master-scoped)
| Column | Type | Notes |
|---|---|---|
id |
ULID | PK |
artist_id |
ULID FK | → artists (master) |
name |
string(120) | |
email |
string nullable | |
phone |
string nullable | |
role |
string(60) | tour manager / agent / booker / etc. |
is_primary |
bool default false | one primary per role |
receives_briefing |
bool default false | |
receives_infosheet |
bool default false |
Indexes: (artist_id, role)
V1 keeps contacts on the master artist. Engagement-specific contacts deferred (most contacts persist across events for the same artist).
5.4 Soft delete strategy
| Table | Soft delete | Cascade behaviour |
|---|---|---|
artists |
Yes | NO cascade to engagements (D27). Master record marked deleted_at; engagements remain fully functional with visual "verwijderd" markers. |
artist_engagements |
Yes | Cascade to performances + advance_sections via observer |
performances |
Yes | Direct cascade with engagement |
advance_sections |
No | Hard delete with engagement |
advance_submissions |
No | Audit-immutable; remain for retention compliance |
artist_contacts |
No | Hard delete with artist master |
stages |
No | Performances cascade-park (set stage_id=null) |
stage_days |
No | Pure pivot, hard delete |
genres |
No (use is_active) |
Re-link or block depending on referencing artists |
5.5 Cross-doc updates
SCHEMA.md§3.5.7 fully rewritten in Session 1 (move fromARCH-PLANNED-MODULES.mdper the established pattern)ARCH-FORM-BUILDER.md§3.2.5 (artist_advancelifecycle) updated to reflect engagement-scoped sections instead of artist-scopedARCH-FORM-BUILDER.md§17.3 (purpose registry) gets a footnote about engagement context resolutionBACKLOG.md: ARCH-09 + ART-02 + ART-03 closed; ART-04 through ART-23 + FIN-01 + FIN-02 added
6. Routes & API
All routes scoped to events.{event} with OrganisationScope
enforced via FK-chain.
6.1 Org-level resources
GET /api/v1/organisations/{org}/artists # paginated, search by name/slug, filter by genre/agent_company/has_upcoming_engagements, sort by name|last_engagement_at|first_engagement_at, ?with_trashed=true|trashed_only=true to include soft-deleted
POST /api/v1/organisations/{org}/artists # master create
GET /api/v1/organisations/{org}/artists/{artist} # full detail incl. engagements summary (lifetime + upcoming)
PATCH /api/v1/organisations/{org}/artists/{artist} # master update
DELETE /api/v1/organisations/{org}/artists/{artist} # soft-delete; engagements untouched (D27)
POST /api/v1/organisations/{org}/artists/{artist}/restore # unset deleted_at; logged
GET /api/v1/organisations/{org}/genres # config list
POST /api/v1/organisations/{org}/genres
PATCH /api/v1/organisations/{org}/genres/{genre}
DELETE /api/v1/organisations/{org}/genres/{genre} # block if referenced
6.2 Event-level resources
GET /api/v1/events/{event}/engagements # paginated, search, status filter
POST /api/v1/events/{event}/engagements # create from existing or new artist
GET /api/v1/events/{event}/engagements/{engagement} # full detail
PATCH /api/v1/events/{event}/engagements/{engagement} # status, deal info, etc.
DELETE /api/v1/events/{event}/engagements/{engagement} # cancel + soft-delete
GET /api/v1/events/{event}/stages # ordered by sort_order
POST /api/v1/events/{event}/stages
PATCH /api/v1/events/{event}/stages/{stage}
DELETE /api/v1/events/{event}/stages/{stage} # cascade-park performances
PATCH /api/v1/events/{event}/stages/order # {stage_ids: ULID[]}
PUT /api/v1/events/{event}/stages/{stage}/days # {event_ids: ULID[]} replaces matrix
PUT /api/v1/events/{event}/lineup # bulk matrix replacement
GET /api/v1/events/{event}/performances?day={subevent} # filter by sub-event
GET /api/v1/events/{event}/performances?stage_id=null # wachtrij
POST /api/v1/events/{event}/performances # create (or park if stage_id null)
GET /api/v1/events/{event}/performances/{perf}
PATCH /api/v1/events/{event}/performances/{perf} # status/notes only
DELETE /api/v1/events/{event}/performances/{perf}
POST /api/v1/events/{event}/timetable/move # D18 — transactional cascade
6.3 Engagement sub-resources
GET /api/v1/engagements/{engagement}/advance-sections
POST /api/v1/engagements/{engagement}/advance-sections
PATCH /api/v1/advance-sections/{section} # toggle is_open, etc.
DELETE /api/v1/advance-sections/{section}
GET /api/v1/engagements/{engagement}/contacts # via artist master
POST /api/v1/engagements/{engagement}/portal-token # generate or rotate
DELETE /api/v1/engagements/{engagement}/portal-token # revoke
6.4 Portal endpoints (token-authenticated)
Existing /p/* routes get the artist portal:
GET /api/v1/p/artist/{token} # event + engagement context
GET /api/v1/p/artist/{token}/advance # form_schema + sections
POST /api/v1/p/artist/{token}/advance/sections/{section} # submit one section
GET /api/v1/p/artist/{token}/crew-list # placeholder rows
PATCH /api/v1/p/artist/{token}/crew-list/{person} # tour manager edits crew
PortalTokenMiddleware resolves engagement context from
portal_token and exposes portal_engagement + portal_event
attributes.
6.5 Resource shapes
PerformanceResource:
{
"id": "01HX...",
"engagement_id": "01HX...",
"event_id": "01HX...",
"stage_id": "01HX..." | null,
"lane": 0,
"lane_resolved": 0, // D19
"start_at": "2026-07-10T22:00:00+02:00",
"end_at": "2026-07-11T00:00:00+02:00",
"version": 3,
"warnings": ["overlap", "capacity"], // populated server-side
"engagement": {
"id": "01HX...",
"booking_status": "confirmed",
"advancing_completed_count": 4,
"advancing_total_count": 5,
"artist": {
"id": "01HX...",
"name": "Devin Wild",
"slug": "devin-wild",
"default_genre": { "id": "...", "name": "Hardstyle" },
"default_draw": 4200,
"initials": "DW" // computed by resource
}
},
"stage": {
"id": "01HX...",
"name": "Hardstyle District",
"color": "#e85d75",
"capacity": 4500
} | null
}
Idempotency-Key header required on POST /performances,
POST /timetable/move, POST /engagements. Same pattern as
form_submissions.
7. Frontend architecture
apps/app/src/pages/events/[id]/timetable/
index.vue — page entry, day-tab state, ?day query sync
TimetableGrid.vue — scroll container, time axis, stage rows
StageRow.vue — left cell + draggable performance row + reorder handle
StageHeaderCell.vue — swatch + name + capacity + day chips
PerformanceBlock.vue — single block; emits drag/resize/click; CSS-tokens for status
PerformancePopover.vue — floating detail; teleported; closes on Escape
Wachtrij.vue — sidebar with search + status multi-select + genre pills
WachtrijCard.vue — single parked card
AddPerformanceDialog.vue — modal create
StageEditor.vue — single-stage modal CRUD with cascade preview
LineupMatrix.vue — bulk stages × sub-events editor modal
EmptyDayState.vue — "no stages on this day, copy from..."
apps/app/src/pages/events/[id]/artists/[engagement]/
index.vue — engagement detail page with tabs
OverviewTab.vue — deal info + Buma + crew/guests + status timeline
PerformancesTab.vue — list of performances within event
AdvancingTab.vue — section list with Form Builder section components
ContactsTab.vue — master contacts (read-only mostly)
CrewGuestsTab.vue — crowd_list view + tour-manager-fillable
ActivityLogTab.vue — Spatie activity log filtered by subject
apps/app/src/pages/p/artist/[token]/
index.vue — portal landing
PerformanceCard.vue — show details (time, stage, load-in)
AdvanceFormSection.vue — reuse Form Builder section render
apps/app/src/pages/organisation/artists/
index.vue — Master Artist Library: search, filter sidebar, paginated VDataTable, "Toon verwijderde" toggle
ArtistRow.vue — list row: avatar, name, genre, last engagement, # engagements, status badge if trashed
AddArtistDialog.vue — create master (or pick existing — also reused inline from "+ Performance")
RestoreArtistDialog.vue — restore-confirm with summary of N engagements
apps/app/src/pages/organisation/artists/[artist]/
index.vue — Master Artist Detail with 4 tabs
OverviewTab.vue — name, default_genre, default_draw, star_rating, home_base, agent_company, notes
EngagementsTab.vue — cross-event history table: event, dates, status, fee, performances count, link
ContactsTab.vue — master `artist_contacts` CRUD
ActivityLogTab.vue — Spatie activity log filtered by Artist subject
TrashedBanner.vue — shared banner component (also rendered by EngagementOverviewTab when artist is trashed)
apps/app/src/composables/timetable/
useTimetable.ts — TanStack queries: stages, performances per day
useTimetableMutations.ts — performance CRUD + the move endpoint with optimistic update + rollback
useStageDays.ts — matrix replacement
useDragOrClick.ts — single composable for threshold + suppression (replaces 3-place duplicate)
useLaneCascade.ts — pure preview-only logic during drag (server is authoritative)
usePointerDrag.ts — PointerEvents-based drag (modern replacement for mousedown stack)
useTimetableKeyboard.ts — D20 keyboard model
apps/app/src/composables/engagements/
useEngagement.ts — single-engagement TanStack query
useEngagementMutations.ts — status transitions, deal info edits
apps/app/src/lib/timetable/
snap.ts, conflicts.ts, b2b.ts — pure logic ports of helpers.js
laneAssign.ts — Pass 2 logic for client-side preview
capacity.ts — capacity-warn computation
apps/app/src/types/
artist.ts, engagement.ts, performance.ts, stage.ts (zod schemas)
apps/app/src/api/
artists.ts, engagements.ts, stages.ts, performances.ts, timetable.ts
State pattern:
- TanStack Query owns canonical server state (per-event timetable cached with stale-time 30s; mutations invalidate selectively)
- Pinia store
useTimetableStoreowns ONLY UI state (selectedDay, selectedPerformanceId, popoverPosition, dragState, keyboardFocusId) — never duplicate server data - Optimistic updates on drag/move via TanStack
onMutaterollback pattern; on 409 toast + refetch - Server response from
POST /timetable/movepatches both the moved perf AND every cascade entry into the cache in one operation
Form validation: VeeValidate + Zod on all dialogs + the engagement
detail forms. Schemas mirrored from apps/app/src/types/.
8. Activity log
Spatie ActivityLog with LogsActivity trait on:
Artist(master),ArtistEngagement,Stage,PerformanceGenre(config-level changes)
Logged events:
artist.created,artist.updated,artist.deletedartist_engagement.created,artist_engagement.status_changed,artist_engagement.deal_info_updated,artist_engagement.cancelled,artist_engagement.option_expired(system-causer for the daily job)stage.created,stage.updated,stage.deleted,stage.day_added,stage.day_removed,stage.reorderedperformance.created,performance.updated,performance.deletedperformance.moved— D18 cascade — single parent entry withproperties.cascade_count,properties.cascaded_ids[]performance.parked,performance.unparkedgenre.created,genre.updated,genre.deactivated
Capture before/after on: name, color, capacity (stages); booking_status, fee_amount, buma_handled_by, option_expires_at (engagements); start_at, end_at, stage_id, lane, version (performances).
Activity log entries scoped to organisation + event for the
event audit log filter.
9. Authorization (Policy)
ArtistPolicy:
viewAny(User, Organisation) — member of organisation
view(User, Artist) — same + organisation match
create(User, Organisation) — 'organisations.manage_artists'
update(User, Artist) — same as create
delete(User, Artist) — same as create + no active engagements
ArtistEngagementPolicy:
viewAny(User, Event) — event member + 'events.view_program'
view(User, ArtistEngagement) — viewAny + scope match
create(User, Event) — 'events.manage_program'
update(User, ArtistEngagement) — same as create
delete(User, ArtistEngagement) — same as create
StagePolicy: same as v0.1 §9
PerformancePolicy: same as v0.1 §9
GenrePolicy:
viewAny, view: any org member
create/update/delete: 'organisations.manage_settings'
New permissions:
events.view_program— read-only timetable accessevents.manage_program— full CRUD on engagements/performances/stagesorganisations.manage_artists— master artist CRUDorganisations.manage_settings— genre config
New roles:
program_manager— getsevents.manage_program+organisations.manage_artistsproduction_assistant— getsevents.view_programonly
10. Validation rules
10.1 Status enum (D9)
PerformanceBookingStatus::cases() = [
Draft, Requested, Option, Offered, Confirmed,
Contracted, Cancelled, Rejected, Declined
]
State-transition rules (FormRequest validates):
Optionrequiresoption_expires_atin the futureContractedrequiresfee_amountsetRejectedandDeclinedare terminal (no further transitions)Cancelledcascade-soft-deletes performances (observer)
10.2 CreatePerformanceRequest / UpdatePerformanceRequest
engagement_id required ulid exists:artist_engagements,id
(scoped to event via engagement.event_id chain)
event_id required ulid exists:events,id
(must equal engagement.event_id; must be flat event
OR a sub-event of the engagement.event_id festival)
stage_id nullable ulid exists:stages,id
(if non-null: must satisfy StageActiveOnEvent rule)
start_at required date format Y-m-d H:i:s
must be ⩾ event.start_at
must be < event.end_at
(sub-event bounds enforced via WithinSubEventBounds rule)
end_at required date format Y-m-d H:i:s
must be > start_at
must be ⩽ event.end_at
(no cross-event-window — naturally bounded by event-config)
lane nullable integer min:0 max:9
(10-lane ceiling; can be raised, but ten lanes is
already pathological in practice)
notes nullable string max:1000
Overlap, B2B, capacity-warn are NOT validation errors — they
produce 200 with warnings: [...] array. The booker decides.
10.3 CreateArtistEngagementRequest
artist_id required ulid exists:artists,id (org-scoped)
event_id required ulid exists:events,id (org-scoped, must be
flat event OR festival; cannot be a sub-event)
booking_status required enum (D9)
if Option → option_expires_at required + future
if Contracted → fee_amount required
project_leader_id nullable ulid exists:users,id (must be event member)
fee_amount nullable decimal min:0 max:9999999.99
fee_currency nullable string size:3 in:EUR,USD,GBP,...
buma_applicable boolean default true
buma_percentage decimal min:0 max:100 default 7.00
buma_handled_by enum (D26)
crew_count integer min:0 max:200
guests_count integer min:0 max:1000
10.4 MoveTimetablePerformanceRequest (D18)
performance_id required ulid exists:performances,id (event-scoped)
target_stage_id nullable ulid exists:stages,id (StageActiveOnEvent or null=park)
target_start_at nullable date format Y-m-d H:i:s
required_unless target_stage_id null
WithinEventBounds rule
target_end_at nullable date format Y-m-d H:i:s
required_unless target_stage_id null
after:target_start_at
target_lane nullable integer min:0 max:9
version required integer (optimistic lock)
10.5 Stage-day matrix replacement (PUT /stages/{stage}/days)
event_ids required array min:1
event_ids.* ulid exists:events,id
(each must be sub-event of stage.event_id, OR
if stage.event_id is flat event → must equal stage.event_id)
If the request would remove an event_id with scheduled non-cancelled
performances on that stage, return 409 Conflict with body
{ performances_on_removed_events: [...] }. Frontend shows a
matrix-editor confirmation dialog and resends with
?force_orphan=true. Orphaned performances persist (soft references,
hidden from views) — recovered when the day re-activates.
11. Open questions / future work
No open questions remain at v0.2. All material decisions are locked. Recorded resolutions:
- R1 — Idempotency-Key window on
POST /timetable/move: 60-second window in Redis (not 12-hour MySQL per ARCH §10 default). Rationale: a stale 12-hour replay of a cascade-bump can corrupt timetable state in non-trivial ways. Implementation in Session 2. - R2 — Hard-delete artists: not in v1.
deleted_atis terminal. Real GDPR erasure is a separate flow (backlog ART-24). - R3 — Engagement-specific contacts (not master-scoped): deferred.
V1 keeps all
artist_contactson the masterartistsrecord. If per-event contact divergence becomes operational, split viaartist_engagement_contactspolymorphic table in v2. - R4 — Per-tenant status colour overrides: infrastructure ready via D21 CSS-tokens; activation deferred to ART-14.
Out-of-v1 backlog items are listed in §3.2.
12. Implementation order
Six Claude Code sessions, sequential. Estimated 22-26 days total.
12.0 — Authoritative reference materials for ALL frontend sessions
The interactive prototype at ./resources/Crewli - Artist Timetable Management/ and the prototype audit at
dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md (commit
a57437a) are the authoritative UX/UI blueprint for the
frontend. Claude Code prompts for Sessions 4, 5, and 6 explicitly
require both as required input before writing any component.
What is to be ported, not reinvented:
- Visual layouts (timetable grid, blocks, popover, modals, wachtrij)
- Drag-and-drop snap math and interaction semantics
- Click-vs-drag threshold and click-suppression timing
- Lane assignment + cascade-bump algorithms (verbatim per audit §5)
- Conflict / B2B / capacity-warn detection (verbatim per audit §5)
- Status filter UX (multi-select with counts, "Alle aan/uit", default cancelled-off)
- Stage editor + lineup matrix layout
- Empty-day copy-from-other-day affordance
- Design tokens (colours, dimensions, typography, animations) per audit §6
What is net new for production (not in prototype):
- Multi-tenancy (
OrganisationScope, no cross-tenant data in store) - Real backend persistence with TanStack Query + version-conflict rollback
- Optimistic updates with onError revert
- Server-transactional cascade-bump (D18) + server-side lane resolution (D19)
- Keyboard a11y model (D20) — from-scratch work, not port
- CSS-tokens for status colours (D21), Vuetify/Vuexy integration
- Master Artist Library (Session 6) — no prototype reference exists
- Activity log integration
The audit document quotes verbatim code for the five pure-logic functions and the cascade-bump algorithm. Sessions 4+ port these functions to TypeScript with line-for-line correspondence; new TypeScript types replace JavaScript implicit types but the algorithm shape is identical.
Session 1 — Foundation (3-4 days)
- Migrations (in order):
create_genres_tablecreate_artists_tableadd_handles_buma_to_companies_tablecreate_artist_contacts_tablecreate_stages_table(with sort_order)create_stage_days_table(event_id-based)create_artist_engagements_table(with all 26 columns from D10)create_performances_table(with engagement_id, lane, version, nullable stage_id)create_advance_sections_table(engagement_id-scoped)create_advance_submissions_table
- PHP enums (5 enums per D9, D26, fee_type, payment_status, advance section types)
- Eloquent models with
HasUlids,OrganisationScope,LogsActivitytraits - Observers:
ArtistEngagementObserver(advancing aggregate recompute),PerformanceObserver(version bump, cascade soft-delete) - Factories + DevSeeder integration (festival with 4 stages, 6 artists, ~12 engagements, ~13 performances reproducing the prototype's fixture)
- Update
AppServiceProvider::PURPOSE_SUBJECT_FQCN: string-literal'artist' => 'App\\Models\\Artist'→Artist::class - SCHEMA.md update (move §3.5.7 from
ARCH-PLANNED-MODULES.mdtoSCHEMA.md, fully rewrite to reflect engagement model) - ARCH-FORM-BUILDER §3.2.5 update (engagement-scoped advance)
Session 2 — Backend API + business logic (3-4 days)
- Spatie permissions + roles (
events.view_program,events.manage_program,organisations.manage_artists,organisations.manage_settings; rolesprogram_manager,production_assistant) - Policies (5 of them per §9)
- Services:
ArtistService(master CRUD, duplicate detection by name + slug)ArtistEngagementService(status transitions with state machine validation, defaults from artist master, Buma auto-flip)StageService,StageDayService(atomic matrix replace with orphan-performance handling)PerformanceService(CRUD, park/unpark)LaneCascadeService(the D18 transactional move algorithm withSELECT … FOR UPDATElocks)GenreService(org-level config)
- FormRequests (§10)
- Custom validation rules:
StageActiveOnEvent,WithinEventBounds,OptionExpiresInFuture,ContractRequiresFee - API Resources with
lane_resolvedcomputation (D19) - Controllers (thin)
- Routes (§6)
- Activity log integration (§8)
- Scheduled command
DemoteExpiredOptions(daily, 03:00) - Tests:
- Feature tests for all endpoints (200/201/204/401/403/404/409/422)
- Policy tests (positive + negative)
- Validation tests
- Cascade-bump transaction tests (concurrent moves return 409)
- Status transition tests (rejected → contracted blocked)
- Buma calculation tests
- Option expiry job test
- API.md update
Session 3 — Form Builder integration (1-2 days)
ArtistAdvanceDefaulttemplate seeder with 5 sections (General Info, Contacts, Production, Technical Rider, Hospitality)- Update
ArtistResolver::fromPortalTokento resolve via engagement (was via artist; engagement carries the portal_token now) EngagementPortalControllerwith the/p/artist/{token}/*endpoint family- Wire engagement.event_id into form_submissions via the resolver
- Update
PurposeRegistrydocumentation footnote about engagement context - Tests for portal-flow round-trip
Session 4 — Frontend Timetable (5-6 days)
- Types + Zod schemas (per §7 file structure)
- API client modules
- TanStack composables + Pinia store
- Page entry (
index.vuewith day-tab state + ?day query sync) - Components in dependency order:
- TimeAxis, GridBg
- StageHeaderCell, StageRow
- PerformanceBlock (with CSS tokens for status, capacity icon, B2B dots, conflict ring)
- PerformancePopover (with Form Builder advancing aggregate fetch)
- Wachtrij + WachtrijCard
- AddPerformanceDialog
- StageEditor + LineupMatrix
- EmptyDayState
- Composables:
useDragOrClick(threshold + click-suppression, single source)usePointerDrag(PointerEvents replacement of mousedown stack)useTimetableMutations(move + park + unpark + version-rollback)useTimetableKeyboard(D20 model)
- Pure logic ports from prototype
helpers.jstolib/timetable/ - CSS-tokens file (D21)
- Tests:
- Vitest unit on pure logic (snap, conflicts, B2B, lane preview)
- Component tests on PerformanceBlock (status rendering, drag invokes mutation, optimistic + rollback)
- Integration: full add → drag → resize → park → delete flow with mocked API
- Keyboard a11y: tab order, arrow nudges, escape to cancel drag
- Screen-reader test on focused block aria-labels (axe-core)
Session 5 — Frontend Engagement Detail + Portal (3-4 days)
/events/{event}/artists/{engagement}detail page with 6 tabs- Each tab as separate component (Overview, Performances, Advancing, Contacts, CrewGuests, ActivityLog)
- Buma + VAT live calculation in OverviewTab
- Status timeline component with transition-history rendering
/p/artist/{token}portal pages (reuse Form Builder section render components for Advancing form)- Portal-side: performance card, locatie, hospitality summary, crew/guestlist editor, contact-organisation list
- TrashedBanner shared component (also rendered when artist master
is
deleted_atnon-null per D27) - Tests:
- E2E: organizer creates engagement → opens timetable → drags → opens detail → updates status → opens portal as tour manager → fills advance section → returns to organizer view
- Portal-token validation tests
- Tab switching state preservation
- Trashed-banner appears when artist soft-deleted, disappears on restore
Session 6 — Frontend Master Artist Library (2-3 days)
/organisation/artistslist page:- Vuetify VDataTable with pagination + search by name/slug + filter sidebar (genre multi-select, agent_company, has_upcoming_engagements)
- Sort: name (default), last_engagement_at desc, first_engagement_at
- Action column: edit, soft-delete (with confirm dialog), restore (when trashed)
- "Toon verwijderde" toggle in header (default off)
- "+ Nieuwe artiest" button → AddArtistDialog
/organisation/artists/{artist}detail page with 4 tabs:- Overview: master fields (name, default_genre, default_draw, star_rating, home_base_country, agent_company, notes); inline edit; TrashedBanner if soft-deleted
- Engagements: cross-event history table — event name + dates + booking_status + fee_amount + performance count + link to engagement detail; sortable; future engagements visually distinguished from past
- Contacts: master
artist_contactsCRUD inline editing - Activity log: Spatie activity log filtered by Artist subject
- AddArtistDialog (also reusable inline from "+ Performance" flow when booker types a new act name in the modal)
- RestoreArtistDialog with confirm + summary of "{N} historische boekingen, {M} toekomstige boekingen worden hersteld"
- TrashedBanner shared component (also imported by Session 5)
- Tests:
- Search + filter combinations
- Soft-delete flow: confirm dialog, banner appears on engagement views
- Restore flow: banner disappears
- Trashed artist hidden from AddPerformanceDialog dropdown
- Cannot create new engagement for trashed artist (modal disables)
- Trashed artist visible in EngagementHistoryTable on master detail
- Cross-event analytics correctness
13. Test strategy
13.1 Backend coverage targets
- 100% on
LaneCascadeService(the D18 transactional algorithm) - 100% on
ArtistEngagementService::transition(the 9-state machine) - 100% on Buma calculation derivation
- All policy methods (positive + negative)
- All endpoints (success + 4 error cases each)
Specific scenarios:
- Two concurrent move requests on same performance → second returns 409
- Move-with-cascade where cascade hits a
lane=9block (max boundary) - Cascade-bump on a busy stage where 5+ blocks need bumping
- Option expiry job with mixed expired/active/no-option engagements
- Stage-day matrix replace with orphan-performance handling
- Form Builder section submit via portal_token with engagement context
13.2 Frontend coverage
- Pure: snap math, conflict detection, B2B detection, lane preview, capacity warn, day-filter
- Component: PerformanceBlock renders correctly per booking_status, shows version-conflict toast, optimistic rollback works
- Integration: full add → drag → edit → delete flow with mocked API
- Keyboard a11y: focused block + arrow nudges; escape cancels move
- Screen reader: aria-label on blocks; aria-live on status changes
- Visual: snapshot tests on per-status block rendering
13.3 E2E (Playwright)
- Booker workflow: create engagement → schedule performance → drag to new time → open popover → change status to Contracted → see fee-required validation
- Tour manager workflow: receive portal link → open advance form → fill technical rider section → submit → see updated advancing % in organizer view (after polling)
- Concurrency: two browsers, same timetable, simultaneous drags on different blocks → no data loss
14. Dependencies
14.1 Hard
- ARCH-09 (Artist model) — resolved by Session 1
- New permission
events.manage_programand roleprogram_manager— Session 2 prerequisite - New permission
organisations.manage_artists— Session 2
14.2 Soft
- ART-15 (realtime broadcasts) — would enable multi-user live update but version conflict (D14) covers correctness in v1
- ART-04 (tech-rider parsing) — would deepen capacity warning; not blocking
- Notification framework (planned post-Accreditation) — needed for option-expiry email + advance-due reminders (v1 sends via existing EmailService directly)
14.3 None
- WS-3 (SPA merge) — already complete (verified 2026-05-08)
- Accreditation Engine — fully independent (auto-cascade from Confirmed status to accreditation reservations is a v2 nice-to-have)
- Briefings module — independent
15. Cross-references
dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md(commita57437a) — exhaustive prototype reference for UX/algorithm detailsdev-docs/SCHEMA.md— will be updated by Session 1dev-docs/ARCH-FORM-BUILDER.md§3.2.5, §17.3 — will be footnoted by Session 3dev-docs/AUTH_ARCHITECTURE.md§6 — portal-token flow already in placedev-docs/ARCH-BINDINGS.mdv1.2 — for the Form Builder integration invariantsBACKLOG.md— closing ARCH-09, ART-02, ART-03; opening ART-04 through ART-24, FIN-01, FIN-02
16. Glossary
- Artist (master) — org-level record describing a performer; one per organisation per real-world act. Persists across events.
- ArtistEngagement — per-event booking record; owns deal info, status, advancing, portal_token. Multiple per artist over time.
- Performance — scheduled show on a stage at a time, belonging to an engagement. 1:N with engagement.
- Wachtrij — runtime view of performances with
stage_id IS NULL; not a separate table. - Show host — the event a performance hangs on: a sub-event for festivals/series, or the flat event itself.
- Lane — within-stage vertical slot for parallel performances. 0-indexed.
- Cascade-bump — algorithm that pushes existing blocks down a lane when a new block is dropped on top. Server-transactional.
- Buma — Dutch rights-collection society. 7% of fee, with three handling modes (organisation / booking_agency / not_applicable).
- Advancing — the process of collecting performer data (rider, hospitality, technical needs) via the artist_advance Form Builder purpose, section by section.
17. Document history
| Version | Date | State | Note |
|---|---|---|---|
| v0.1 | 2026-05-08 | Superseded | Initial draft |
| v0.2 | 2026-05-08 | Approved | Engagement model, Buma/VAT, deal info, prototype-audit alignment |
End of RFC v0.2. This document is the authoritative spec for the Artist Timetable & Engagement module across 6 implementation sessions, 22-26 days total. Implementation prompts will reference specific decisions (e.g. "per RFC v0.2 D18") rather than restating them. Frontend prompts (Sessions 4, 5, 6) additionally require the prototype + audit doc as authoritative UX/UI input — see §12.0.