Files
crewli/resources/Crewli - Artist Timetable Management/docs/RFC-TIMETABLE - Artist Timetable Module.md

530 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.