Files
crewli/resources/Crewli - Artist Timetable Management/docs/timetable-module.md

18 KiB

Timetable Module — Implementatie-spec

Doel: een interactieve timetable-module in de Crewli-app waarin programmers acts kunnen plannen op stages, slepen tussen stages/wachtrij, conflicten en advancing-status zien, en stages beheren.

Stack: Vue 3 (Composition API) + Pinia + TypeScript op de Vuexy/Vuetify-template, Laravel 11 als API-backend, MySQL/Postgres, Reverb voor real-time.

Referentie-PoC: zie _poc/Crewli Timetable.html (interactieve mockup met alle gewenste UX-gedrag — gebruik dit als executable spec).


1. Domain model

1.1 Tabellen

events
  id                 ulid PK
  name               string                       # "Echt Zomer Feesten 2026"
  slug               string unique
  start_hour         tinyint                      # grid start, default 14 (14:00)
  total_minutes      smallint                     # grid lengte, default 720 (12u)
  stages_label       string                       # configureerbare term, default "Stages"
  created_at, updated_at, deleted_at

event_days
  id                 ulid PK
  event_id           ulid FK -> events
  date               date
  label              string                       # "Vrijdag 12 juli"
  sort               smallint
  unique(event_id, date)

stages
  id                 ulid PK
  event_id           ulid FK -> events
  name               string
  color              string(7)                    # hex
  capacity           int                          # voor draw-warnings
  sort               smallint                     # globale fallback-volgorde
  created_at, updated_at, deleted_at              # soft delete

stage_days                                        # welke stages zijn op welke dag actief
  id                 ulid PK
  stage_id           ulid FK -> stages
  day_id             ulid FK -> event_days
  sort               smallint                     # volgorde van stages binnen die dag
  unique(stage_id, day_id)

artists
  id                 ulid PK
  event_id           ulid FK -> events
  name               string
  initials           string(4)
  genre              string                       # enum-string, freeform mag
  draw               int                          # verwachte trekkracht
  created_at, updated_at, deleted_at

advance_sections                                  # configureerbaar per event
  id                 ulid PK
  event_id           ulid FK -> events
  key                string                       # 'tour' | 'hosp' | 'travel' | ...
  label              string
  sort               smallint
  unique(event_id, key)

artist_advance                                    # pivot: welke secties zijn done
  artist_id          ulid FK
  section_id         ulid FK
  done_at            timestamp nullable
  primary key (artist_id, section_id)

performances                                      # GEPLAND op een stage
  id                 ulid PK
  event_id           ulid FK
  day_id             ulid FK -> event_days
  stage_id           ulid FK -> stages
  artist_id          ulid FK -> artists
  start_minute       smallint                     # offset vanaf event.start_hour*60
  end_minute         smallint
  lane               tinyint                      # sub-swimlane within stage row
  status             enum('concept','requested','option','confirmed','cancelled')
  notes              text nullable
  version            int default 0                # optimistic locking
  created_at, updated_at, deleted_at
  index(day_id, stage_id, start_minute)

parked_performances                               # WACHTRIJ — niet gepland
  id                 ulid PK
  event_id           ulid FK
  day_id             ulid FK
  artist_id          ulid FK
  duration_minutes   smallint                     # voorgenomen duur
  status             enum(...)                    # zelfde set als performances
  notes              text nullable
  origin             enum('manual','unscheduled','stage_deleted')
  created_at, updated_at

pending_performances                              # nog niet bevestigde aanvragen
  id                 ulid PK
  event_id           ulid FK
  day_id             ulid FK
  artist_id          ulid FK
  duration_minutes   smallint
  notes              text nullable
  created_at, updated_at

audit_log                                         # wie deed wat wanneer
  id                 ulid PK
  user_id            ulid FK -> users
  event_id           ulid FK
  subject_type       string                       # 'performance' | 'stage' | ...
  subject_id         ulid
  action             string                       # 'move' | 'create' | 'park' | ...
  before             json nullable
  after              json nullable
  created_at
  index(event_id, subject_type, subject_id)

1.2 Invarianten

  1. Een artiest mag op één moment maar één performance hebben (cross-stage conflict). Backend valideert; frontend toont waarschuwing maar blokkeert niet.
  2. Op één stage mogen meerdere performances op verschillende lanes tegelijk lopen. Op dezelfde lane mag geen tijds-overlap.
  3. start_minute < end_minute, beide binnen [0, event.total_minutes].
  4. stage_id moet een actieve stage_day hebben voor day_id.
  5. Soft-delete van een stage cascadet alle bijbehorende performances naar parked_performances met origin='stage_deleted'.

