471 lines
18 KiB
Markdown
471 lines
18 KiB
Markdown
# 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<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):
|
|
```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 → `<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.**
|