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

22 KiB
Raw Blame History

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:

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.