Add design en information for developing the Artist Management module

This commit is contained in:
2026-05-08 16:57:03 +02:00
parent a57437a4b7
commit c9863ee4f8
14 changed files with 7068 additions and 1 deletions

View File

@@ -0,0 +1,530 @@
# RFC-TIMETABLE — Artist Timetable Module
## 1. Status
- **State:** Draft for review
- **Created:** 2026-05-08
- **Version:** v0.1
- **Owner:** Bert Hausmans
- **Origin:** UX brainstorm session 2026-05-08 (Claude Chat) — concept + three PoC iterations + six locked decisions
- **Related:**
- `SCHEMA.md` §3.5.7 (artists, performances, stages, stage_days, advance_sections)
- `BACKLOG.md` ARCH-09 (Artist model — hard prerequisite), ART-02 (Timetable backlog item)
- `ARCH-FORM-BUILDER.md` §17 (artist_advance purpose subject_type)
- `CLAUDE.md` "Order of work" (17-step module-generation sequence)
- `design-document.md` §3.5.10 Database design rules
- `dev-docs/PurposeRegistry` — artist_advance purpose with subject_type=artist
## 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, and a bug here visibly breaks the show. Three
properties make this module non-trivial enough to warrant up-front spec:
1. It depends on a model (Artist) that does not exist yet — see ARCH-09.
2. It bridges three concerns that festival operators conflate but Crewli must
keep distinct: stages (physical), festival_sections (organisational), and
advancing (per-section workflow).
3. The interaction surface (drag-drop Gantt with conflict detection,
cross-day stage variance, popover with advancing summary) does not exist
off-the-shelf in the current frontend stack — the build-vs-buy choice for
the rendering library has multi-week consequences.
This RFC captures every architectural decision so implementation does not
re-litigate them under time pressure. PoC iterations (chat artefacts v1/v2/v3)
are visual references, not authoritative — this document is.
## 3. Scope & non-scope
### In scope (v1)
- CRUD for stages and stage_days (which stage runs which day)
- CRUD for performances (artist on stage at time)
- Horizontal Gantt timetable: stages as rows, time as x-axis, day tabs
- Drag/drop performance: re-time and re-stage in one gesture
- Resize performance duration (snap to 15 min)
- Click-to-add performance from empty grid cell
- Detail popover with avatar, status pill, advancing aggregate, status switch,
delete, manage-booking link
- Conflict detection: same-stage same-day overlapping non-cancelled performances
- Back-to-back marker: ≤5 min gap between consecutive performances on same stage
- "Edit lineup" matrix: bulk toggle stages × days
- Empty-day state: copy-from-other-day affordance
- Activity log on all mutations
- Multi-tenancy via OrganisationScope (FK-chain through event)
### Out of scope (v1)
- **Artist Handling / show-day check-in view** — separate Mission Control module
- **Capacity warning** on performance blocks — requires a new column
(`performances.expected_attendance` or similar). Defer to v2; backlog as
ART-04.
- **Advance section CRUD** — covered by separate Artist Advancing module
(BACKLOG ART-01). The popover only *reads* advance_sections aggregate.
- **PDF / print export** of running order — backlog as ART-05.
- **Multi-select bulk shift** of performances ("move everything on Mainstage
+15 min") — backlog as ART-06.
- **Undo/redo stack** — backlog as ART-07.
- **Mobile-optimised view** — timetable is a desktop tool. Mobile gets a
read-only list view in a later iteration.
- **Stage templates across events** ("copy stages from last year's event")
— backlog as ARCH-10, parallel to ARCH-03 (festival_section templates).
## 4. Locked design decisions
### D1 — Block visual: stage-stripe + status-fill
Each performance block carries:
- Background fill = booking_status colour (6 colours, see §10).
- 3px left stripe = `stages.color` for stage-grouping cue.
- Right grab handle for resize.
Rationale: status-fill alone (PoC v2) lost stage grouping when many stages
shared similar status. The stripe restores the visual stage-cluster signal at
zero cost. (Bert decision 2026-05-08.)
### D2 — Advancing aggregate, not per-section dots
The detail popover shows `n/m completed` (e.g. "3/5 advancing complete") and
the underlying section list as a tooltip on hover. No per-section dots on the
block itself.
Rationale: `advance_sections` is configurable per artist — fixed 5-dot icons
break the moment one artist has 4 sections and another has 7. Aggregate
fraction degrades gracefully across configurations and reads cleanly at small
sizes. (Bert decision 2026-05-08.)
The aggregate is computed as:
```
n = count(advance_sections WHERE artist_id = X AND submission_status = 'accepted')
m = count(advance_sections WHERE artist_id = X)
```
Implementation: cached on `artists` model as a denormalised
`advancing_completed_count` + `advancing_total_count` pair, recomputed via
observer on `advance_section` create/update/delete. Avoids N+1 on timetable
load. To be added in same migration as Artist model (ARCH-09).
### D3 — Manage booking opens artist page (not modal)
Click "Manage booking" in popover → navigate to
`/events/{event}/artists/{artist}?return=timetable&day=fri&t=210000`. The
artist page surfaces all related records (performances, advance_sections,
contacts, riders, itinerary) as tabs. A prominent return banner at top:
"← Back to Timetable (Vr 21:00)" restores scroll/tab context.
Rationale: artist record is too large for modal (5+ relation tables). Page
navigation matches every other Crewli edit surface (shifts, persons,
festival_sections). Bookmarkability + cmd-click-to-new-tab are bonuses
no modal can provide. (Bert decision 2026-05-08.)
### D4 — Stage-day filtering is enforced everywhere
The timetable for day D shows only stages where `stage_days(stage_id, D)`
exists. Performances scheduled on `(stage_id, D)` where `stage_days(...)` does
not exist are **hidden but not deleted** — toggling a stage off a day must be
reversible without data loss.
This is enforced at:
- API list endpoint (filter at SQL level)
- Frontend rendering (defensive — trust API but verify)
- Performance creation — the FormRequest validates that `stage_days`
exists for the requested `(stage_id, performance.date)` and rejects
with 422 otherwise.
### D5 — Conflict detection scope: same-stage same-day overlap only
Cross-stage overlap (same artist on two stages simultaneously) is **not**
flagged in v1. Reason: an artist on two stages at once is rare but legitimate
at small festivals where one DJ runs main set + silent disco simultaneously.
Surface this on the artist page in a future iteration, not on the timetable.
Cancelled performances participate in **no** conflict check.
### D6 — B2B marker rule
Two consecutive non-cancelled performances on the same stage with
`p2.start - p1.end ∈ [0, 5]` minutes get a B2B marker (small dot at the
boundary). Useful for stage managers planning changeover windows.
Pure rendering concern — no schema impact.
### D7 — Drag-drop interaction model
- Drag block horizontally → re-time (snap to 15 min)
- Drag block vertically → re-stage (only across stages active on current day)
- Drag right edge → resize duration (snap to 15 min, min 15 min)
- Drag is committed on mouseup with single PATCH request
- Failed PATCH (validation error, conflict the user wants to refuse) reverts
block to its origin position with a toast
### D8 — Rendering library: custom Vue components, not FullCalendar
**Recommendation: build custom.**
FullCalendar timeline view (`resourceTimelinePlugin`) was the obvious choice
on paper but has three blockers:
1. Premium-licensed (~€600/yr commercial). Acceptable cost but adds
procurement friction and a vendor dependency.
2. The advancing-aggregate badge, stage-stripe, B2B marker, and conflict
ring all require `eventDidMount` render hooks — at that point we're
reimplementing rendering inside FC's render cycle, fighting its DOM.
3. Cross-day stage filtering (D4) does not map cleanly to FC's resource
model. Workaround is per-day resource list rebuild on tab switch, which
defeats incremental-render benefits.
Custom Vue components (TimetableGrid, StageRow, PerformanceBlock,
PerformancePopover, LineupMatrix) on top of native HTML5 drag-and-drop give
total control at ~3 days additional frontend cost. The PoC v3 is already
~80% of the rendering logic. PoC code must be rewritten as proper Vue 3
components (Composition API, `<script setup lang="ts">`) — the PoC is not
production code.
**Open verification before commit:** test drag-and-drop accessibility with
screen reader. If insurmountable, revisit FullCalendar Premium decision.
### D9 — `performances.booking_status` migrates from string to PHP Enum
SCHEMA §3.5.7 currently lists `booking_status` on performances as `string`.
This violates the zero-compromise rule "PHP Enums mandatory for all
type/operator/status fields". Must be promoted to a backed PHP enum during
implementation:
```php
namespace App\Enums\Artist;
enum PerformanceBookingStatus: string {
case Concept = 'concept';
case Requested = 'requested';
case Option = 'option';
case Confirmed = 'confirmed';
case Contracted = 'contracted';
case Cancelled = 'cancelled';
}
```
Same enum is reused on `artists.booking_status` (currently inline string-enum
in migration). Rename of existing column type to use the enum cast in the
Eloquent model. No column-level migration needed — both are already string
columns. SCHEMA.md needs update to reflect this in the implementation prompt.
## 5. Schema impact
### No new tables
All four needed tables exist in §3.5.7: `artists`, `performances`, `stages`,
`stage_days`.
### Two column additions on `artists` (ARCH-09 migration)
```
advancing_completed_count unsigned int default 0
advancing_total_count unsigned int default 0
```
Denormalised aggregate for D2 popover. Recomputed via
`AdvanceSectionObserver`. Both columns indexed only as part of the artist
PK (no separate index needed — read pattern is by artist).
### One enum upgrade
`performances.booking_status` cast to `PerformanceBookingStatus` enum at
Eloquent layer. SCHEMA.md updated to reflect enum constraint in §3.5.7.
### Soft-delete strategy
- `artists`: soft-delete YES (already in schema)
- `performances`: soft-delete YES (cascade with artist via observer; restore
cascades back)
- `stages`: soft-delete NO. Reason: deleting a stage is rare and destructive;
if you mistakenly delete you can recreate. Soft-delete on stages adds
query complexity (joining performances→stages with `withTrashed`) without
meaningful safety win.
- `stage_days`: pure pivot, no soft-delete, hard delete on day-toggle-off.
This is a deviation from the §3.5.10 default ("artists soft-delete yes,
stages not listed") — explicitly noted here.
## 6. Routes & API
All routes scoped under `events.{event}` with `OrganisationScope` enforced
via FK-chain (stage.event.organisation_id, performance.stage.event.organisation_id).
```
GET /api/v1/events/{event}/stages
POST /api/v1/events/{event}/stages
GET /api/v1/events/{event}/stages/{stage}
PATCH /api/v1/events/{event}/stages/{stage}
DELETE /api/v1/events/{event}/stages/{stage}
PUT /api/v1/events/{event}/stages/{stage}/days
Body: { day_dates: ["2026-07-10", "2026-07-11"] }
Replaces stage_days for stage atomically. Returns 200 with
new stage_days collection.
GET /api/v1/events/{event}/performances?day=2026-07-10
POST /api/v1/events/{event}/performances
GET /api/v1/events/{event}/performances/{performance}
PATCH /api/v1/events/{event}/performances/{performance}
DELETE /api/v1/events/{event}/performances/{performance}
```
Notes:
- The `PUT /stages/{stage}/days` endpoint is the matrix-editor backend.
Single-day toggle from the stage editor uses the same endpoint with the
full new array. This is REST-correct (replace resource collection) and
avoids race conditions inherent in N individual POST/DELETE calls.
- `GET /performances?day=YYYY-MM-DD` filters by `performances.date`. Without
the parameter returns all performances for the event (used for cross-day
artist views).
- Resource includes: `artist:id,name,booking_status,advancing_completed_count,advancing_total_count`,
`stage:id,name,color`. Always eager-loaded — no N+1.
- Idempotency-Key header required on POST `/performances` and POST
`/stages` per ARCH §10. 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
StageHeaderCell.vue ← swatch + name + capacity + day chips
PerformanceBlock.vue ← single block; emits drag/resize/click events
PerformancePopover.vue ← floating detail; teleported to body
LineupMatrix.vue ← bulk stages × days editor (modal)
StageEditor.vue ← single stage CRUD (modal)
AddPerformanceDialog.vue ← click-to-add modal
EmptyDayState.vue ← "no stages on this day, copy from..."
apps/app/src/composables/timetable/
useTimetable.ts ← TanStack queries: stages, performances
useTimetableMutations.ts ← performance CRUD mutations + optimistic updates
useStageDays.ts ← stage-day matrix mutation
useDragDrop.ts ← pointer event handling, snap math
useTimetableConflicts.ts ← computed conflict + b2b state per day
apps/app/src/types/timetable.ts
Stage, StageDay, Performance, PerformanceBookingStatus (zod schemas)
apps/app/src/api/timetable.ts
Axios calls; types inferred from zod schemas
```
State pattern:
- TanStack Query holds canonical server state.
- Pinia store (`useTimetableStore`) holds *only* UI state (selectedDay,
selectedPerformanceId, popoverPosition, dragState). Never duplicate
server data into Pinia.
- Optimistic updates on drag/drop and resize via `onMutate` rollback pattern.
Form validation: VeeValidate + Zod on AddPerformanceDialog and StageEditor.
Schemas mirrored from `apps/app/src/types/timetable.ts`.
## 8. Activity log
Spatie ActivityLog with `LogsActivity` trait on Stage, Performance, Artist
(latter already covered by ARCH-09).
Logged events:
- `stage.created`, `stage.updated`, `stage.deleted`
- `stage.day_added`, `stage.day_removed` (custom events on PUT /days
diffing the before/after sets)
- `performance.created`, `performance.updated`, `performance.deleted`
- `performance.moved` — special event when stage_id or date changes (high
signal for production managers; surfaces in event audit log)
Stage and performance updates capture before/after on: name, color, capacity
(stages); start_time, end_time, date, stage_id, booking_status (performances).
Activity log entries scoped to organisation + event for audit log filtering.
## 9. Authorization (Policy)
```
StagePolicy:
viewAny(User, Event) — user must be member of event.organisation
view(User, Stage) — same + stage.event_id check
create(User, Event) — user must have permission 'events.manage_program'
update(User, Stage) — same as create + organisation match
delete(User, Stage) — same as update + no related performances or
performances are also deletable
PerformancePolicy:
viewAny(User, Event) — same as Stage
view(User, Performance) — via stage.event.organisation
create(User, Event) — 'events.manage_program' permission
update(User, Performance) — same as create
delete(User, Performance) — same as create
```
`events.manage_program` is a new Spatie permission to add. Roles that get it:
`event_admin`, `program_manager` (new role — added in same sprint).
Volunteers, crew, suppliers do not have it.
## 10. Validation rules
### CreatePerformanceRequest / UpdatePerformanceRequest
```
artist_id required ulid exists:artists,id (scoped to event via stage)
stage_id required ulid exists:stages,id (scoped to event)
date required date
between: event.start_date and event.end_date inclusive
must exist in stage_days(stage_id=value of stage_id,
day_date=value)
→ custom rule StageActiveOnDay
start_time required time format H:i
end_time required time format H:i
after start_time within same date
(cross-midnight handled via end_date if needed; v1 forbids
cross-midnight performances — flag as a known limitation)
booking_status required PerformanceBookingStatus enum value
check_in_status optional CheckInStatus enum, default 'expected'
```
Overlap is **not** a hard validation error — it produces a 200 response with
a `warnings: ["overlap"]` field in the resource. The frontend renders the
conflict ring; the production manager decides whether to resolve.
### CreateStageRequest
```
name required string max:120 unique:stages,name,NULL,id,event_id,{event_id}
color required string regex:/^#[0-9A-Fa-f]{6}$/
capacity nullable integer min:1 max:1000000
active_days required array min:1
active_days.* date between event.start_date and event.end_date
```
### Stage-day matrix replacement (PUT /stages/{stage}/days)
```
day_dates required array min:1 (a stage must always have ≥1 active day)
day_dates.* date between event.start_date and event.end_date
```
If the request would remove a day with scheduled non-cancelled performances,
return 409 Conflict with `{performances_on_removed_days: [...]}`. The frontend
shows the matrix-editor confirmation dialog and resends with
`?force_orphan=true` to commit. The orphaned performances persist as soft
references (still in DB, hidden from views) — recovered when the day is
re-added.
## 11. Open questions / future work
- **Stage capacity-vs-draw warning** — requires `expected_attendance` column
on performances OR `expected_draw_default` on artists. Add as ART-04 in
backlog. Out of v1.
- **Stage templates** — copy stage configuration across events. Backlog as
ARCH-10. Out of v1.
- **Artist double-booking detection across stages** — defer to artist page,
not timetable. Backlog as ART-08.
- **Cross-midnight performances** — currently forbidden by validation
(end_time after start_time within same date). For show-day operations
this is restrictive (a 23:30 → 00:30 set spans midnight). Pragmatic v1
workaround: store as 23:30 → 24:30 with `end_time > 23:59:59` allowed
via custom validation. Or add `end_date` column and treat properly.
**Decision required before implementation.**
## 12. Implementation order
Three Claude Code sessions, sequential:
### Session 1 — ARCH-09 + Backend models
1. Artist model + migration + factory + seeder (ARCH-09)
2. Update `PURPOSE_SUBJECT_FQCN` constant: string-literal → `Artist::class`
3. Stage + StageDay + Performance models + migrations
4. PHP Enums: PerformanceBookingStatus, CheckInStatus (artists)
5. AdvanceSectionObserver to recompute advancing aggregate
6. PerformanceObserver for cascade soft-delete with artist
7. OrganisationScope registration on all three models (FK-chain)
8. SCHEMA.md update (enum upgrade, advancing_*_count columns)
### Session 2 — Backend API + business logic
1. StagePolicy, PerformancePolicy
2. New permission `events.manage_program`, new role `program_manager`
3. StageService, PerformanceService (StageDayService for matrix replace)
4. FormRequests (CreateStageRequest, UpdateStageRequest, etc.)
5. Custom rules: StageActiveOnDay
6. API Resources (StageResource, PerformanceResource with includes)
7. Controllers (5 routes per module)
8. Routes registered in api.php
9. Activity log integration on all mutations
10. Tests: feature tests for all endpoints, policy tests, validation tests,
overlap-warning tests, day-removal-with-orphans flow
11. API.md update
### Session 3 — Frontend
1. Types + zod schemas
2. API client
3. TanStack composables
4. Pinia store (UI state only)
5. Page entry + routing
6. TimetableGrid + StageRow + StageHeaderCell
7. PerformanceBlock with drag/resize composable
8. PerformancePopover with advancing aggregate fetch
9. AddPerformanceDialog
10. StageEditor + LineupMatrix modals
11. EmptyDayState
12. Vitest component tests on critical math (snap, overlap, conflict)
13. End-to-end flow test with Playwright (or whatever Crewli uses): drag
performance, switch days, edit lineup matrix
Estimate: 2.5 + 2 + 3 = **7.5 days across three sessions**.
## 13. Test strategy
### Backend unit + feature
- StageActiveOnDay rule
- PerformanceService overlap detection (warns, does not block)
- StageDayService atomic replacement with orphan-performance handling
- AdvanceSectionObserver count recomputation
- PerformanceObserver cascade soft-delete with artist
- All policy methods (positive + negative)
- All endpoints (200, 201, 204, 401, 403, 404, 409, 422)
### Frontend
- Pure: snap math, conflict detection, B2B detection, day-filter
- Component: PerformanceBlock renders correctly per booking_status
- Component: drag invokes mutation with optimistic update + rollback on error
- Integration: full add → drag → edit → delete flow with mocked API
- Accessibility: keyboard navigation through stages, screen-reader on
performance blocks (focus order, aria-labels)
## 14. Dependencies
### Hard
- ARCH-09 (Artist model) — must land in Session 1 before stages/performances
can be created. Currently a string-literal `'App\\Models\\Artist'` in
`PURPOSE_SUBJECT_FQCN`; first artist submission would fault. Resolves at
start of Session 1.
- New permission `events.manage_program` and role `program_manager`
Session 2 prerequisite.
### Soft
- ART-01 (Artist Advancing portal) — provides the advance_sections data
that powers the popover aggregate. Without ART-01 the aggregate is always
`0/0`. Acceptable for v1: the aggregate displays with grey "—" when
total is 0.
- ARCH-10 (stage templates) — would speed up setup of recurring events but
is not blocking.
### None
- Form Builder, Accreditation Engine, Briefings — fully independent.