Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod
schema drift. The prompt's hypothesis (enum {value, label} mismatch) was
incorrect — the schema already uses the enumLabel() wrapper for every
enum field. The actual drift is decimal-cast columns: Laravel serialises
`decimal(N,M)` columns as strings to preserve precision, but the schema
expected numbers, so the very first response triggered a ZodError.
Affected fields, all on `artist_engagements`:
fee_amount decimal(10,2) → wire `"11503.58"`, schema was z.number()
buma_percentage decimal(5,2) → wire `"7.00"`, schema was z.number()
vat_percentage decimal(5,2) → wire `"21.00"`, schema was z.number()
deposit_percentage decimal(5,2) → wire `"…"`, schema was z.number()
Backend has no explicit `decimal:N` cast on these columns
(api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers
the enums + booleans + dates + integers, but skips decimals).
Per the strategic decision (frontend adapts, backend stays):
- schemas/timetable.ts: four fields → z.string().nullable()
- types/timetable.ts: matching ArtistEngagement interface fields →
`string | null`
- PerformancePopover.vue:129: only consumer doing arithmetic on a
decimal field; coerce at the use site via Number(...).toFixed(2).
Single line.
- tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts:
spot-checked mocks; the two with hand-built engagement payloads
flipped fee_amount/buma_percentage/vat_percentage from numbers to
strings to match the new schema. No other mocks needed updating.
The {value, label} enum wrapper claim in the prompt was specifically
debunked in Phase A — every consumer (Wachtrij, PerformanceBlock,
WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry)
already uses .value/.label access against an enumLabel-wrapped schema.
B6 will lock the wire-format contract with a real-API fixture
regression test.
All 397 tests still pass; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure structural seam — no layout changes yet (B3 wires the page through).
apps/app/src/lib/timetable/row-height.ts (NEW):
computeStageRowHeight(laneCount, laneHeightPx, lanePadPx) — one-line pure
function with the existing math: max(1, laneCount) * (laneHeight + lanePad) + lanePad.
Math.max(1, laneCount) keeps an empty stage row visible at single-lane
height instead of collapsing.
apps/app/src/components/timetable/StageRow.vue:
Switches its inline rowHeightPx computation to call the helper. Behavior
identical (the math was the helper's body).
apps/app/src/components/timetable/StageHeaderCell.vue:
New optional `rowHeightPx?: number` prop. When provided (B3 will pass it
from the page via the same helper), the header root applies blockSize
inline so the sticky-left column aligns pixel-for-pixel with the row.
When omitted, the legacy `block-size: 100%` CSS still applies — every
existing call-site keeps working.
apps/app/src/lib/timetable/index.ts: re-export the new helper.
Tests still green (389 across 54 files); typecheck clean.
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>
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>
- PerformancePopover.vue — teleported floating panel; closes on Esc; shows
status chip, advancing %, computed Buma/VAT/total cost; deal-summary +
delete + open-detail buttons. Position math (340px wide, 12px margin,
flip side if no room) ports prototype's pickPos verbatim.
- AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches
CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema
for client-side validation; falls back to surface-level errors map per field.
- StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch
palette picker. Window.confirm cascade-park warning on delete.
- LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages
fire replaceStageDays (atomic per stage).
- Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts;
reads/writes useTimetableStore.statusFilter and searchQuery.
- WachtrijCard.vue — initials avatar + status dot + dot label + cancelled
strike-through. role=button, tabindex=0.
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>