# 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 ```text 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`**: ```json { "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. ```json // 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 ```ts // stores/timetable.ts export const useTimetableStore = defineStore('timetable', () => { const event = ref(null) const activeDayId = ref(null) const stages = ref([]) const performances = ref>(new Map()) const parked = ref([]) const pending = ref([]) const artists = ref([]) // ── 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): ```ts 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 ```php 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 ```ts // 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`: ```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 // 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 → ``. Modals → ``. Density-toggle → ``. Drawer → ``. Toasts → `` 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.**