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
- Een artiest mag op één moment maar één performance hebben (cross-stage conflict). Backend valideert; frontend toont waarschuwing maar blokkeert niet.
- Op één stage mogen meerdere performances op verschillende lanes tegelijk lopen. Op dezelfde lane mag geen tijds-overlap.
start_minute < end_minute, beide binnen[0, event.total_minutes].stage_idmoet een actievestage_dayhebben voorday_id.- Soft-delete van een stage cascadet alle bijbehorende
performancesnaarparked_performancesmetorigin='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:
newRowafgeleid van cursor-Y, niet block-top - Click-after-drag suppressie: zet
data-just-draggedop 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:
- Migrations + seeders met testdata (kopieer
data.jsuit PoC als seeder-script) - Models + Resources + Policies + tests
- API endpoints + Feature tests (volledige roundtrip per endpoint)
lib/*.tsports + Vitest — alleen pure logic- Pinia store + mock-API tests
Timetable.vue+Block.vue+StageRow.vue— read-only render- Drag composable + drop wiring (move, resize, restage)
- Wachtrij + create-drag + parked-drop
- Modals (PerformanceModal, StageEditor, LineupMatrix)
- Popover + Drawer
- Realtime (Reverb)
- Audit log + permissions
- 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)
- Multi-event per organisatie: één gebruiker kan meerdere events tegelijk plannen →
event_idoveral scope, of subdomain per event? - Wachtrij per dag of per event: PoC toont wachtrij gefilterd op actieve dag. Confirm: dat is de spec.
- Lane storage: bewaar je
laneop 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). - Conflict policy: blokkeren of waarschuwen? PoC waarschuwt + toont rode rand. Confirm.
- Print-formaat: A3 landscape per dag? Of één file met alle dagen?
- 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 statetimetable.jsx— grid, blocks, drag, create-drag, ghostpopover.jsx— popover + drawer + wachtrijmodals.jsx— PerformanceModal, StageEditor, LineupMatrixhelpers.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.