1.3 Status-enum

concept     # idee, niet aangevraagd
requested   # aangevraagd bij agent/artiest
option      # geboekt onder optie
confirmed   # bevestigd
cancelled   # geannuleerd; blijft zichtbaar met diagonaal patroon

Status-kleuren staan vast in de frontend theme (zie §6).


2. API-contract

Alle endpoints onder /api/v1, JSON, Sanctum auth. Resources gebruiken EventResource, PerformanceResource, etc.

2.1 Read

Method Path Doel
GET /events/{event} Event-meta + days + stages + advance_sections
GET /events/{event}/timetable?day={dayId} Performances + parked + pending voor één dag, in één response
GET /events/{event}/artists Volledige artiestenlijst (voor add-modal autocomplete)

Response shape /timetable:

{
  "day": { "id": "...", "label": "Vrijdag 12 juli" },
  "stages": [{ "id": "...", "name": "...", "color": "...", "capacity": 2400, "sort": 0 }],
  "performances": [{ "id": "...", "stage_id": "...", "artist_id": "...", "start": 480, "end": 540, "lane": 0, "status": "confirmed", "version": 3 }],
  "parked": [...],
  "pending": [...]
}

2.2 Write — performances

Method Path Body Effect
POST /performances { day_id, stage_id?, artist_id, start?, end?, status, lane? } Nieuw — als stage_id ontbreekt → wachtrij
PATCH /performances/{id} { start?, end?, stage_id?, lane?, status?, version } Move/resize/restage. Bij version mismatch → 409
DELETE /performances/{id} Verwijder volledig
POST /performances/{id}/park Verplaatst naar parked_performances
POST /parked/{id}/schedule { stage_id, start, end, lane? } Wachtrij → timetable, met cascade-bump als lane bezet
POST /pending/{id}/schedule { stage_id, start, end, lane? } Pending → timetable
DELETE /parked/{id} of /pending/{id} Verwijder uit wachtrij

Cascade-logica (lane-conflict op drop): backend ontvangt expliciete lane. Als die lane qua tijd bezet is op die stage, bumpt het bestaande block één lane omlaag, recursief. Backend retourneert alle gewijzigde performances in een cascade[] array zodat de frontend in één state-update kan patchen.

// PATCH response
{
  "performance": { ...updated... },
  "cascade": [{ ...other... }, ...]
}

2.3 Write — stages

Method Path Body Effect
POST /stages { event_id, name, color, capacity, day_ids[] } Nieuwe stage
PATCH /stages/{id} { name?, color?, capacity?, day_ids? } Update + sync stage_days
DELETE /stages/{id} Soft delete + cascade performances → parked
POST /stages/reorder { day_id, stage_ids[] } Per-dag volgorde

2.4 Errors

400 Bad Request          # validatie (start >= end, etc.)
403 Forbidden            # geen permissie
404 Not Found
409 Conflict             # version mismatch (optimistic lock)
422 Unprocessable        # business rule (artiest dubbel geboekt)

3. Frontend architectuur

3.1 Bestandsstructuur

resources/js/timetable/
├─ index.ts                          # public exports + route
├─ types.ts                          # Performance, Stage, Artist, Drag, ...
├─ lib/
│  ├─ lanes.ts                       # assignLanes() + sub-swimlane logic
│  ├─ conflicts.ts                   # findConflicts, findB2B
│  ├─ time.ts                        # fmtTime, snap
│  ├─ advance.ts                     # advanceCount
│  └─ __tests__/
├─ stores/
│  └─ timetable.ts                   # Pinia store
├─ composables/
│  ├─ useTimetableDrag.ts
│  ├─ useTimetableScroll.ts
│  └─ useRealtime.ts
├─ components/
│  ├─ Timetable.vue                  # canvas + axis + rows
│  ├─ TimetableHeader.vue            # day tabs, conflict counter, +Performance
│  ├─ TimetableToolbar.vue           # density, snap, percent toggle, +Performance
│  ├─ Block.vue                      # één performance-block
│  ├─ StageRow.vue
│  ├─ Wachtrij.vue
│  ├─ Popover.vue
│  ├─ Drawer.vue
│  ├─ modals/
│  │  ├─ PerformanceModal.vue
│  │  ├─ StageEditor.vue
│  │  └─ LineupMatrix.vue
│  └─ __tests__/
├─ scss/
│  └─ timetable.scss                 # gebruikt Vuexy SCSS-tokens
└─ pages/
   └─ EventTimetable.vue             # route entrypoint

3.2 Pinia store skelet

