RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18
Reference in New Issue
Block a user
Delete Branch "feat/timetable-session-4"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The Vue 3 timetable canvas at
/events/{event}/timetablelands. Custom Gantt rendering (D8), server-resolved lanes (D19), transactional cascade-bump moves throughPOST /timetable/move(D18), optimistic update + 409 rollback (D14), keyboard a11y model (D20), CSS-token status palette (D21).This PR bundles the original Session 4 implementation plus the follow-up test coverage closure that takes us from 252 to 385 tests across 53 files, and the navigation entry that makes the page reachable.
What's in
apps/app/src/types/timetable.ts+schemas/timetable.ts— backend resource mirrors with runtime Zod parsing on every API responseapps/app/src/lib/timetable/— six pure modules (snap, time-grid, conflict, b2b, capacity, lane)apps/app/src/composables/api/useTimetable*.ts— TanStack queries + optimistic move with cascade pulse + 409 toast + Idempotency-Keyapps/app/src/composables/timetable/—usePointerDrag,useDragOrClick,useTimetableKeyboard,useActiveDayapps/app/src/stores/useTimetableStore.ts— UI state onlyapps/app/src/styles/tokens/_timetable.css— 9 status palettes + lane geometry + cascade-pulse keyframeapps/app/src/components/timetable/— TimeAxis, GridBg, StageHeaderCell, PerformanceBlock, StageRow, EmptyDayState, PerformancePopover, AddPerformanceDialog, StageEditor, LineupMatrix, Wachtrij, WachtrijCardapps/app/src/pages/events/[id]/timetable/index.vue— page entryapps/app/src/components/events/EventTabsNav.vue— Programma tabapps/app/tests/utils/mountWithVuexy.ts— full Vuexy/Vuetify/Pinia/router/QueryClient/notification-mock harnessTest coverage (385 passing across 53 files)
?daysource-of-truthDecisions touched
D5 / D6 / D7 / D8 / D13 / D14 / D17 / D18 / D19 / D20 / D21 / D22 / D25 / D26 / D27.
Test plan
cd apps/app && npm run test→ 385 passing across 53 filesnpx vue-tsc --noEmitcleannpm run buildsucceeds/events/{festival}/timetable?day={subevent}— page mountsNotable design decisions
ref({...})+@core/utils/validators+ Zod for payload schemas. VeeValidate removed (was never adopted)..scssto.cssso jsdom can resolvevar(--tt-…)in component tests.?dayURL is the source of truth; Pinia derives.stringnotRef<string>.Known UX divergence (tracked in BACKLOG)
Browser testing after this PR's stabilization companion (separate PR
fix/timetable-stabilization) revealed substantial UX gaps versus the canonical prototype at./resources/Crewli - Artist Timetable Management/: PerformanceBlock missing genre tag and advancing bar; PerformancePopover missing multi-select status and detail breakdown; Wachtrij missing filter pills and grouping; AddPerformanceDialog two-mode behavior absent; drag interactions and resize handles broken. Mechanical layer (backend, schema, layout, scroll, sticky panes, keyboard a11y) is correct; UX layer is not. Tracked asART-S4-UX-PARITYin BACKLOG.md, gated to land afterTEST-INFRA-001(Playwright + visual regression) so the parity work has proper visual-test scaffolding.🤖 Generated with Claude Code
Ports the prototype's helpers.js + cascade-bump algorithm into typed TypeScript modules in apps/app/src/lib/timetable/: - snap.ts — 5-minute snap (RFC D7) + 15-min minimum duration - time-grid.ts — pixel ↔ minute ↔ ISO-8601 coordinate conversions - conflict.ts — same-stage same-lane overlap detection (RFC D5) - b2b.ts — back-to-back marker links, 3-min threshold (RFC D26) - capacity.ts — 110% over-capacity warn level (RFC D25) - lane.ts — two-pass resolver + drag-preview cascade (D13/D18/D19, client-side preview only; server is authoritative) All functions are pure (no Vue, no DOM). Tested in Phase C. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>PerformanceBlock is the heart of the canvas: - Status palette via CSS tokens (D21) — one class per booking_status enum value - Cancelled hatch overlay + line-through (D5) - Trashed-artist dashed border + ⌂ overlay icon (D27) - Conflict ring + glow when warnings.includes('overlap') (D5) - Capacity icon driven by evaluateCapacity() with warn/critical levels (D25) - B2B left/right dots (D26 — 3-min threshold) - Cascade-pulse class fired by parent on cascaded[] non-empty (D18) - aria-label structure per D20: artist, stage, time window, status, advancing - tabindex 0 + Enter/Space → select; Delete → emit delete StageRow positions blocks by lane_resolved (D19) — server is authoritative. StageHeaderCell uses Vuexy VMenu pattern for the per-stage actions. EmptyDayState routes the user to LineupMatrix when no stages are active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>apps/app/tests/unit/lib/timetable/: - snap.test.ts (5) — rounding, clamp, edge cases - time-grid.test.ts (6) — px↔min↔ISO roundtrips, formatTickLabel - conflict.test.ts (8) — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion - b2b.test.ts (6) — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant - capacity.test.ts (7) — null capacity, missing data, warn/critical, crew+guests preference - lane.test.ts (8) — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion apps/app/tests/unit/composables/: - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade, 409 VersionMismatch surfaced, park sends null, createStage POST path - useDragOrClick.test.ts (3) — onClick fires under threshold, onDragStart+End above threshold, Esc cancels mid-flight apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal) so AxiosError.message and the VersionMismatchError shape both bubble through .catch(). Test count: 252 → 319 (+67). All 42 files pass. Out of scope this session (added to BACKLOG): - ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at the lib/ layer. - ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The aria-label structure on PerformanceBlock + aria-live on the page entry are authored to pass an axe scan when added. - ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs Vuetify + router + msw setup; defer with the component tests above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Foundation for the upcoming component / integration / a11y tests. vitest.config.ts now declares two projects: - "unit" — pure-logic tests under tests/unit/, src/**/__tests__/, and tests/*.spec.ts (the legacy sanity test). happy-dom, no Vuetify, fast path. - "component" — tests under tests/component/, tests/integration/, tests/a11y/. jsdom, Vuetify inlined via SSR noExternal, CSS imports processed (so :root token sheet loads), and no global vue-router mock so the real router can run. Both share the same alias map and AutoImport bag. tests/utils/mountWithVuexy.ts (new): - Real Vuetify with the Crewli theme tokens - createTestingPinia (actions execute by default; stubActions opt-in) - vue-router with memory history at the configured initialPath + ?query - Fresh QueryClient per call (zero cross-test cache leak) - Notification mock injected via Pinia plugin so any useNotificationStore() resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual NotificationStore API surface (per Phase A finding A4) - Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves var(--tt-…) when components call getComputedStyle() tests/setup.component.ts (new): - vitest-axe matcher registration - JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body bounding rect — Vuetify menus / overlays would crash without them - Deterministic crypto polyfill (mirrors tests/setup.ts so generateIdempotencyKey() is stable, but without the router mock) tests/component/_smoke.test.ts (new): - Mounts a trivial component → asserts wrapper, queryClient, pinia, router, notificationMock all populated - Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg') → asserts '#e8f8f0' (proves the CSS token sheet really loaded) devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing. Total: 319 → 321 tests; 42 → 43 files. Both projects green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were types-only; nothing parsed actual server responses. Backend → frontend contract drift would only surface as TypeError deep in components. useTimetable.ts queries now parse: - useStages → stageArraySchema.parse() - usePerformances → performanceArraySchema.parse() - useWachtrij → performanceArraySchema.parse() - useEngagement → artistEngagementSchema.parse() useTimetableMutations.ts mutations now parse: - move success → moveTimetableSuccessSchema.parse() - move 409 errors → moveTimetableConflictSchema.parse() (the .errors sub-object — see backend canon at TimetableMoveController:64) - create / updateNotes → performanceSchema.parse() - createStage / updateStage → stageSchema.parse() The move() success parse runs OUTSIDE the try/catch so a Zod failure on a 200 response surfaces as a true error rather than being misclassified as a 409. Per Phase A finding A8 the conflict shape already matches backend field-for-field; no schema correction needed, but the parse() locks future drift in. Regression test (tests/unit/composables/api/zodParseFailure.test.ts): - move() success with missing fields → rejects with ZodError - move() 409 with malformed errors payload → rejects with ZodError - createStage() with missing fields → rejects with ZodError Existing test fixture for createStage was missing created_at/updated_at; fixed in same commit (real backend responses always include them). Test count: 321 → 324. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Per Phase A finding A6 — the previous three-watcher Pinia-store design had no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly set store.activeDayId to that bogus value and showed an empty page. Cross-org sub-event IDs were silently accepted (backend OrganisationScope returned an empty perf list, so the UI looked broken without telling the user). New design (Session 4 follow-up Step 5): - src/composables/timetable/useActiveDay.ts (NEW) - The URL `?day` is the source of truth; Pinia does NOT hold this value. - `activeDayId` is a computed: queryDay if it appears in `validIds`, else the first valid id, else null when the list is empty. - One corrective watcher (immediate:true, flush:'post') quietly rewrites the URL when `?day` is missing or invalid; runs after Vue settles and after validIds has been recomputed from a fresh fetch. - `setActiveDay(id)` is the user-driven entry point — calls replace(). - Cross-org IDs are blocked transparently: OrganisationScope keeps them out of validIds, so they fail the .includes() check and fall back. - src/stores/useTimetableStore.ts - Removed `activeDayId` state and `setActiveDay()` action; the store docstring now documents that day-state lives at the URL. - src/pages/events/[id]/timetable/index.vue - Replaced the three watchers + onMounted bootstrap with one `useActiveDay({ queryDay, validIds, replace })` call. The day-change side-effect watcher (clear drag, deselect performance) stays. - VTabs binds dayIdRef + setActiveDay directly. - tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests) - Valid ?day=X → activeDayId=X, no URL rewrite. - Missing / invalid / cross-org ?day → fallback + URL replaced once. - Empty validIds → activeDayId=null, URL untouched. - setActiveDay(id) → calls replace. - setActiveDay(null) → no-op. - External URL change (browser back) → activeDayId follows. - validIds populated AFTER mount → fallback fires correctly. - tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId and setActiveDay are GONE from the store surface. Test count: 324 → 333. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>17 component-level tests via mountWithVuexy: Visual states (10): - status palette × 3 (option, confirmed, cancelled) — asserts both the CSS class AND that the matching --tt-status-{X}-bg custom property resolves on :root (proves the token sheet really loaded) - capacity icon present when crew + guests > stage.capacity - capacity icon absent when sum ≤ capacity - capacity icon absent when stage.capacity is null (no warning possible) - B2B left dot present when b2bLeft prop true - B2B right dot present when b2bRight prop true - no dots when neither prop true - conflict ring class when warnings includes 'overlap' - cascade-pulse class when pulse=true - aria-label includes artist + stage + status + HH:mm time window - tabindex="0" for keyboard focus Interactions (5, in second describe): - click → emits select with performance + DOMRect - pointerdown → emits pointerdown with (event, performance) - Delete keypress → emits delete - Enter keypress → emits select Test count: 333 → 350. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>4 component tests via mountWithVuexy: - happy path: valid form values → POST /performances called with the correct body shape (engagement_id, event_id mapped from dayId, stage_id, start_at, end_at) - end_at < start_at → submit blocked, schema-level error visible on the end_at field - empty engagement_id → submit blocked, error visible on the engagement_id field - cancel button → emits update:modelValue=false Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors, submit })` so jsdom tests can drive validation deterministically without piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes internal refs only — no behavioural change. VDialog stubbed in the test (it teleports to body, which puts content outside the wrapper); App* wrappers stubbed (we test the schema + submit pipeline, not Flatpickr ergonomics). Test count: 360 → 364. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Minimal seam in src/composables/api/useTimetableMutations.ts: the move() mutation's onError now calls useNotificationStore().show(...) on a 409 status. Generic axios errors stay quiet here — the global response handler in lib/axios/factory.ts already toasts those. RFC D14 wanted the version-mismatch toast specifically. apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests): - on success: returns server payload with bumped version + sends the Idempotency-Key supplied by the caller - 409: rejects with VersionMismatchError + notification.show() invoked once with the Dutch translation + 'error' level - cascade: success with cascaded[] populated puts those peers into the result.cascaded array - Idempotency-Key uniqueness: two distinct logical move() calls send distinct keys - Idempotency-Key reuse: caller-controlled retry within the same logical action sends the SAME key on the wire (so the backend's 60s idempotency middleware dedupes) The two existing unit-project tests now register a Pinia instance (createPinia + setActivePinia) so useNotificationStore() resolves. Existing assertions unchanged. Test count: 364 → 369. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>11 tests for useTimetableKeyboard (RFC v0.2 D20): - Arrow Left → nudge(-SNAP_MIN, 0, 0) - Arrow Right → nudge(+SNAP_MIN, 0, 0) - Shift+Arrow → nudge(±60min) - Arrow Up/Down → ±lane - Shift+Arrow Up/Down → ±stage - ] / [ → cycle stages preserving time + lane - Enter → openPopover with the selected performance - Delete → remove with the selected performance - Space → drag mode + aria-live announce; Arrow keys accumulate; Enter commits with the cumulative offset; aria-live announces 'bevestigd' - Esc cancels keyboard drag, no mutation, aria-live announces 'geannuleerd' - all keys are no-ops when no performance is selected Tests the composable directly with a host component that owns a focusable canvas root and exposes the spies + announce ref — much more reliable than mounting the whole timetable page (heavy + asynchronous). Test count: 369 → 380. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three jsdom axe scans covering the user-facing surface of the canvas. The scans surfaced two real a11y bugs which are fixed in this same commit: 1. PerformancePopover — VProgressLinear (advancing aggregate) had no accessible name. Added aria-label that announces "X van Y secties afgerond (N%)". 2. AddPerformanceDialog — the icon-only close button (×) was missing aria-label. Added 'Sluiten'. Test scenarios: - PerformanceBlock with focus - PerformancePopover open - AddPerformanceDialog open Page-level axe rules (region, page-has-heading-one, landmark-one-main, color-contrast) are disabled for fragment scans — they only make sense on a full page, and color-contrast resolution is jsdom-blind. Both are covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001. Test count: 380 → 383. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two integration tests that drive the entire RFC D17 lifecycle through the mutation composable + TanStack cache: 1. happy-path lifecycle (5 stages): - ADD → POST /performances + Idempotency-Key - DRAG → POST /timetable/move (target_lane=1, version bumps), server returns cascaded[] sibling — both surface in the resolved Promise - RESIZE → POST /timetable/move with new end_at + new version - PARK → POST /timetable/move with target_stage_id=null - DELETE → DELETE /performances/p1 final wire: 4 POSTs + 1 DELETE 2. drag rollback on 409: - server returns version_mismatch - mutation rejects with VersionMismatchError shape - notification.show() invoked with the Dutch toast + 'error' Why not the full page mount: events/[id]/timetable/index.vue requires EventTabsNav, useEventDetail, useEventChildren, multiple VTabs/VBtn/ VDialog teleports — too brittle for jsdom CI. The end-to-end + visual flavour of this flow lives on TEST-INFRA-001's Playwright migration backlog (and TEST-CONTRACT-001 covers the 409 path against a real backend). Test count: 383 → 385. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The timetable canvas page at /events/{event}/timetable was added in RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now exposes it as the "Programma" tab between Artiesten and Briefings on flat events, and between Artiesten and Briefings on festivals (in the re-ordered tab list, post-Artiesten / pre-Briefings). Changes: - baseTabs gains the Programma entry at position 6 (after Artiesten). - The festival re-order computed switches from positional indexing (baseTabs[5], [6], [7]) to name-based lookup via a findTab helper — insertions to baseTabs no longer break the festival branch. - Icon: tabler-calendar-time. Conservative Dutch label "Programma" — doesn't collide with "Programmaonderdelen" (the festival sub-events page) since festivals see both tabs side-by-side. vitest.config.ts: extend the component-project AutoImport to include 'vue-router' so tests of components that auto-import useRoute/useRouter mount cleanly. (EventTabsNav was the first such test.) tests/component/EventTabsNav.test.ts (NEW, 4 assertions): - Programma tab is rendered with the correct label - it carries the tabler-calendar-time icon - the route binding resolves to the events-id-timetable name with the /events/.../timetable URL pattern - the tab is also visible on a festival (re-ordered tab list path) Mocks the useEvents composables so the component skips its skeleton/ error branches and renders tabs immediately. Test count: 385 → 389. 🤖 Generated with [Claude Code](https://claude.com/claude-code)