Default parallel execution of sync-check and git-lfs commands within
the pre-push hook deadlocks: both read from stdin (git pipes the push
refspec to pre-push hooks), and two parallel readers never reach EOF.
Add piped: true to force sequential execution. sync-check runs first
(only inspects push_files via lefthook templating, doesn't actually
consume stdin), then git-lfs runs second with clean stdin access.
Observed during chore/test-infra-001 sprint: LFS upload completed
100% but pre-push hook hung indefinitely. Workaround was --no-verify;
this commit removes the need for that.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Marks all three sprint backlog entries Resolved with sprint commit
references and documented deviations:
- TEST-INFRA-001 (b8d18e6, 82af117, f6509d9, 2dfb1e8) — Playwright
foundation operational locally. CI deferred.
- TEST-CONTRACT-001 (2dfb1e8) — 409 conflict shape verified against
real Laravel. Single-context replay instead of two-browser
concurrent edit; UI rollback assertion deferred to F4.
- TEST-VISUAL-001 (f6509d9) — 5 composite baselines from canonical
prototype. Composite-over-isolated rationale: prototype DOM lacks
data-* attributes; isolated artist-name locators would rot. F4
adds isolated baselines using stable data-test-id.
Opens TEST-INFRA-002 for the deferred CI work: Gitea/GitHub Actions
decision, runner image, caching, screenshot-diff artifacts, label-
gated nightly e2e. No deadline; surfaces when first review cycle
feels drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B5 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add dev-docs/ARCH-TESTING.md (~13 KB):
§1 Five-tier pyramid (Unit / Component / Integration / Visual /
E2E) with environment, cost, and purpose per tier
§2 Decision tree — pick by what is being verified, not by speed
§3 Mock-vs-real-backend rules + the self-confirming-bias anti-
pattern that motivated TEST-CONTRACT-001
§4 Visual baseline workflow including the composite-over-isolated
strategy used in B3
§5 CI strategy stub — deferred to TEST-INFRA-002
§6 Conventions + 5 anti-patterns
§7 Vuetify-during-PrimeVue-migration: explicit doc that the
Vuetify plugin in playwright/index.ts is INTENTIONAL TEMPORARY
STATE replaced in F3 by PrimeVue. Forbids the "abstract the UI
framework provider" deferred-cost trap.
§8 Host setup — Node, pnpm, Chromium, Git LFS, MySQL 8, PHP, .env;
known risks (unpkg.com flakiness, shared crewli_test DB)
§9 Deferred work cross-references to BACKLOG entries
- Update CLAUDE.md ### Testing section to reference ARCH-TESTING.md
- Add ARCH-TESTING.md to .claude-sync.conf so the dev-docs sync
pipeline picks it up; sync script run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B4 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add api/database/seeders/E2EBaselineSeeder.php — deterministic seed
for Playwright e2e: e2e@test.local user (org_admin) on a fresh org +
event + stage + StageDay + artist + engagement + performance
(version=0). Writes seeded IDs to api/storage/app/e2e-fixtures.json
so the Playwright fixture can construct API URLs without API
discovery calls.
- Add apps/app/tests/playwright-e2e/global-setup.ts — runs
`php artisan migrate:fresh --force --seed` against crewli_test (the
existing PHPUnit MySQL test DB) before the test suite starts.
Uses --env=testing to satisfy the dangerous-bash hook's migrate:fresh
guard.
- Add apps/app/tests/playwright-e2e/utils/fixtures.ts — typed reader
for e2e-fixtures.json. Cached after first read.
- Add apps/app/tests/playwright-e2e/utils/auth.ts — login helper that
POSTs /api/v1/auth/login and returns user/org IDs. Uses Bearer-via-
cookie flow (per api/.../SetAuthCookie.php), not stateful Sanctum.
- Add apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts —
the contract test: first move with version=0 returns 200, second
move with same stale version returns 409 with shape
`errors.conflict: 'version_mismatch'`. Catches the schema-drift
bug class that timetable-stabilization B5 surfaced.
- Update apps/app/playwright.config.ts — wire globalSetup, webServer
for `php artisan serve --port=8001`, baseURL `http://localhost:8001`
(NOT 127.0.0.1 — auth cookie's domain=localhost requires hostname
match).
- Update .gitignore — runtime e2e-fixtures.json never committed.
DoD-19 met locally: `pnpm test:e2e` passes against a real Laravel
test server. CI integration deferred to TEST-INFRA-002 (per A-1
amendment).
Constraint: e2e tests share the crewli_test DB with PHPUnit. Running
both concurrently would collide. Documented in ARCH-TESTING.md (B5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B3 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add tests/playwright-ct/visual/static-server.mjs: 60-line Node http
server that serves the canonical prototype directory. No new
dependency added (vs. http-server / serve packages).
- Wire static server into playwright-ct.config.ts via webServer; tests
navigate to http://127.0.0.1:5179/crewli-timetable.html.
- Add tests/playwright-ct/visual/prototype-smoke.spec.ts to verify the
prototype loads in CT runner.
- Add tests/playwright-ct/visual/prototype.spec.ts with 5 @visual
composite baselines:
canvas-friday.png — all status colors, b2b indicators,
multi-lane stacking
canvas-saturday.png — conflict ring + capacity warnings
stage-row-multilane.png — first row in isolation
wachtrij-populated.png — sidebar list with parked + pending
popover.png — block-click popover layout
9 additional surfaces from RFC §A.3's enumerated list are documented
as test.skip() with reasons (cancelled status absent from prototype
data, isolated-block locators would lock to artist names, drag-mode
flaky under simulated pointer events, empty Wachtrij/empty day not
reachable from canonical seed). All deferred to F4 component-level
Vue baselines that will use stable data-test-id attributes.
- Baselines stored at tests/playwright-ct/__screenshots__/visual/
prototype.spec.ts/*.png; tracked via Git LFS (.gitattributes).
Composite-over-isolated rationale: the prototype's DOM exposes status
only via inline style.background, no data-* attributes. Isolated-block
baselines would require artist-name locators that silently rot if
prototype data changes. Composite captures yield the same visual
vocabulary in fewer, more stable images. dev-docs/ARCH-TESTING.md (B5)
documents this strategy and the F4 transition plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B2 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add tests/playwright-ct/utils/mountWithProviders.ts: ergonomic
wrapper around Playwright CT's mount() exposing buildMountArgs()
and readNotificationState(). Documents the Vue Test Utils ↔
Playwright CT API divergence (provider plugins must be wired in
beforeMount, not at call time) and the Vuetify-temp lifecycle
(replaced by PrimeVue in F3).
- Add tests/playwright-ct/components/SanityButtonHarness.vue: a
v-btn harness with a click counter; lives in a .vue file so Vite
bundles its CSS-side-effect imports for the browser context
(Playwright CT runs the test orchestrator in Node and components
in a Vite-bundled browser, unlike Vitest's single jsdom graph).
- Add tests/playwright-ct/components/sanity-vuetify.spec.ts: two
tests proving (a) v-btn renders and propagates clicks, (b) the
--v-theme-primary CSS variable resolves to a parseable RGB triplet.
- Update playwright/index.ts: import 'vuetify/styles' so the v-btn
renders with its actual visual appearance (not unstyled). Required
for B3's visual baselines.
3 component tests pass. 402 Vitest tests still pass unchanged.
Lint + typecheck clean on new files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trigger: timetable-stabilization sprint (PR #18, #19) surfaced three
diagnostic incidents that the RFC v1.0 sequencing did not anticipate.
Adds TEST-INFRA-001 as prerequisite sprint before F2 (Playwright +
visual regression infrastructure, baselines against prototype HTML).
Extends F5 with dual-tier visual regression scope. Adds R-11 to risk
register, DoD-16 through DoD-20 to Definition of Done.
No changes to F2-F6 internal architecture, Aura preset, FormField API,
Tailwind v4, or bundle size targets.
Effort impact: +5-7 working days. Total now 15-19 days.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Three trigger upgrades + one new entry, in priority order:
TEST-INFRA-001 — trigger upgraded from "before opening Sessie 5" to
"eerstvolgende sprint na merge van fix/timetable-stabilization", with
explicit dependency: ART-S4-UX-PARITY and all Sessie 5+ work gate on
TEST-INFRA-001 merge. Reden quote captures the three sprint-blok
incidents that proved jsdom-tests do not protect against schema /
filter / UX drift.
TEST-VISUAL-001 — scope expanded to use the prototype HTML at
`./resources/Crewli - Artist Timetable Management/` as the visual
baseline source (not hand-curated screenshots). Added explicit state
matrix per surface: PerformanceBlock 8 states + B2B + cascade-pulse;
PerformancePopover full detail; AddPerformanceDialog drag-mode +
button-mode; Wachtrij filtered/grouped axes. Trigger remains "tweede
toevoeging na TEST-CONTRACT-001" inside the TEST-INFRA-001 sprint.
TEST-CONTRACT-001 — unchanged. Trigger ("eerste e2e na TEST-INFRA-001
lands") was already correct.
ART-S4-UX-PARITY (NEW) — captures Bert's screenshot-report findings as
a seed list grouped A/B/C/D (component-shape / interaction / logic /
AddPerformanceDialog two-mode). Explicit pointer at the bottom to the
Phase A finalization report for the full 20-item itemisation with
severity ratings. Trigger gates Sessie 5 + all subsequent Artist-domain
frontend work behind ART-S4-UX-PARITY merge.
Spelling consistency: VEE-001 entry "formalized" → "formalised" to
match British-English already used elsewhere in the doc and now
mandated by the new CLAUDE.md "Diagnostic discipline" section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New section in CLAUDE.md after "Order of work for each new module".
Three consecutive incidents in the timetable sprint led to formalising
this principle:
- B1 (controller assumed buggy, seeder was wrong) — Phase A's
schema-verify gate against SCHEMA.md:1285 + RFC §10.2 inverted the
fix direction.
- B5 (enum-shape assumed drifted, decimals were wrong) — Phase A's
field-by-field response audit caught the actual decimal-as-string
drift before any "fix" against the wrong hypothesis was written.
- Timetable UX (test-passing layer diverged from prototype) — the
mechanical-vs-UX split surfaced via browser test, not via the
389-test suite which all agreed with the buggy state.
Pattern across all three: the initial hypothesis was wrong. The fix
prompts ALL gated Phase A as STOP-and-report; the schema/contract/
prototype audit was reviewed before any code was written. Codifying
this as an explicit project principle so future fix prompts inherit
the gate by default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A finding A5 traced this race in the browser logs:
GET .../performances?day={festival_id} → 200, 0 results ← wrong day
GET .../children → 200, 3 sub_events
GET .../performances?day={subevent_id} → 200, 13 results ← correct
The pre-fix `isFlatEvent` was:
computed(() => !subEvents.value || subEvents.value.length === 0)
While `subEvents` was still loading (undefined), `!undefined` is `true`,
so isFlatEvent erroneously returned `true` for festivals during the
loading window. dayOptions then took the flat-event branch and seeded
validSubEventIds with the FESTIVAL id. useActiveDay's corrective watcher
rewrote the URL to `?day={festival_id}` and fired a wasted query that
returned zero results (correct semantics — performances live at sub-event
level — but waste + visible URL flicker).
Fix:
computed(() => eventDetail.value?.event_type === 'event')
EventResource always serialises event_type (verified at
api/app/Http/Resources/Api/V1/EventResource.php:26). EventTabsNav
already consumes event_type / is_festival from the same shape
(apps/app/src/components/events/EventTabsNav.vue:175,266) so this is
the canonical signal, not a one-off addition.
New behavior trace:
- Both queries pending → eventDetail=undefined → isFlatEvent=false
→ festival branch returns (subEvents ?? []).map(...)
→ validSubEventIds=[] → activeDayId=null
→ usePerformances.enabled=false → NO fetch
- subEvents resolves first → festival branch populates dayOptions
→ fetch fires with correct sub-event id
- eventDetail resolves first to flat event → flat branch fires
→ fetch with eventDetail.id (correct)
- eventDetail resolves first to festival → still false until subEvents
→ no false-positive flat-event fetch
402 tests still pass; typecheck + lint + production build all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
- base shape: one performance with stage assigned + full engagement
(Bert's browser-tested sample, field-for-field). Asserts decimal-as-
string contract on fee_amount/buma_percentage/vat_percentage AND
enum-label wrapper on booking_status AND nested computed object.
- parked shape: stage_id=null, stage=null (Wachtrij case)
- multi-perf shape: two performances sharing engagement_id
(RFC §D17 "Friday + Saturday under one combined deal")
- sanity: individual performanceSchema parses each fixture element
- regression guard: a payload with NUMBER fee_amount throws (locks
out the pre-B5 bug class)
Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.
Schema surface change to support the test:
apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
(previously a private const inside useTimetable.ts).
apps/app/src/composables/api/useTimetable.ts imports the shared one
instead of redeclaring it locally — single source of truth for the
array shape consumers and tests share.
Test count: 397 → 402 (+5). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
B4 — jsdom-runnable assertions for the structural pieces of B2/B3.
apps/app/tests/unit/lib/timetable/row-height.test.ts (4 tests):
- laneCount=0 → 52px (Math.max(1, 0) fallback path)
- laneCount=1 → 52px (single-lane stage row)
- laneCount=3 → 148px
- laneCount=10 → 484px (10 × 48 + 4)
apps/app/tests/component/StageHeaderCell.test.ts (4 tests):
- row-height-px prop applies as inline blockSize on the root
- prop omitted → no inline blockSize set (legacy `block-size: 100%`
CSS path takes over for any caller still relying on parent-driven sizing)
- 484px for laneCount=10 round-trips through the prop without truncation
- conflict badge renders only when conflictCount > 0 (existing behavior;
locked in as part of touching this surface)
Visual scroll/alignment proof (sticky-left freeze pane, sticky-top axis,
horizontal scroll cohesion across 14 stages, diagonal trackpad scroll,
pixel-perfect header↔row alignment) is deferred to TEST-VISUAL-001
explicitly: jsdom does not compute position:sticky offsets, scrollbar
visibility, layout overflow chains, or scroll containment ancestry. This
is a known limitation of jsdom-based component testing — not a test gap
in this branch. The sticky behavior, z-index ladder, and DOM structure
are all in place per E1-E4; their validation requires a real browser,
which is exactly what the Playwright CT migration on TEST-INFRA-001 +
TEST-VISUAL-001 unlocks.
No existing tests asserted the old broken layout (no references to the
deprecated `tt-page__rows`, `tt-page__stages`, or `<GridBg>` in tests/).
The unused GridBg component file remains on disk; deleting it is a
stylistic cleanup outside this stabilization scope.
Test count: 389 → 397.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the canvas so the spreadsheet-feel works correctly with
the seeder's 14 stages: horizontal scroll moves the rows AND the
TimeAxis together; vertical scroll moves the rows but keeps TimeAxis
pinned; both panes intersect at a fixed corner cell. Diagonal trackpad
scroll behaves naturally because there's only one scroll container.
DOM restructure (E2 — sticky resolves to its nearest scroll ancestor;
fixed by giving sticky elements the right scroll-container parent
instead of patching with absolute positioning):
.tt-page__canvas position: relative; overflow: auto
└ .tt-page__layout display: grid; grid-template-columns: 200px auto;
inline-size: max-content
├ .tt-page__corner sticky top:0 left:0 z=3
├ .tt-page__axis sticky top:0 z=2 (full 1872px wide, no clip)
└ for each stage:
├ .tt-page__header-cell sticky left:0 z=2
│ └ <StageHeaderCell :row-height-px="row.rowHeightPx">
└ .tt-page__row-cell normal z=1 (height = same value)
└ <StageRow>
Z-index ladder (E1) is documented in the page CSS:
corner=3, axis row=2, header rail=2, row content=1, blocks=auto.
Popover + AddPerformanceDialog stay above via Teleport-to-body.
Drops the broken pre-stabilization layout:
- `grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr`
that put ALL stage headers in ONE grid cell (cause of "lanes too tall"
via headers stretching to 100% of the 570px cell)
- nested `overflow: auto` on `.tt-page__rows` (cause of horizontal-scroll
desync — only the rows pane scrolled, axis stayed put)
- `overflow: hidden` on `.tt-page__axis` (E4 — clipped axis ticks beyond
the 1fr cell width)
- `<GridBg :total-height="0" />` which was a no-op anyway; gridlines now
render directly on each `.tt-page__row-cell` background
`inline-size: max-content` on the layout grid forces it wider than the
canvas viewport, so `overflow: auto` on the canvas actually fires a
horizontal scrollbar. Without this, the `auto` second column shrinks to
viewport and nothing overflows.
The page now passes `:row-height-px` to StageHeaderCell (B2 seam, now
load-bearing). Both header and row cell get the same explicit blockSize
inline so the freeze panes align pixel-for-pixel under whatever
laneCount each stage resolves to.
Visual scroll/alignment proof is deferred to TEST-VISUAL-001 — jsdom
cannot verify position:sticky behavior, scrollbar visibility, or pixel
alignment of the freeze panes. This is a known limitation, not a test
gap. B4 covers the structural assertions jsdom CAN verify.
All 389 existing tests still pass; production build smoke 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>
Phase A diagnosed an empty SPA timetable as a controller filter bug. B1.1's
schema-verify gate proved the opposite: the seeder violates Model A, the
controllers are correct.
Canonical model (Model A) per:
- dev-docs/SCHEMA.md:1285 artist_engagements.event_id → festival OR flat event
- dev-docs/SCHEMA.md:1329 performances.event_id → sub-event OR flat event ("show host")
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:1247-1257 (§10.2 contract)
"performance.event_id must be flat event OR a sub-event of the
engagement.event_id festival"
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:455-477 (§D17)
"Friday + Saturday under one combined deal = 1 engagement, 2 performances"
— only works if engagement is at festival level
Controller audit (B1.2): all five filters in
api/app/Http/Controllers/Api/V1/Artist/{PerformanceController,
ArtistEngagementController, StageController}.php already match Model A.
No controller changes needed.
Seeder change (B1.3) — single consistent fix:
ArtistTimetableDevSeeder::seedForFestival now creates one engagement per
(artist, festival) instead of per (artist, sub-event). When the same artist
recurs across iterations on different sub-events, the existing engagement
is reused and another performance is added (the D17 multi-perf path).
Performances continue to carry event_id = sub-event.
Same model fix in seedForSeries (engagement at parent series, performance
at week sub-event).
seedForFlatEvent already conformed (engagement.event_id = performance.event_id
= the flat event itself).
Existence-check semantics shift from `where event_id = $subEvent->id` to
`where event_id = $festival->id` (or $parent->id for series). Numerically
the test counts hold because the bucket-cycling makes scheduled artists
distinct within the festival window.
Tests (B1.4) — new TimetableSeederControllerIntegrationTest with 7 assertions:
- engagement.event_id is at festival level (DB invariant)
- performance.event_id is at sub-event level (DB invariant)
- GET /performances?day={subEvent} returns non-empty + correct event_ids
- GET /performances unfiltered returns all sub-event performances
- GET /performances?stage_id=null returns the seeded parked perf
- GET /engagements returns engagements with event_id = festival
- GET /stages returns 5 stages with event_id = festival
This locks the visible-symptom regression from Session 4: an empty SPA
timetable on a freshly-seeded festival cannot land again silently.
Existing ArtistTimetableDevSeederTest (4 tests) and the broader Artist
suite (121 tests) all stay green. composer analyse + Pint clean.
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)
Lint cleanup spotted during Phase C — `router.replace` returns Promise<void>
which the no-void rule rejects. The dropped void had no behavioural effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new entries that codify the test-architecture roadmap surfaced
during the Session 4 follow-up:
TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright
Component Testing. **Trigger: before opening the Sessie 5 prompt.**
Sessie 5 builds Engagement Detail (6 tabs) + Portal pages (drag-to-
reorder, file uploads); adding more jsdom-based tests for those
surfaces compounds the migration cost.
TEST-CONTRACT-001 — End-to-end 409 contract test against running Laravel.
Trigger: first e2e flow added after TEST-INFRA-001 lands. Highest
contract-protection value per line of test code.
TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock
states (RFC D21/D22/D25/D26). Trigger: second addition to the
TEST-INFRA-001 sprint.
ART-S4-TESTS marked ✅ Resolved with the audit trail of all 9 commits
that landed the test coverage closure (252 → 385 tests across both PRs).
.claude-sync/ regenerated by the post-commit hook (gitignored;
re-uploaded to Project Knowledge separately).
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>
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>
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>
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>
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>
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>
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>
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>
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 A2 — `_timetable.scss` was functionally pure CSS:
only :root custom properties + @keyframes + one .tt-cascade-pulse class.
The only SCSS-specific syntax was `// line comments`. Zero $vars, @use,
@mixin, @function, nesting, or color functions.
Why move to .css: Vitest+jsdom can `import '@/styles/tokens/_timetable.css'`
directly so getComputedStyle() resolves var(--tt-…) in component tests
(needed for the upcoming PerformanceBlock visual-state assertions). SCSS
imports require Vite's SCSS plugin, which the vitest.config.ts intentionally
skips for unit-test speed.
Changes:
- `_timetable.scss` → `_timetable.css` (line comments converted to /* */
block comments; everything else byte-identical)
- `assets/styles/styles.scss`: switch from `@use "@/styles/tokens/timetable"`
to `@import "@/styles/tokens/_timetable.css"`
- Production `npm run build` passes (16s, no asset warnings)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strict-regex sweep of apps/app/src/ confirms zero VeeValidate usage:
no `from 'vee-validate'` imports, no <Field|Form|ErrorMessage>,
no defineRule(), no useForm(). The 15 prior fuzzy matches were
false positives where /useForm/ matched useFormDraft/useFormSteps/
useFormSchemas/useFormFailures.
Changes:
- Remove `vee-validate` and `@vee-validate/zod` from apps/app/package.json
- Regenerate pnpm-lock.yaml (no other deps shifted)
- CLAUDE.md "Forms": replace VeeValidate prescription with the actual
ref + @core/utils/validators + Zod-payload-schema pattern that the
codebase already uses everywhere
- VUEXY_COMPONENTS.md: correct the stale "Registration uses VeeValidate"
claim (the page actually uses useFormDraft + validators); update the
"Form validation" reference row
- BACKLOG.md: close VEE-001 with the audit trail
All 319 existing tests still pass; vue-tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drift from Session 4 step 11 — unplugin-vue-components and unplugin-vue-router
regenerated their .d.ts files for the new timetable surface. Was missed in the
original commit because the test runner doesn't trigger regen.
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>
usePointerDrag — PointerEvents primitive with capture, escape-cancel,
keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy
mousedown stack the prototype used.
useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan,
matches prototype audit §4.1). Emits onClick when the pointer never crossed
the threshold; otherwise enters drag mode and emits onDragStart / onDragMove /
onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end
so the synthetic click never opens the popover.
RFC v0.2 D7 — implemented once instead of three times like the prototype.
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>