// stores/timetable.ts
export const useTimetableStore = defineStore('timetable', () => {
  const event = ref<Event | null>(null)
  const activeDayId = ref<string | null>(null)
  const stages = ref<Stage[]>([])
  const performances = ref<Map<string, Performance>>(new Map())
  const parked = ref<ParkedPerformance[]>([])
  const pending = ref<PendingPerformance[]>([])
  const artists = ref<Artist[]>([])

  // ── Computed ─────────────────────────────
  const dayStages = computed(() => /* filter by stage_days for activeDayId */)
  const dayPerformances = computed(() => /* filter by day_id */)
  const lanesByStage = computed(() => /* assignLanes per stage */)

  // ── Actions (alle optimistic + rollback) ─
  async function loadDay(eventId: string, dayId: string) { ... }
  async function movePerf(id: string, patch: PerfPatch) { ... }
  async function createPerf(input: NewPerf) { ... }
  async function parkPerf(id: string) { ... }
  async function schedulePending(pendingId: string, target: Target) { ... }
  async function scheduleParked(parkedId: string, target: Target) { ... }
  async function deletePerf(id: string) { ... }
  async function addStage(input: NewStage) { ... }
  async function updateStage(id: string, patch: StagePatch) { ... }
  async function deleteStage(id: string) { ... }
  async function reorderStages(dayId: string, stageIds: string[]) { ... }

  // ── Realtime patches (Reverb) ────────────
  function applyRemotePatch(event: BroadcastEvent) { ... }

  return { /* state + computed + actions */ }
})

Optimistic update pattern (gebruik overal):

async function movePerf(id, patch) {
  const before = performances.value.get(id)
  performances.value.set(id, { ...before, ...patch })          // 1. local apply
  try {
    const { data } = await api.patch(`/performances/${id}`, { ...patch, version: before.version })
    performances.value.set(id, data.performance)               // 2. authoritative replace
    data.cascade.forEach(p => performances.value.set(p.id, p)) // 3. apply cascade
  } catch (e) {
    performances.value.set(id, before)                         // 4. rollback
    if (e.status === 409) toast.warn('Iemand anders heeft dit zojuist aangepast — ververs')
    else toast.error('Opslaan mislukt')
  }
}

3.3 Drag-drop composable

Port startDragMove, startDragResize, startCreateDrag, startDragFromParking van de PoC (timetable.jsx) letterlijk naar één composable. Gebruik ref voor drag-state en window-scoped mousemove/mouseup listeners. Cleanup via onScopeDispose.

Belangrijke regels die de PoC al heeft uitgewerkt — port verbatim:

  • Lane-snap: Math.floor(yInRow / LANE_STEP) voor create, Math.round(...) voor move
  • Cursor-anchor cross-stage move: newRow afgeleid van cursor-Y, niet block-top
  • Click-after-drag suppressie: zet data-just-dragged op het block bij mouseup-na-drag, fail-fast in click handler
  • Timestamp-only conflict check: o.start < newEnd && o.end > newStart — nooit pixel-Y meenemen in conflict-bepaling

3.4 Pure-logic ports

lib/lanes.ts, lib/conflicts.ts etc. zijn directe TypeScript-ports van helpers.js. Begin hiermee — schrijf eerst de Vitest tests en check ze tegen de PoC-output. Dit is de hoeksteen; alles wat erop bouwt is daarna voorspelbaar.


4. Realtime (Reverb + Echo)

4.1 Channels

private-event.{eventId}.timetable

Geautoriseerd in routes/channels.php via Gate::allows('view-event-timetable', $event).

4.2 Events

PerformanceCreated      { performance: PerformanceResource }
PerformanceUpdated      { performance: ..., cascade: [...] }
PerformanceDeleted      { id: ulid }
PerformanceParked       { performance: ..., parked: ParkedResource }
ParkedScheduled         { parked_id: ulid, performance: ... }
StageCreated/Updated/Deleted/Reordered

Elke event-class heeft socket() returning request()->header('X-Socket-Id') — clients filteren hun eigen broadcasts uit, zo voorkom je dubbele optimistic updates.

4.3 Frontend wiring

// composables/useRealtime.ts
export function useRealtime(eventId: string) {
  const store = useTimetableStore()
  const channel = window.Echo.private(`event.${eventId}.timetable`)
  channel.listen('PerformanceUpdated', (e) => store.applyRemotePatch(e))
  // ...
  onScopeDispose(() => channel.stopListening())
}

5. Permissies (CASL)

Definieer in resources/js/abilities.ts:

can('view',   'Timetable', { event_id: ... })
can('edit',   'Performance', { event_id: ... })
can('manage', 'Stage', { event_id: ... })
can('book',   'Performance', { status: ['concept','requested','option'] })

UI: knoppen/handles disablen via v-if="$can('edit', 'Performance')". Backend valideert altijd opnieuw via PerformancePolicy.


6. Styling

6.1 Tokens (Vuexy)

// scss/timetable.scss — gebruikt Vuexy core variables
@use '@core/scss/base/variables' as *;
@use '@core/scss/base/mixins' as *;

:root {
  // Status-kleuren — toevoegen aan Vuetify theme onder customProperties:
  --tt-status-concept-bg:    #f4f5f8;
  --tt-status-concept-bd:    #c8ccd6;
  --tt-status-requested-bg:  #fff5e6;
  --tt-status-requested-bd:  #e89a3c;
  --tt-status-option-bg:     #e9efff;
  --tt-status-option-bd:     #5a8fcf;
  --tt-status-confirmed-bg:  #e8f7ee;
  --tt-status-confirmed-bd:  #3cc28a;
  --tt-status-cancelled-bd:  #d63d4b;
}

.cw-block {
  border-radius: $border-radius-sm;
  background: var(--v-theme-surface);
  // ...
}

Voor dark-mode: definieer alle --tt-status-*-* ook in [data-theme="dark"].

6.2 Vuetify-componenten waar nuttig

Header tabs → <VTabs>. Modals → <VDialog>. Density-toggle → <VBtnToggle>. Drawer → <VNavigationDrawer location="right">. Toasts → <VSnackbar> of vue-toastification.

Niet Vuetify gebruiken voor: het canvas, de blocks, de wachtrij-cards, de popover. Die zijn custom — past geen library-component bij.


7. Migratie-volgorde

Bouw in deze volgorde, één PR per stap:

  1. Migrations + seeders met testdata (kopieer data.js uit PoC als seeder-script)
  2. Models + Resources + Policies + tests
  3. API endpoints + Feature tests (volledige roundtrip per endpoint)
  4. lib/*.ts ports + Vitest — alleen pure logic
  5. Pinia store + mock-API tests
  6. Timetable.vue + Block.vue + StageRow.vue — read-only render
  7. Drag composable + drop wiring (move, resize, restage)
  8. Wachtrij + create-drag + parked-drop
  9. Modals (PerformanceModal, StageEditor, LineupMatrix)
  10. Popover + Drawer
  11. Realtime (Reverb)
  12. Audit log + permissions
  13. Print-view + lock-feature (later)

Elke stap heeft een definition of done: PR groen (tests + Pint + ESLint), screenshot in PR-beschrijving, één code-review.


8. Testing strategy

Laag Tool Coverage-doel
Pure logic (lib/) Vitest 100% — edge cases uit PoC
Pinia store Vitest + mock fetch Alle actions, optimistic + rollback
Components Vitest + @vue/test-utils Smoke + interaction op kritieke flows
API Pest Per endpoint: happy + 4 edge cases + permissie
E2E Playwright 3 critical flows: drag-drop, stage-delete-cascade, realtime-sync

9. Open vragen (beslis vóór de eerste sprint)

  1. Multi-event per organisatie: één gebruiker kan meerdere events tegelijk plannen → event_id overal scope, of subdomain per event?
  2. Wachtrij per dag of per event: PoC toont wachtrij gefilterd op actieve dag. Confirm: dat is de spec.
  3. Lane storage: bewaar je lane op de DB of bereken je het altijd opnieuw bij elk render? PoC bewaart het — dat is mijn aanbeveling (anders kan een gebruiker geen explicit lane-keuze maken).
  4. Conflict policy: blokkeren of waarschuwen? PoC waarschuwt + toont rode rand. Confirm.
  5. Print-formaat: A3 landscape per dag? Of één file met alle dagen?
  6. Audit retention: hoe lang bewaren? GDPR-relevant.

10. Bronbestanden

PoC ligt in _poc/Crewli Timetable.html (zelf-bevattend). De relevante files:

  • app.jsx — root, Pinia-equivalent state
  • timetable.jsx — grid, blocks, drag, create-drag, ghost
  • popover.jsx — popover + drawer + wachtrij
  • modals.jsx — PerformanceModal, StageEditor, LineupMatrix
  • helpers.js — pure logic (port als eerste!)
  • data.js — demo data (seeder-template)
  • styles.css — visuele referentie voor de SCSS-port

Eind van spec — klaar om aan Claude Code te voeden, één hoofdstuk tegelijk.