diff --git a/CLAUDE.md b/CLAUDE.md index 06cdde68..83e75554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,10 +236,24 @@ you are using available components rather than building custom ones. ### Forms -- VeeValidate for form state + Zod for schema validation — always together -- Zod schemas must mirror the backend Form Request rules (field names, required/optional, types) +Canonical form pattern (used everywhere in the SPA): + +- `ref({ field: ... })` for form state +- `VForm` ref + per-field rules drawn from `@core/utils/validators` + (`requiredValidator`, `emailValidator`, etc.) +- A separate `errors: Ref>` for server-validation + feedback (mapped from 422 responses) +- **Zod** for runtime validation of API payloads/responses (in + `apps/app/src/schemas/*.ts`) — Zod schemas mirror backend Form Requests + (field names, required/optional, types) and are the canonical contract - No inline validation logic in components +VeeValidate is **NOT** the form library here. It was previously listed +but never actually adopted in any page; it was removed in commit +`` (Session 4 follow-up). Reference forms: `apps/app/src/components/sections/CreateShiftDialog.vue`, +`apps/app/src/components/timetable/AddPerformanceDialog.vue`, +`apps/app/src/pages/register/[public_token].vue`. + ### Naming - DB columns: `snake_case` diff --git a/api/database/seeders/ArtistTimetableDevSeeder.php b/api/database/seeders/ArtistTimetableDevSeeder.php index 0529a3a9..0f167fc7 100644 --- a/api/database/seeders/ArtistTimetableDevSeeder.php +++ b/api/database/seeders/ArtistTimetableDevSeeder.php @@ -15,195 +15,559 @@ use App\Models\Performance; use App\Models\Stage; use App\Models\StageDay; use Carbon\CarbonImmutable; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; /** - * Seeds the Artist & Timetable fixture for an existing festival with - * three sub-events (e.g. Echt Feesten 2026 → Vrijdag/Zaterdag/Zondag). + * Seeds the org-wide artist roster + per-event timetable fixtures. * - * Reproduces the prototype audit fixture as closely as possible: - * - 4 stages on the festival - * - 4 stages × 3 sub-events = 12 stage_days rows - * - 4 genres, 6 master artists with default genre + draw - * - 12 engagements with status mix (Draft 1, Requested 2, Option 3, - * Confirmed 2, Contracted 3, Cancelled 1). Two artists each have - * two engagements (one per day) to exercise the multi-engagement - * case (D17). - * - 13 performances; one parked (stage_id=null); a B2B pair within - * 3 minutes on the same stage/lane to seed Session 4 frontend dev. - * - One ArtistContact per artist (tour-manager role). + * Three entry points: + * - seedOrganisationPool() — 8 genres + 125 artists at the organisation level + * - seedForFestival() — multi-day festival with sub-events (Echt Feesten) + * - seedForFlatEvent() — single-day event (Braderie, Koningsdag, Nacht) + * - seedForSeries() — recurring series with weekly sub-events (IJsbaan) * - * advance_sections seeding lands in Session 3 with the form-builder - * integration. + * Every event seeded gets: + * - 2–5 stages (with stage_days for active days) + * - "scheduled" engagements: artists with one or more performances on a stage + * - "unscheduled" engagements: artists linked to the event but without + * any performance row yet (= booked but not slotted in the timetable) */ final class ArtistTimetableDevSeeder { - /** @param array $subEvents Sub-events keyed 0..n in date order */ - public static function seedForFestival(Organisation $org, Event $festival, array $subEvents): void + /** + * Curated genre palette used across the org pool. + * + * @var list + */ + private const GENRE_SPECS = [ + ['name' => 'Hardstyle', 'color' => '#e85d75'], + ['name' => 'Techno', 'color' => '#1f6feb'], + ['name' => 'Indie', 'color' => '#7c3aed'], + ['name' => 'Live band', 'color' => '#0ea5e9'], + ['name' => 'House', 'color' => '#22c55e'], + ['name' => 'Hip-hop', 'color' => '#f59e0b'], + ['name' => 'Drum & Bass', 'color' => '#dc2626'], + ['name' => 'Akoestisch', 'color' => '#6366f1'], + ]; + + // ========================================================================= + // Public entry points + // ========================================================================= + + /** + * Seed an organisation-level roster: 8 genres + N artists. + * + * @return array{genres: Collection, artists: Collection} + */ + public static function seedOrganisationPool(Organisation $org, int $artistCount = 125): array + { + $genres = self::createGenres($org); + $artists = self::createArtistPool($org, $genres, $artistCount); + + return ['genres' => $genres, 'artists' => $artists]; + } + + /** + * Seed Echt Feesten festival fixture: 5 stages, ~30 scheduled + * engagements over 3 days, ~10 unscheduled engagements. Preserves + * the prototype audit fixture's special test cases (B2B pair on + * Mainstage Saturday, multi-perf engagement, parked performance). + * + * @param array $subEvents Sub-events keyed 0..n in date order + * @param array{genres: Collection, artists: Collection} $pool + * @return array{stages: int, engagements: int, performances: int, unscheduled: int} + */ + public static function seedForFestival(Organisation $org, Event $festival, array $subEvents, array $pool): array { if (count($subEvents) < 3) { - return; + return ['stages' => 0, 'engagements' => 0, 'performances' => 0, 'unscheduled' => 0]; } - // Genres - $genres = collect([ - ['name' => 'Hardstyle', 'color' => '#e85d75'], - ['name' => 'Techno', 'color' => '#1f6feb'], - ['name' => 'Indie', 'color' => '#7c3aed'], - ['name' => 'Live band', 'color' => '#0ea5e9'], - ])->mapWithKeys(fn (array $g) => [ - $g['name'] => Genre::create([ - 'organisation_id' => $org->id, - 'name' => $g['name'], - 'color' => $g['color'], - 'sort_order' => 0, - 'is_active' => true, - ]), - ]); - - // Stages on the festival - $stages = collect([ + $stages = self::createStages($festival, [ ['name' => 'Mainstage', 'color' => '#e85d75', 'capacity' => 4500, 'sort_order' => 1], ['name' => 'Havana', 'color' => '#0ea5e9', 'capacity' => 1200, 'sort_order' => 2], ['name' => 'Stairway', 'color' => '#7c3aed', 'capacity' => 800, 'sort_order' => 3], ['name' => 'Socialite', 'color' => '#22c55e', 'capacity' => 600, 'sort_order' => 4], - ])->mapWithKeys(fn (array $s) => [ - $s['name'] => Stage::create(['event_id' => $festival->id, ...$s]), - ]); + ['name' => 'Tent op het Plein', 'color' => '#f59e0b', 'capacity' => 400, 'sort_order' => 5], + ], $subEvents); - // stage_days — every stage active every sub-event day - foreach ($stages as $stage) { - foreach ($subEvents as $subEvent) { - StageDay::create([ - 'stage_id' => $stage->id, - 'event_id' => $subEvent->id, + // Reserve the first 50 artists from the org pool for festival + // engagements. (Other events draw from later slices to keep + // some artists reserved for "unscheduled" / pure-pool variety.) + $bucket = $pool['artists']->slice(0, 50)->values(); + $bucketIdx = 0; + + $engagements = 0; + $performances = 0; + $unscheduled = 0; + + // ── Scheduled engagements: 30 across 3 days ── + // Status mix loosely follows RFC §5.3: lots of contracted/confirmed, + // some optioned/requested, a couple of drafts and one cancelled. + $statusMix = [ + ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, + ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, + ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed, + ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option, + ArtistEngagementStatus::Option, ArtistEngagementStatus::Requested, + ]; + $statusIdx = 0; + + $hourSlots = [16, 17, 18, 19, 20, 21, 22, 23]; + + foreach ($subEvents as $dayIdx => $subEvent) { + // Each sub-event gets 10 scheduled engagements across the 5 stages + for ($i = 0; $i < 10; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + $status = $statusMix[$statusIdx++ % count($statusMix)]; + + // unique(artist_id, event_id): only one engagement per + // (artist, sub-event). Since we wrap the pool, skip if + // we'd violate the constraint. + $existing = ArtistEngagement::query() + ->where('artist_id', $artist->id) + ->where('event_id', $subEvent->id) + ->exists(); + if ($existing) { + continue; + } + + $engagement = self::createEngagement($artist, $subEvent, $status); + $engagements++; + + $hour = $hourSlots[$i % count($hourSlots)]; + $minute = ($i % 4) * 15; + $stage = $stages->values()[$i % $stages->count()]; + self::createPerformance($engagement, $subEvent, $stage, $hour, $minute, 60); + $performances++; + + // ~25% of contracted/confirmed engagements get a 2nd perf (D17 multi-perf) + if ($i % 4 === 0 && in_array($status, [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed], true)) { + self::createPerformance($engagement, $subEvent, $stage, $hour + 4, $minute, 45); + $performances++; + } + } + } + + // ── Unscheduled engagements: 12 artists linked but no performance ── + $unscheduledStatusMix = [ + ArtistEngagementStatus::Draft, ArtistEngagementStatus::Draft, + ArtistEngagementStatus::Requested, ArtistEngagementStatus::Requested, + ArtistEngagementStatus::Option, ArtistEngagementStatus::Option, + ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Offered, + ]; + $u = 0; + for ($i = 0; $i < 12; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + $subEvent = $subEvents[$i % count($subEvents)]; + + $existing = ArtistEngagement::query() + ->where('artist_id', $artist->id) + ->where('event_id', $subEvent->id) + ->exists(); + if ($existing) { + continue; + } + + self::createEngagement($artist, $subEvent, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]); + $engagements++; + $unscheduled++; + } + + // ── One parked performance (= scheduled but stage_id = null) ── + // Exercises the Session 4 "wachtrij" UI. + $parkedArtist = $bucket[$bucketIdx++ % $bucket->count()]; + $parkedSub = $subEvents[0]; + if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $parkedSub->id)->exists()) { + $parkedEngagement = self::createEngagement($parkedArtist, $parkedSub, ArtistEngagementStatus::Confirmed); + Performance::create([ + 'engagement_id' => $parkedEngagement->id, + 'event_id' => $parkedSub->id, + 'stage_id' => null, + 'lane' => 0, + 'start_at' => CarbonImmutable::parse($parkedSub->start_date)->setTime(0, 0), + 'end_at' => CarbonImmutable::parse($parkedSub->start_date)->setTime(1, 0), + 'version' => 0, + ]); + $engagements++; + $performances++; + } + + // ── B2B pair on Mainstage Saturday with 3-min offset (B2B detector seed) ── + // Two artists overlap on the same stage/lane within 3 minutes. + $zaterdag = $subEvents[1]; + $b2bA = $bucket[$bucketIdx++ % $bucket->count()]; + $b2bB = $bucket[$bucketIdx++ % $bucket->count()]; + $mainstage = $stages['Mainstage'] ?? $stages->first(); + + foreach ([['artist' => $b2bA, 'minute' => 30], ['artist' => $b2bB, 'minute' => 33]] as $pair) { + if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $zaterdag->id)->exists()) { + continue; + } + $eng = self::createEngagement($pair['artist'], $zaterdag, ArtistEngagementStatus::Contracted); + self::createPerformance($eng, $zaterdag, $mainstage, 23, $pair['minute'], 60); + $engagements++; + $performances++; + } + + return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; + } + + /** + * Seed a flat single-day event (e.g. Braderie, Koningsdag, Nacht). + * + * @param array{genres: Collection, artists: Collection} $pool + * @param array{ + * stages?: list, + * scheduled?: int, + * unscheduled?: int, + * pool_offset?: int, + * start_hour?: int, + * end_hour?: int, + * force_status?: ArtistEngagementStatus + * } $config + * @return array{stages: int, engagements: int, performances: int, unscheduled: int} + */ + public static function seedForFlatEvent(Organisation $org, Event $event, array $pool, array $config = []): array + { + $stageSpecs = $config['stages'] ?? [ + ['name' => 'Hoofdpodium', 'color' => '#e85d75', 'capacity' => 1500, 'sort_order' => 1], + ['name' => 'Tweede Podium', 'color' => '#0ea5e9', 'capacity' => 600, 'sort_order' => 2], + ]; + $stages = self::createStages($event, $stageSpecs, [$event]); + + $scheduledCount = $config['scheduled'] ?? 10; + $unscheduledCount = $config['unscheduled'] ?? 5; + $offset = $config['pool_offset'] ?? 50; + $startHour = $config['start_hour'] ?? 14; + $endHour = $config['end_hour'] ?? 23; + $forceStatus = $config['force_status'] ?? null; + + $bucket = $pool['artists']->slice($offset, $scheduledCount + $unscheduledCount + 4)->values(); + $bucketIdx = 0; + + $engagements = 0; + $performances = 0; + $unscheduled = 0; + + $statusMix = $forceStatus !== null ? [$forceStatus] : [ + ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Contracted, + ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Confirmed, + ArtistEngagementStatus::Option, ArtistEngagementStatus::Requested, + ]; + $statusIdx = 0; + + $totalHours = max(1, $endHour - $startHour); + + for ($i = 0; $i < $scheduledCount; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $event->id)->exists()) { + continue; + } + + $status = $statusMix[$statusIdx++ % count($statusMix)]; + $engagement = self::createEngagement($artist, $event, $status); + $engagements++; + + $stage = $stages->values()[$i % $stages->count()]; + $hour = $startHour + ($i % $totalHours); + $minute = ($i % 4) * 15; + self::createPerformance($engagement, $event, $stage, $hour, $minute, 60); + $performances++; + } + + $unscheduledStatusMix = $forceStatus !== null ? [$forceStatus] : [ + ArtistEngagementStatus::Draft, + ArtistEngagementStatus::Requested, + ArtistEngagementStatus::Option, + ArtistEngagementStatus::Confirmed, + ]; + for ($i = 0; $i < $unscheduledCount; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $event->id)->exists()) { + continue; + } + self::createEngagement($artist, $event, $unscheduledStatusMix[$i % count($unscheduledStatusMix)]); + $engagements++; + $unscheduled++; + } + + return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; + } + + /** + * Seed a recurring series (e.g. IJsbaan): stages live on the parent + * event, and each weekly sub-event gets its own scheduled + + * unscheduled engagements. + * + * @param array $subEvents + * @param array{genres: Collection, artists: Collection} $pool + * @param array{ + * stages?: list, + * per_week_scheduled?: int, + * per_week_unscheduled?: int, + * pool_offset?: int + * } $config + * @return array{stages: int, engagements: int, performances: int, unscheduled: int} + */ + public static function seedForSeries(Organisation $org, Event $parent, array $subEvents, array $pool, array $config = []): array + { + $stageSpecs = $config['stages'] ?? [ + ['name' => 'Schaatspaviljoen', 'color' => '#0ea5e9', 'capacity' => 800, 'sort_order' => 1], + ['name' => 'Verwarmde Tent', 'color' => '#22c55e', 'capacity' => 300, 'sort_order' => 2], + ]; + $stages = self::createStages($parent, $stageSpecs, $subEvents); + + $perWeekScheduled = $config['per_week_scheduled'] ?? 3; + $perWeekUnscheduled = $config['per_week_unscheduled'] ?? 2; + $offset = $config['pool_offset'] ?? 80; + + $bucket = $pool['artists']->slice($offset, ($perWeekScheduled + $perWeekUnscheduled) * count($subEvents) + 4)->values(); + $bucketIdx = 0; + + $engagements = 0; + $performances = 0; + $unscheduled = 0; + + foreach ($subEvents as $weekIdx => $week) { + for ($i = 0; $i < $perWeekScheduled; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $week->id)->exists()) { + continue; + } + + $status = [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option][$i % 3]; + $engagement = self::createEngagement($artist, $week, $status); + $engagements++; + + $stage = $stages->values()[$i % $stages->count()]; + self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60); + $performances++; + } + + for ($i = 0; $i < $perWeekUnscheduled; $i++) { + $artist = $bucket[$bucketIdx++ % $bucket->count()]; + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $week->id)->exists()) { + continue; + } + $status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3]; + self::createEngagement($artist, $week, $status); + $engagements++; + $unscheduled++; + } + } + + return ['stages' => $stages->count(), 'engagements' => $engagements, 'performances' => $performances, 'unscheduled' => $unscheduled]; + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + /** @return Collection */ + private static function createGenres(Organisation $org): Collection + { + $genres = collect(); + foreach (self::GENRE_SPECS as $i => $spec) { + $genres[$spec['name']] = Genre::create([ + 'organisation_id' => $org->id, + 'name' => $spec['name'], + 'color' => $spec['color'], + 'sort_order' => $i + 1, + 'is_active' => true, + ]); + } + + return $genres; + } + + /** + * @param Collection $genres + * @return Collection + */ + private static function createArtistPool(Organisation $org, Collection $genres, int $count): Collection + { + $names = self::generateArtistNames($count); + $countries = ['NL', 'NL', 'NL', 'NL', 'NL', 'BE', 'BE', 'DE', 'UK', 'FR']; + $genreList = $genres->values(); + + $artists = collect(); + foreach ($names as $idx => $name) { + $genre = $genreList[$idx % $genreList->count()]; + $artist = Artist::create([ + 'organisation_id' => $org->id, + 'name' => $name, + 'default_genre_id' => $genre->id, + 'default_draw' => fake()->numberBetween(50, 5000), + 'star_rating' => fake()->numberBetween(1, 5), + 'home_base_country' => $countries[$idx % count($countries)], + ]); + $artists->push($artist); + + // ~30% get a tour-manager contact for downstream advance/portal flows + if ($idx % 3 === 0) { + ArtistContact::create([ + 'artist_id' => $artist->id, + 'name' => 'Tour Manager '.$artist->name, + 'email' => 'tm-'.$artist->slug.'@example.test', + 'phone' => '+316123'.str_pad((string) ($idx + 1), 5, '0', STR_PAD_LEFT), + 'role' => 'tour_manager', + 'is_primary' => true, + 'receives_briefing' => true, + 'receives_infosheet' => true, ]); } } - // Master artists - $artistsData = [ - ['name' => 'Donker & Licht', 'genre' => 'Hardstyle', 'draw' => 3500, 'star' => 5], - ['name' => 'Voltage Collective', 'genre' => 'Techno', 'draw' => 1800, 'star' => 4], - ['name' => 'Roos & de Wolf', 'genre' => 'Indie', 'draw' => 700, 'star' => 3], - ['name' => 'Rotterdam Brass', 'genre' => 'Live band', 'draw' => 900, 'star' => 4], - ['name' => 'Nachtwacht DJs', 'genre' => 'Techno', 'draw' => 1100, 'star' => 3], - ['name' => 'De Lichtbrigade', 'genre' => 'Live band', 'draw' => 500, 'star' => 3], + return $artists; + } + + /** + * Generate $count unique artist names by combining curated parts. + * The first ~40 entries are hand-picked "real-feeling" names so the + * top of the list looks human; the remainder are generated. + * + * @return list + */ + private static function generateArtistNames(int $count): array + { + $curated = [ + 'Donker & Licht', 'Voltage Collective', 'Roos & de Wolf', 'Rotterdam Brass', + 'Nachtwacht DJs', 'De Lichtbrigade', 'Dijkdoorbraak', 'Kaapse Gasten', + 'Polderpop', 'De Stadsklokken', 'Noorderzon Project', 'Bij de Buren', + 'Echo van de Maas', 'Kortsluiting', 'Bonte Hond', 'Rauwe Diamant', + 'Maan & Sterren', 'Zondagmiddag Sessies', 'Het Geluidsmuseum', 'Storm op Zee', + 'De Jonge Honden', 'Volksfeest Brass Band', 'Tussen de Wolken', 'Plat Vlaams', + 'Stille Helden', 'De Veerboot', 'Holland Heat', 'Springstof', + 'Café Onder de Brug', 'Mannen van Staal', 'Dijk Disco', 'Studio West', + 'Het Kleine Orkest', 'Hard tegen Hart', 'Nachtsessie Live', 'Boterham met Tien', + 'De Klompendansers', 'Kraakheldere Stemmen', 'Vrijdagavondblues', 'Broeders Beeld', ]; - $artists = []; - foreach ($artistsData as $data) { - /** @var Artist $artist */ - $artist = Artist::create([ - 'organisation_id' => $org->id, - 'name' => $data['name'], - 'default_genre_id' => $genres[$data['genre']]->id, - 'default_draw' => $data['draw'], - 'star_rating' => $data['star'], - 'home_base_country' => 'NL', - ]); - $artists[$data['name']] = $artist; - - ArtistContact::create([ - 'artist_id' => $artist->id, - 'name' => 'Tour Manager '.$artist->name, - 'email' => 'tm-'.$artist->slug.'@example.test', - 'phone' => '+31612340000', - 'role' => 'tour_manager', - 'is_primary' => true, - 'receives_briefing' => true, - 'receives_infosheet' => true, - ]); - } - - // Engagements (12) — status mix per RFC §5.3 Session 1 prompt. - // Two artists get two engagements (different days) to exercise D17. - $statusPlan = [ - ['artist' => 'Donker & Licht', 'sub' => 0, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Donker & Licht', 'sub' => 1, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Voltage Collective', 'sub' => 0, 'status' => ArtistEngagementStatus::Confirmed], - ['artist' => 'Voltage Collective', 'sub' => 1, 'status' => ArtistEngagementStatus::Option], - ['artist' => 'Roos & de Wolf', 'sub' => 1, 'status' => ArtistEngagementStatus::Contracted], - ['artist' => 'Rotterdam Brass', 'sub' => 0, 'status' => ArtistEngagementStatus::Confirmed], - ['artist' => 'Rotterdam Brass', 'sub' => 2, 'status' => ArtistEngagementStatus::Requested], - ['artist' => 'Nachtwacht DJs', 'sub' => 1, 'status' => ArtistEngagementStatus::Option], - ['artist' => 'Nachtwacht DJs', 'sub' => 2, 'status' => ArtistEngagementStatus::Cancelled], - ['artist' => 'De Lichtbrigade', 'sub' => 0, 'status' => ArtistEngagementStatus::Draft], - ['artist' => 'De Lichtbrigade', 'sub' => 1, 'status' => ArtistEngagementStatus::Requested], - ['artist' => 'De Lichtbrigade', 'sub' => 2, 'status' => ArtistEngagementStatus::Option], + $cores = [ + 'Voltage', 'Echo', 'Lumen', 'Donker', 'Licht', 'Storm', 'Zon', 'Maan', + 'Stadse', 'Polder', 'Noord', 'Zuid', 'Oost', 'West', 'Maas', 'IJssel', + 'Rotterdam', 'Amsterdam', 'Utrecht', 'Eindhoven', 'Brabant', 'Limburg', + 'Kraak', 'Veer', 'Brug', 'Plein', 'Markt', 'Park', 'Tuin', 'Bos', + 'Ster', 'Wolk', 'Regen', 'Wind', 'Vuur', 'IJs', 'Sneeuw', 'Mist', + ]; + $suffixes = [ + 'Collective', 'Project', 'Sound', 'Live', 'Crew', 'Brothers', 'Sisters', + 'Sessions', 'Orkest', 'Band', 'Trio', 'Quartet', 'DJs', 'System', + 'Allstars', 'Republic', 'Union', 'Express', 'Riders', 'Beats', ]; - $engagements = []; - foreach ($statusPlan as $idx => $plan) { - /** @var Event $subEvent */ - $subEvent = $subEvents[$plan['sub']]; - $artist = $artists[$plan['artist']]; - - $attrs = [ - 'artist_id' => $artist->id, - 'event_id' => $subEvent->id, - 'booking_status' => $plan['status'], - 'fee_currency' => 'EUR', - 'buma_applicable' => true, - 'buma_percentage' => 7.00, - 'buma_handled_by' => 'organisation', - 'vat_applicable' => true, - 'vat_percentage' => 21.00, - 'payment_status' => 'none', - 'crew_count' => 2, - 'guests_count' => 4, - 'advancing_completed_count' => 0, - 'advancing_total_count' => 0, - ]; - - if ($plan['status'] === ArtistEngagementStatus::Option) { - $attrs['option_expires_at'] = CarbonImmutable::now()->addDays(14); + $names = $curated; + $taken = array_flip($names); + $i = 0; + while (count($names) < $count) { + $core = $cores[$i % count($cores)]; + $suffix = $suffixes[(int) ($i / count($cores)) % count($suffixes)]; + $candidate = $core.' '.$suffix; + if (! isset($taken[$candidate])) { + $names[] = $candidate; + $taken[$candidate] = true; } - if ($plan['status'] === ArtistEngagementStatus::Requested) { - $attrs['requested_at'] = CarbonImmutable::now()->subDays(3); + $i++; + // Safety: avoid infinite loop if combinations exhaust + if ($i > count($cores) * count($suffixes) * 2) { + $names[] = $core.' '.$suffix.' '.Str::upper(Str::random(3)); } - if ($plan['status'] === ArtistEngagementStatus::Contracted) { - $attrs['fee_amount'] = 7500.00; - } - - $engagements[$idx] = ArtistEngagement::create($attrs); } - // Performances (13) — most engagements get 1 perf, a couple - // get 2 (D17). One parked. One B2B pair on Mainstage Saturday. - $perfPlan = [ - // [engagementIdx, stageName|null, subIdx, hour, minute, duration] - [0, 'Mainstage', 0, 22, 0, 75], // Donker & Licht — Vrijdag mainstage - [1, 'Mainstage', 1, 23, 30, 60], // Donker & Licht — Zaterdag mainstage (B2B partner-A) - [1, 'Mainstage', 1, 21, 0, 60], // …extra perf same engagement (D17 multi-perf) - [2, 'Havana', 0, 21, 0, 60], - [3, 'Havana', 1, 22, 30, 60], - [4, 'Stairway', 1, 21, 0, 75], - [5, 'Socialite', 0, 19, 0, 60], - [6, 'Socialite', 2, 17, 0, 45], - [7, 'Mainstage', 1, 23, 33, 60], // B2B partner-B (3-min offset → seeds B2B detector) - [9, null, 0, 0, 0, 60], // De Lichtbrigade Vrijdag = parked / wachtrij - [10, 'Stairway', 1, 19, 0, 60], - [11, 'Havana', 2, 20, 30, 60], - [4, 'Stairway', 1, 22, 30, 60], // multi-perf on same engagement (D17) + return array_slice($names, 0, $count); + } + + /** + * Create stages on $event and stage_days entries linking each stage + * to every $performanceDay (the parent event itself for flat events, + * each sub-event for festivals/series). + * + * @param list $specs + * @param array $performanceDays + * @return Collection + */ + private static function createStages(Event $event, array $specs, array $performanceDays): Collection + { + $stages = collect(); + foreach ($specs as $spec) { + $stage = Stage::create([ + 'event_id' => $event->id, + 'name' => $spec['name'], + 'color' => $spec['color'], + 'capacity' => $spec['capacity'], + 'sort_order' => $spec['sort_order'], + ]); + $stages[$spec['name']] = $stage; + + foreach ($performanceDays as $day) { + StageDay::create([ + 'stage_id' => $stage->id, + 'event_id' => $day->id, + ]); + } + } + + return $stages; + } + + private static function createEngagement(Artist $artist, Event $event, ArtistEngagementStatus $status): ArtistEngagement + { + $attrs = [ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + 'booking_status' => $status, + 'fee_currency' => 'EUR', + 'buma_applicable' => true, + 'buma_percentage' => 7.00, + 'buma_handled_by' => 'organisation', + 'vat_applicable' => true, + 'vat_percentage' => 21.00, + 'payment_status' => 'none', + 'crew_count' => fake()->numberBetween(1, 4), + 'guests_count' => fake()->numberBetween(0, 6), + 'advancing_completed_count' => 0, + 'advancing_total_count' => 0, ]; - foreach ($perfPlan as $row) { - [$engagementIdx, $stageName, $subIdx, $hour, $minute, $minutes] = $row; - $engagement = $engagements[$engagementIdx]; - /** @var Event $subEvent */ - $subEvent = $subEvents[$subIdx]; - - $start = CarbonImmutable::parse($subEvent->start_date)->setTime($hour, $minute); - - Performance::create([ - 'engagement_id' => $engagement->id, - 'event_id' => $subEvent->id, - 'stage_id' => $stageName === null ? null : $stages[$stageName]->id, - 'lane' => 0, - 'start_at' => $start, - 'end_at' => $start->addMinutes($minutes), - 'version' => 0, - ]); + if ($status === ArtistEngagementStatus::Option) { + $attrs['option_expires_at'] = CarbonImmutable::now()->addDays(fake()->numberBetween(7, 30)); } + if ($status === ArtistEngagementStatus::Requested) { + $attrs['requested_at'] = CarbonImmutable::now()->subDays(fake()->numberBetween(1, 14)); + } + if ($status === ArtistEngagementStatus::Contracted) { + $attrs['fee_amount'] = fake()->randomFloat(2, 750, 18000); + } + if ($status === ArtistEngagementStatus::Confirmed) { + $attrs['fee_amount'] = fake()->randomFloat(2, 500, 12000); + } + + return ArtistEngagement::create($attrs); + } + + private static function createPerformance( + ArtistEngagement $engagement, + Event $day, + Stage $stage, + int $hour, + int $minute, + int $minutes, + ): Performance { + $dayOffset = intdiv($hour, 24); + $start = CarbonImmutable::parse($day->start_date) + ->addDays($dayOffset) + ->setTime($hour % 24, $minute); + + return Performance::create([ + 'engagement_id' => $engagement->id, + 'event_id' => $day->id, + 'stage_id' => $stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addMinutes($minutes), + 'version' => 0, + ]); } } diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index f3e4f331..df4bde1b 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -43,6 +43,9 @@ class DevSeeder extends Seeder /** @var array */ private array $personTags = []; + /** @var array{genres: \Illuminate\Support\Collection, artists: \Illuminate\Support\Collection}|null */ + private ?array $artistPool = null; + public function run(): void { $this->call(RoleSeeder::class); @@ -163,7 +166,14 @@ class DevSeeder extends Seeder $templatesCreated = FormBuilderDevSeeder::seedSystemTemplates($this->org); - $this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates created"); + // ── Org-wide artist pool (8 genres + 125 artists) ── + // Used as the shared roster: every event draws scheduled + + // unscheduled engagements from this pool. + $this->artistPool = ArtistTimetableDevSeeder::seedOrganisationPool($this->org, 125); + $artistCount = $this->artistPool['artists']->count(); + $genreCount = $this->artistPool['genres']->count(); + + $this->command->info(" Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, {$templatesCreated} form_templates, {$genreCount} genres, {$artistCount} artists created"); }); } @@ -956,14 +966,16 @@ class DevSeeder extends Seeder FormBuilderDevSeeder::seedEventRegistrationShowcase($this->org, $festival, $this->command); } - // RFC-TIMETABLE v0.2 — artist + timetable fixture for the - // festival (4 stages, 6 artists, 12 engagements, 13 perfs). - ArtistTimetableDevSeeder::seedForFestival( + // RFC-TIMETABLE v0.2 — festival timetable: 5 stages, ~30 scheduled + // performances over 3 days + ~12 unscheduled engagements (linked + // but not yet slotted on a stage/timeslot). Draws from the org pool. + $tt = ArtistTimetableDevSeeder::seedForFestival( $this->org, $festival, [$vrijdag, $zaterdag, $zondag], + $this->artistPool, ); - $this->command->info(' Artist timetable: 4 stages, 12 stage_days, 6 artists, 12 engagements, 13 performances'); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); $this->command->info(' Echt Feesten 2026 complete'); }); @@ -1031,6 +1043,20 @@ class DevSeeder extends Seeder $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($braderie, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + // Stages + timetable (flat event, 1 day, lighter program) + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $braderie, $this->artistPool, [ + 'stages' => [ + ['name' => 'Marktplein Podium', 'color' => '#e85d75', 'capacity' => 800, 'sort_order' => 1], + ['name' => 'Akoestiekhoek', 'color' => '#22c55e', 'capacity' => 200, 'sort_order' => 2], + ], + 'scheduled' => 8, + 'unscheduled' => 4, + 'pool_offset' => 50, + 'start_hour' => 11, + 'end_hour' => 18, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); + $this->command->info(' Braderie Dorpstown 2026 complete'); }); } @@ -1206,6 +1232,18 @@ class DevSeeder extends Seeder $formSchema = FormBuilderDevSeeder::seedEventSchema($ijsbaan); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($ijsbaan, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + + // Stages + timetable (series, 4 weekend editions) + $tt = ArtistTimetableDevSeeder::seedForSeries($this->org, $ijsbaan, $weeks, $this->artistPool, [ + 'stages' => [ + ['name' => 'Schaatspaviljoen', 'color' => '#0ea5e9', 'capacity' => 800, 'sort_order' => 1], + ['name' => 'Verwarmde Tent', 'color' => '#22c55e', 'capacity' => 300, 'sort_order' => 2], + ], + 'per_week_scheduled' => 3, + 'per_week_unscheduled' => 2, + 'pool_offset' => 65, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); }); } @@ -1378,6 +1416,24 @@ class DevSeeder extends Seeder $formSchema = FormBuilderDevSeeder::seedEventSchema($koningsdag); $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($koningsdag, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + + // Stages + timetable (closed event — most acts contracted+done, + // but a small backlog of unscheduled engagements lives in the + // pool to demo "linked, not slotted" alongside historical data.) + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $koningsdag, $this->artistPool, [ + 'stages' => [ + ['name' => 'Erasmusbrug Podium', 'color' => '#e85d75', 'capacity' => 2000, 'sort_order' => 1], + ['name' => 'Willemsplein Stage', 'color' => '#0ea5e9', 'capacity' => 1500, 'sort_order' => 2], + ['name' => 'Oude Haven Floater', 'color' => '#7c3aed', 'capacity' => 600, 'sort_order' => 3], + ], + 'scheduled' => 12, + 'unscheduled' => 4, + 'pool_offset' => 90, + 'start_hour' => 12, + 'end_hour' => 23, + 'force_status' => \App\Enums\Artist\ArtistEngagementStatus::Contracted, + ]); + $this->command->info(" Artist timetable: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); }); } @@ -1410,6 +1466,21 @@ class DevSeeder extends Seeder FormBuilderDevSeeder::seedEventSchema($event); + // Stages + minimal timetable (draft event — 4 acts in option/draft + // status with a couple already slotted, plus a few unscheduled). + $tt = ArtistTimetableDevSeeder::seedForFlatEvent($this->org, $event, $this->artistPool, [ + 'stages' => [ + ['name' => 'Deliplein', 'color' => '#7c3aed', 'capacity' => 500, 'sort_order' => 1], + ['name' => 'Kaapse Bar', 'color' => '#22c55e', 'capacity' => 200, 'sort_order' => 2], + ], + 'scheduled' => 4, + 'unscheduled' => 5, + 'pool_offset' => 110, + 'start_hour' => 21, + 'end_hour' => 26, + ]); + $this->command->info(" Draft event: {$tt['stages']} stages, {$tt['engagements']} engagements ({$tt['unscheduled']} unscheduled), {$tt['performances']} performances"); + $this->command->info(' Empty draft event created (with form schema, 0 submissions)'); }); } diff --git a/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php index 184122f8..99d2aa3e 100644 --- a/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php +++ b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Tests\Feature\Artist; use App\Models\Artist; -use App\Models\ArtistContact; use App\Models\ArtistEngagement; use App\Models\Event; use App\Models\Genre; @@ -22,9 +21,31 @@ final class ArtistTimetableDevSeederTest extends TestCase { use RefreshDatabase; - public function test_seeder_produces_expected_fixture_counts(): void + public function test_organisation_pool_creates_125_artists_and_8_genres(): void { $org = Organisation::factory()->create(); + + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + $this->assertSame(8, Genre::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertSame(125, Artist::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertCount(8, $pool['genres']); + $this->assertCount(125, $pool['artists']); + + // Every artist belongs to the seeded organisation + $this->assertSame( + 125, + Artist::withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $org->id) + ->count(), + ); + } + + public function test_seed_for_festival_produces_stages_engagements_and_unscheduled(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + /** @var Event $festival */ $festival = Event::factory()->for($org)->festival()->create([ 'start_date' => '2026-07-10', @@ -43,18 +64,98 @@ final class ArtistTimetableDevSeederTest extends TestCase 'end_date' => '2026-07-12', ]); - ArtistTimetableDevSeeder::seedForFestival($org, $festival, [$vrijdag, $zaterdag, $zondag]); + $result = ArtistTimetableDevSeeder::seedForFestival( + $org, + $festival, + [$vrijdag, $zaterdag, $zondag], + $pool, + ); - $this->assertSame(4, Genre::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(4, Stage::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(12, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(6, Artist::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(6, ArtistContact::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(12, ArtistEngagement::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame(13, Performance::withoutGlobalScope(OrganisationScope::class)->count()); - $this->assertSame( + // 5 stages, each linked to all 3 sub-events via stage_days + $this->assertSame(5, Stage::withoutGlobalScope(OrganisationScope::class)->count()); + $this->assertSame(15, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); + + // Engagement / performance counts (deterministic from the loop structure) + $this->assertSame(45, $result['engagements']); + $this->assertSame(12, $result['unscheduled']); + $this->assertGreaterThanOrEqual(35, $result['performances']); + + // At least one parked performance for the wachtrij UI + $this->assertGreaterThanOrEqual( 1, - Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count() + Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count(), + ); + + // Unscheduled engagements really have no performances + $unscheduledQuery = ArtistEngagement::withoutGlobalScope(OrganisationScope::class) + ->doesntHave('performances'); + $this->assertSame(12, $unscheduledQuery->count()); + } + + public function test_seed_for_flat_event_creates_stages_and_mixed_engagements(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + /** @var Event $event */ + $event = Event::factory()->for($org)->create([ + 'event_type' => 'event', + 'start_date' => '2026-04-27', + 'end_date' => '2026-04-27', + ]); + + $result = ArtistTimetableDevSeeder::seedForFlatEvent($org, $event, $pool, [ + 'scheduled' => 6, + 'unscheduled' => 3, + 'pool_offset' => 0, + ]); + + $this->assertSame(2, $result['stages']); + $this->assertSame(9, $result['engagements']); + $this->assertSame(3, $result['unscheduled']); + $this->assertSame(6, $result['performances']); + + // Stage days for the flat event (1 day × 2 stages = 2 rows) + $this->assertSame( + 2, + StageDay::withoutGlobalScope(OrganisationScope::class) + ->where('event_id', $event->id) + ->count(), ); } + + public function test_seed_for_series_creates_stages_with_stage_days_per_sub_event(): void + { + $org = Organisation::factory()->create(); + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($org, 125); + + /** @var Event $parent */ + $parent = Event::factory()->for($org)->create([ + 'event_type' => 'series', + 'start_date' => '2026-12-05', + 'end_date' => '2027-01-25', + ]); + $subs = []; + foreach (['2026-12-06', '2026-12-20', '2027-01-04', '2027-01-25'] as $date) { + $subs[] = Event::factory()->for($org)->subEvent($parent)->create([ + 'start_date' => $date, + 'end_date' => $date, + ]); + } + + $result = ArtistTimetableDevSeeder::seedForSeries($org, $parent, $subs, $pool, [ + 'per_week_scheduled' => 2, + 'per_week_unscheduled' => 1, + 'pool_offset' => 0, + ]); + + // 2 stages × 4 sub-events = 8 stage_days + $this->assertSame(2, $result['stages']); + $this->assertSame(8, StageDay::withoutGlobalScope(OrganisationScope::class)->count()); + + // 4 weeks × (2 scheduled + 1 unscheduled) = 12 engagements + $this->assertSame(12, $result['engagements']); + $this->assertSame(4, $result['unscheduled']); + $this->assertSame(8, $result['performances']); + } } diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 1b865bc5..74de8c1d 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -12,6 +12,7 @@ declare module 'vue' { AddEditPermissionDialog: typeof import('./src/components/dialogs/AddEditPermissionDialog.vue')['default'] AddEditRoleDialog: typeof import('./src/components/dialogs/AddEditRoleDialog.vue')['default'] AddMemberAsPersonDialog: typeof import('./src/components/persons/AddMemberAsPersonDialog.vue')['default'] + AddPerformanceDialog: typeof import('./src/components/timetable/AddPerformanceDialog.vue')['default'] AddPersonToCrowdListDialog: typeof import('./src/components/crowd-lists/AddPersonToCrowdListDialog.vue')['default'] AppAutocomplete: typeof import('./src/@core/components/app-form-elements/AppAutocomplete.vue')['default'] AppBarSearch: typeof import('./src/@core/components/AppBarSearch.vue')['default'] @@ -67,6 +68,7 @@ declare module 'vue' { EmailBrandingTab: typeof import('./src/components/organisation/EmailBrandingTab.vue')['default'] EmailLogTab: typeof import('./src/components/organisation/EmailLogTab.vue')['default'] EmailTemplatesTab: typeof import('./src/components/organisation/EmailTemplatesTab.vue')['default'] + EmptyDayState: typeof import('./src/components/timetable/EmptyDayState.vue')['default'] EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default'] ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default'] EventCard: typeof import('./src/components/portal/EventCard.vue')['default'] @@ -95,6 +97,7 @@ declare module 'vue' { FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default'] FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default'] FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default'] + GridBg: typeof import('./src/components/timetable/GridBg.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default'] ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default'] @@ -104,6 +107,7 @@ declare module 'vue' { InformatieTab: typeof import('./src/components/portal/event/InformatieTab.vue')['default'] InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] + LineupMatrix: typeof import('./src/components/timetable/LineupMatrix.vue')['default'] MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default'] MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default'] MfaEmailSetupDialog: typeof import('./src/components/settings/MfaEmailSetupDialog.vue')['default'] @@ -115,6 +119,8 @@ declare module 'vue' { OverzichtTab: typeof import('./src/components/portal/event/OverzichtTab.vue')['default'] PasswordRequirements: typeof import('./src/components/auth/PasswordRequirements.vue')['default'] PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default'] + PerformanceBlock: typeof import('./src/components/timetable/PerformanceBlock.vue')['default'] + PerformancePopover: typeof import('./src/components/timetable/PerformancePopover.vue')['default'] PersonDetailPanel: typeof import('./src/components/persons/PersonDetailPanel.vue')['default'] PersonTagsTab: typeof import('./src/components/organisation/PersonTagsTab.vue')['default'] ProductDescriptionEditor: typeof import('./src/@core/components/ProductDescriptionEditor.vue')['default'] @@ -138,14 +144,20 @@ declare module 'vue' { ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default'] ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default'] Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default'] + StageEditor: typeof import('./src/components/timetable/StageEditor.vue')['default'] + StageHeaderCell: typeof import('./src/components/timetable/StageHeaderCell.vue')['default'] + StageRow: typeof import('./src/components/timetable/StageRow.vue')['default'] StatusCard: typeof import('./src/components/portal/StatusCard.vue')['default'] SubmitterDetails: typeof import('./src/components/shared/public-form/SubmitterDetails.vue')['default'] TablePagination: typeof import('./src/@core/components/TablePagination.vue')['default'] TemplatePickerDialog: typeof import('./src/components/event/TemplatePickerDialog.vue')['default'] ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default'] + TimeAxis: typeof import('./src/components/timetable/TimeAxis.vue')['default'] TiptapEditor: typeof import('./src/@core/components/TiptapEditor.vue')['default'] TwoFactorAuthDialog: typeof import('./src/components/dialogs/TwoFactorAuthDialog.vue')['default'] UserAvatarMenu: typeof import('./src/components/portal/UserAvatarMenu.vue')['default'] UserInfoEditDialog: typeof import('./src/components/dialogs/UserInfoEditDialog.vue')['default'] + Wachtrij: typeof import('./src/components/timetable/Wachtrij.vue')['default'] + WachtrijCard: typeof import('./src/components/timetable/WachtrijCard.vue')['default'] } } diff --git a/apps/app/package.json b/apps/app/package.json index 2786095c..dcef67f4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -30,7 +30,6 @@ "@tiptap/pm": "^2.27.1", "@tiptap/starter-kit": "^2.27.1", "@tiptap/vue-3": "^2.27.1", - "@vee-validate/zod": "^4.15.1", "@vueuse/core": "10.11.1", "@vueuse/math": "10.11.1", "apexcharts": "3.54.1", @@ -50,7 +49,6 @@ "shepherd.js": "13.0.3", "ufo": "1.6.1", "unplugin-vue-define-options": "1.5.5", - "vee-validate": "^4.15.1", "vue": "3.5.22", "vue-chartjs": "5.3.2", "vue-flatpickr-component": "11.0.5", @@ -83,6 +81,7 @@ "@iconify/utils": "2.3.0", "@iconify/vue": "4.1.2", "@intlify/unplugin-vue-i18n": "11.0.1", + "@pinia/testing": "^1.0.3", "@stylistic/eslint-plugin-js": "0.0.4", "@stylistic/eslint-plugin-ts": "0.0.4", "@stylistic/stylelint-config": "1.0.1", @@ -103,6 +102,7 @@ "@vitejs/plugin-vue": "6.0.1", "@vitejs/plugin-vue-jsx": "5.1.1", "@vue/test-utils": "^2.4.9", + "axe-core": "^4.11.4", "baseline-browser-mapping": "^2.10.16", "eslint": "8.57.1", "eslint-config-airbnb-base": "15.0.0", @@ -129,6 +129,7 @@ "eslint-plugin-vue": "9.33.0", "eslint-plugin-yml": "1.19.0", "happy-dom": "^20.9.0", + "jsdom": "^29.1.1", "msw": "2.6.8", "postcss-html": "1.8.0", "postcss-scss": "4.0.9", @@ -150,6 +151,7 @@ "vite-plugin-vuetify": "2.1.2", "vite-svg-loader": "5.1.0", "vitest": "^4.1.5", + "vitest-axe": "^0.1.0", "vue-eslint-parser": "9.4.3", "vue-shepherd": "3.0.0", "vue-tsc": "3.1.2" diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 89377565..1761002e 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -51,9 +51,6 @@ importers: '@tiptap/vue-3': specifier: ^2.27.1 version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(vue@3.5.22(typescript@5.9.3)) - '@vee-validate/zod': - specifier: ^4.15.1 - version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76) '@vueuse/core': specifier: 10.11.1 version: 10.11.1(vue@3.5.22(typescript@5.9.3)) @@ -111,9 +108,6 @@ importers: unplugin-vue-define-options: specifier: 1.5.5 version: 1.5.5(vue@3.5.22(typescript@5.9.3)) - vee-validate: - specifier: ^4.15.1 - version: 4.15.1(vue@3.5.22(typescript@5.9.3)) vue: specifier: 3.5.22 version: 3.5.22(typescript@5.9.3) @@ -205,6 +199,9 @@ importers: '@intlify/unplugin-vue-i18n': specifier: 11.0.1 version: 11.0.1(@vue/compiler-dom@3.5.22)(eslint@8.57.1)(rollup@4.52.5)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + '@pinia/testing': + specifier: ^1.0.3 + version: 1.0.3(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))) '@stylistic/eslint-plugin-js': specifier: 0.0.4 version: 0.0.4 @@ -265,6 +262,9 @@ importers: '@vue/test-utils': specifier: ^2.4.9 version: 2.4.9(@vue/compiler-dom@3.5.22)(@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) + axe-core: + specifier: ^4.11.4 + version: 4.11.4 baseline-browser-mapping: specifier: ^2.10.16 version: 2.10.16 @@ -343,6 +343,9 @@ importers: happy-dom: specifier: ^20.9.0 version: 20.9.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 msw: specifier: 2.6.8 version: 2.6.8(@types/node@24.9.2)(typescript@5.9.3) @@ -405,7 +408,10 @@ importers: version: 5.1.0(vue@3.5.22(typescript@5.9.3)) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(jsdom@29.1.1)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)) + vitest-axe: + specifier: ^0.1.0 + version: 0.1.0(vitest@4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(jsdom@29.1.1)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))) vue-eslint-parser: specifier: 9.4.3 version: 9.4.3(eslint@8.57.1) @@ -446,6 +452,21 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -594,6 +615,10 @@ packages: resolution: {integrity: sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==} engines: {node: '>=18.18'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -612,16 +637,52 @@ packages: '@casl/ability': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0 vue: ^3.0.0 + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@2.7.1': resolution: {integrity: sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==} engines: {node: ^14 || ^16 || >=18} peerDependencies: '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@2.4.1': resolution: {integrity: sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==} engines: {node: ^14 || ^16 || >=18} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@csstools/media-query-list-parser@2.1.13': resolution: {integrity: sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==} engines: {node: ^14 || ^16 || >=18} @@ -825,6 +886,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1070,6 +1140,11 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@pinia/testing@1.0.3': + resolution: {integrity: sha512-g+qR49GNdI1Z8rZxKrQC3GN+LfnGTNf5Kk8Nz5Cz6mIGva5WRS+ffPXQfzhA0nu6TveWzPNYTjGl4nJqd3Cu9Q==} + peerDependencies: + pinia: '>=3.0.4' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1931,11 +2006,6 @@ packages: cpu: [x64] os: [win32] - '@vee-validate/zod@4.15.1': - resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} - peerDependencies: - zod: ^3.24.0 - '@vitejs/plugin-vue-jsx@5.1.1': resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2223,6 +2293,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} @@ -2237,6 +2311,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2317,6 +2394,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2473,6 +2554,10 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -2492,6 +2577,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2525,6 +2614,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-equal@2.2.3: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} @@ -2654,6 +2746,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3291,6 +3387,10 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -3471,6 +3571,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3559,6 +3662,15 @@ packages: resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==} engines: {node: '>=12.0.0'} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -3652,6 +3764,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} @@ -3667,6 +3782,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3716,6 +3835,9 @@ packages: mdn-data@2.25.0: resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -4001,6 +4123,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4281,6 +4406,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -4408,6 +4537,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -4730,6 +4863,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4781,6 +4917,13 @@ packages: tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4793,6 +4936,14 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4898,6 +5049,10 @@ packages: resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} engines: {node: '>=18.17'} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5001,11 +5156,6 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - vee-validate@4.15.1: - resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} - peerDependencies: - vue: ^3.4.26 - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -5102,6 +5252,11 @@ packages: yaml: optional: true + vitest-axe@0.1.0: + resolution: {integrity: sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==} + peerDependencies: + vitest: '>=0.16.0' + vitest@4.1.5: resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5248,9 +5403,17 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webfontloader@1.6.28: resolution: {integrity: sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5267,6 +5430,14 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5346,6 +5517,13 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -5505,6 +5683,26 @@ snapshots: '@antfu/utils@8.1.1': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5712,6 +5910,10 @@ snapshots: - eslint-import-resolver-webpack - supports-color + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -5734,12 +5936,36 @@ snapshots: '@casl/ability': 6.7.3 vue: 3.5.22(typescript@5.9.3) + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1)': dependencies: '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-tokenizer@2.4.1': {} + '@csstools/css-tokenizer@4.0.0': {} + '@csstools/media-query-list-parser@2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': dependencies: '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) @@ -5874,6 +6100,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6146,6 +6374,10 @@ snapshots: '@open-draft/until@2.1.0': {} + '@pinia/testing@1.0.3(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))': + dependencies: + pinia: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) + '@pkgjs/parseargs@0.11.0': optional: true @@ -7013,14 +7245,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vee-validate/zod@4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)': - dependencies: - type-fest: 4.41.0 - vee-validate: 4.15.1(vue@3.5.22(typescript@5.9.3)) - zod: 3.25.76 - transitivePeerDependencies: - - vue - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 @@ -7424,6 +7648,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.11.4: {} + axios@1.15.0: dependencies: follow-redirects: 1.15.11 @@ -7438,6 +7664,10 @@ snapshots: baseline-browser-mapping@2.10.16: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} birpc@2.6.1: {} @@ -7516,6 +7746,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@1.1.4: {} @@ -7680,6 +7912,11 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} csscolorparser@1.0.3: {} @@ -7692,6 +7929,13 @@ snapshots: csstype@3.1.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7720,6 +7964,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + deep-equal@2.2.3: dependencies: array-buffer-byte-length: 1.0.2 @@ -7854,6 +8100,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -8805,6 +9053,12 @@ snapshots: hosted-git-info@2.8.9: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-tags@3.3.1: {} html-void-elements@3.0.0: {} @@ -8974,6 +9228,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9055,6 +9311,32 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@0.5.0: {} jsesc@3.1.0: {} @@ -9132,6 +9414,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.clonedeep@4.5.0: {} lodash.merge@4.6.2: {} @@ -9142,6 +9426,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9233,6 +9519,8 @@ snapshots: mdn-data@2.25.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} meow@13.2.0: {} @@ -9546,6 +9834,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -9835,6 +10127,11 @@ snapshots: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9992,6 +10289,10 @@ snapshots: immutable: 4.3.7 source-map-js: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -10376,6 +10677,8 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 + symbol-tree@3.2.4: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9 @@ -10427,6 +10730,12 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -10440,6 +10749,14 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} ts-api-utils@1.4.3(typescript@5.9.3): @@ -10544,6 +10861,8 @@ snapshots: undici@6.22.0: {} + undici@7.25.0: {} + unicorn-magic@0.3.0: {} unimport@3.14.6(rollup@4.52.5): @@ -10722,12 +11041,6 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vee-validate@4.15.1(vue@3.5.22(typescript@5.9.3)): - dependencies: - '@vue/devtools-api': 7.7.7 - type-fest: 4.41.0 - vue: 3.5.22(typescript@5.9.3) - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -10830,7 +11143,17 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitest@4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)): + vitest-axe@0.1.0(vitest@4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(jsdom@29.1.1)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))): + dependencies: + aria-query: 5.1.3 + axe-core: 4.11.4 + chalk: 5.6.2 + dom-accessibility-api: 0.5.16 + lodash-es: 4.18.1 + redent: 3.0.0 + vitest: 4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(jsdom@29.1.1)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)) + + vitest@4.1.5(@types/node@24.9.2)(happy-dom@20.9.0)(jsdom@29.1.1)(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(msw@2.6.8(@types/node@24.9.2)(typescript@5.9.3))(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -10855,6 +11178,7 @@ snapshots: optionalDependencies: '@types/node': 24.9.2 happy-dom: 20.9.0 + jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -10954,8 +11278,14 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webfontloader@1.6.28: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -10966,6 +11296,16 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -11059,6 +11399,10 @@ snapshots: xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@4.0.3: {} y18n@5.0.8: {} diff --git a/apps/app/src/assets/styles/styles.scss b/apps/app/src/assets/styles/styles.scss index 3118a334..8e6dbc76 100644 --- a/apps/app/src/assets/styles/styles.scss +++ b/apps/app/src/assets/styles/styles.scss @@ -1 +1,5 @@ // Write your overrides + +// RFC-TIMETABLE v0.2 D21 — status palette + geometry custom properties. +// Plain CSS so jsdom/vitest can also load it via `import '@/styles/tokens/_timetable.css'`. +@import "@/styles/tokens/_timetable.css"; diff --git a/apps/app/src/components/events/EventTabsNav.vue b/apps/app/src/components/events/EventTabsNav.vue index 42978a76..fee71cc3 100644 --- a/apps/app/src/components/events/EventTabsNav.vue +++ b/apps/app/src/components/events/EventTabsNav.vue @@ -156,6 +156,7 @@ const baseTabs = [ { label: 'Tijdsloten', icon: 'tabler-clock', route: 'events-id-time-slots' }, { label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' }, { label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' }, + { label: 'Programma', icon: 'tabler-calendar-time', route: 'events-id-timetable' }, { label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' }, { label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' }, ] @@ -174,23 +175,27 @@ const tabs = computed(() => { if (!event.value?.is_festival) return baseTabs - // Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen + // Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Programma | Briefings | Instellingen const festivalTab = { label: programmaonderdelenLabel.value, icon: 'tabler-calendar-event', route: 'events-id-programmaonderdelen', } + // Look up by route name so insertions/reorders in baseTabs don't break this. + const findTab = (routeName: string) => baseTabs.find(t => t.route === routeName)! + return [ - baseTabs[0], // Overzicht + findTab('events-id'), festivalTab, - baseTabs[3], // Tijdsloten - baseTabs[4], // Secties & Shifts - baseTabs[1], // Personen - baseTabs[2], // Publiekslijsten - baseTabs[5], // Artiesten - baseTabs[6], // Briefings - baseTabs[7], // Instellingen + findTab('events-id-time-slots'), + findTab('events-id-sections'), + findTab('events-id-persons'), + findTab('events-id-crowd-lists'), + findTab('events-id-artists'), + findTab('events-id-timetable'), + findTab('events-id-briefings'), + findTab('events-id-settings'), ] }) diff --git a/apps/app/src/components/timetable/AddPerformanceDialog.vue b/apps/app/src/components/timetable/AddPerformanceDialog.vue new file mode 100644 index 00000000..551b6dba --- /dev/null +++ b/apps/app/src/components/timetable/AddPerformanceDialog.vue @@ -0,0 +1,204 @@ + + + diff --git a/apps/app/src/components/timetable/EmptyDayState.vue b/apps/app/src/components/timetable/EmptyDayState.vue new file mode 100644 index 00000000..5a357ee5 --- /dev/null +++ b/apps/app/src/components/timetable/EmptyDayState.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/app/src/components/timetable/GridBg.vue b/apps/app/src/components/timetable/GridBg.vue new file mode 100644 index 00000000..accb313a --- /dev/null +++ b/apps/app/src/components/timetable/GridBg.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/app/src/components/timetable/LineupMatrix.vue b/apps/app/src/components/timetable/LineupMatrix.vue new file mode 100644 index 00000000..cde957df --- /dev/null +++ b/apps/app/src/components/timetable/LineupMatrix.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/apps/app/src/components/timetable/PerformanceBlock.vue b/apps/app/src/components/timetable/PerformanceBlock.vue new file mode 100644 index 00000000..76270434 --- /dev/null +++ b/apps/app/src/components/timetable/PerformanceBlock.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/apps/app/src/components/timetable/PerformancePopover.vue b/apps/app/src/components/timetable/PerformancePopover.vue new file mode 100644 index 00000000..018141d5 --- /dev/null +++ b/apps/app/src/components/timetable/PerformancePopover.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/apps/app/src/components/timetable/StageEditor.vue b/apps/app/src/components/timetable/StageEditor.vue new file mode 100644 index 00000000..9721dfb7 --- /dev/null +++ b/apps/app/src/components/timetable/StageEditor.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/apps/app/src/components/timetable/StageHeaderCell.vue b/apps/app/src/components/timetable/StageHeaderCell.vue new file mode 100644 index 00000000..1b9f45a8 --- /dev/null +++ b/apps/app/src/components/timetable/StageHeaderCell.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/apps/app/src/components/timetable/StageRow.vue b/apps/app/src/components/timetable/StageRow.vue new file mode 100644 index 00000000..d87faf9f --- /dev/null +++ b/apps/app/src/components/timetable/StageRow.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/apps/app/src/components/timetable/TimeAxis.vue b/apps/app/src/components/timetable/TimeAxis.vue new file mode 100644 index 00000000..99b39708 --- /dev/null +++ b/apps/app/src/components/timetable/TimeAxis.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/app/src/components/timetable/Wachtrij.vue b/apps/app/src/components/timetable/Wachtrij.vue new file mode 100644 index 00000000..71a5ec27 --- /dev/null +++ b/apps/app/src/components/timetable/Wachtrij.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/apps/app/src/components/timetable/WachtrijCard.vue b/apps/app/src/components/timetable/WachtrijCard.vue new file mode 100644 index 00000000..ee773e98 --- /dev/null +++ b/apps/app/src/components/timetable/WachtrijCard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/apps/app/src/composables/api/useTimetable.ts b/apps/app/src/composables/api/useTimetable.ts new file mode 100644 index 00000000..14d4a86f --- /dev/null +++ b/apps/app/src/composables/api/useTimetable.ts @@ -0,0 +1,171 @@ +import { useQuery } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { computed } from 'vue' +import { z } from 'zod' +import { apiClient } from '@/lib/axios' +import { + artistEngagementSchema, + performanceSchema, + stageSchema, +} from '@/schemas/timetable' +import type { + ArtistEngagement, + Performance, + Stage, +} from '@/types/timetable' + +const stageArraySchema = z.array(stageSchema) +const performanceArraySchema = z.array(performanceSchema) + +/** + * RFC v0.2 §6.2 — read-side composables for the timetable canvas. + * Server is authoritative for `lane_resolved` (D19); the client only + * reads & renders. Mutations (move, park, CRUD) live in + * useTimetableMutations.ts. + */ + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +interface ResourceCollection { + data: T[] +} + +interface ResourceObject { + data: T +} + +/** + * Fetch stages for an event (ordered by sort_order, with stage_days). + * Query key: ['timetable', 'stages', eventId]. + */ +export function useStages(orgId: Ref, eventId: Ref) { + return useQuery({ + queryKey: ['timetable', 'stages', eventId], + queryFn: async (): Promise => { + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/events/${eventId.value}/stages`, + ) + + return stageArraySchema.parse(data.data) + }, + enabled: () => !!orgId.value && !!eventId.value, + staleTime: 30_000, + refetchOnWindowFocus: true, + }) +} + +/** + * Fetch performances for a sub-event (or flat event) on a specific day. + * `dayId` is the sub-event id; passing the same id as `eventId` works + * for flat events thanks to the backend's day-filter behaviour. + * + * Query key: ['timetable', 'performances', eventId, dayId]. + */ +export function usePerformances( + orgId: Ref, + eventId: Ref, + dayId: Ref, +) { + return useQuery({ + queryKey: ['timetable', 'performances', eventId, dayId], + queryFn: async (): Promise => { + const params = dayId.value ? `?day=${encodeURIComponent(dayId.value)}` : '' + + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/events/${eventId.value}/performances${params}`, + ) + + return performanceArraySchema.parse(data.data) + }, + enabled: () => !!orgId.value && !!eventId.value && !!dayId.value, + staleTime: 30_000, + refetchOnWindowFocus: true, + }) +} + +/** + * Fetch performances parked in the wachtrij (stage_id IS NULL). + * Backend reads `?stage_id=null` literally per StageController index(). + * + * Query key: ['timetable', 'wachtrij', eventId]. + */ +export function useWachtrij(orgId: Ref, eventId: Ref) { + return useQuery({ + queryKey: ['timetable', 'wachtrij', eventId], + queryFn: async (): Promise => { + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/events/${eventId.value}/performances?stage_id=null`, + ) + + return performanceArraySchema.parse(data.data) + }, + enabled: () => !!orgId.value && !!eventId.value, + staleTime: 30_000, + refetchOnWindowFocus: true, + }) +} + +/** + * Fetch a single engagement (full resource incl. computed Buma + VAT). + * Used by PerformancePopover to surface deal info + advancing aggregate. + * + * Query key: ['timetable', 'engagement', engagementId]. + */ +export function useEngagement(orgId: Ref, engagementId: Ref) { + return useQuery({ + queryKey: ['timetable', 'engagement', engagementId], + queryFn: async (): Promise => { + const { data } = await apiClient.get>( + `/organisations/${orgId.value}/engagements/${engagementId.value}`, + ) + + return artistEngagementSchema.parse(data.data) + }, + enabled: () => !!orgId.value && !!engagementId.value, + staleTime: 30_000, + }) +} + +/** + * Aggregate composable that combines stages + day performances + wachtrij + * into a single derived shape, useful for the page entry. + */ +export function useTimetable( + orgId: Ref, + eventId: Ref, + dayId: Ref, +) { + const stagesQ = useStages(orgId, eventId) + const performancesQ = usePerformances(orgId, eventId, dayId) + const wachtrijQ = useWachtrij(orgId, eventId) + + const isLoading = computed(() => stagesQ.isLoading.value || performancesQ.isLoading.value || wachtrijQ.isLoading.value) + const isError = computed(() => stagesQ.isError.value || performancesQ.isError.value || wachtrijQ.isError.value) + const error = computed(() => stagesQ.error.value ?? performancesQ.error.value ?? wachtrijQ.error.value) + + function refetch(): void { + void stagesQ.refetch() + void performancesQ.refetch() + void wachtrijQ.refetch() + } + + return { + stages: stagesQ.data, + performances: performancesQ.data, + wachtrij: wachtrijQ.data, + isLoading, + isError, + error, + refetch, + } +} + +/** + * Re-export the internal envelope types so the mutations file (and tests) + * can mock the same shape. + */ +export type { ApiResponse, ResourceCollection, ResourceObject } diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts new file mode 100644 index 00000000..a72369a3 --- /dev/null +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -0,0 +1,375 @@ +import { useMutation, useQueryClient } from '@tanstack/vue-query' +import type { AxiosError } from 'axios' +import type { Ref } from 'vue' +import type { ApiResponse, ResourceCollection } from './useTimetable' +import { apiClient } from '@/lib/axios' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' +import { + moveTimetableConflictSchema, + moveTimetableSuccessSchema, + performanceSchema, + stageSchema, +} from '@/schemas/timetable' +import { useNotificationStore } from '@/stores/useNotificationStore' +import type { + CreatePerformancePayload, + CreateStagePayload, + MoveTimetableConflict, + MoveTimetablePayload, + MoveTimetableSuccess, + Performance, + ReorderStagesPayload, + ReplaceStageDaysPayload, + ReplaceStageDaysResponse, + Stage, + UpdatePerformancePayload, + UpdateStagePayload, +} from '@/types/timetable' + +/** + * RFC v0.2 mutations for the timetable canvas. + * + * D14 — POST /timetable/move returns 200 on success or 409 with the + * VersionMismatch payload; client surfaces a toast and refetches. + * D18 — Cascade-bump runs in a single server transaction; the response + * carries `{moved, cascaded[]}` so the canvas can pulse the bumped + * siblings (visual-only, no extra mutation). + * + * Idempotency-Key is regenerated PER LOGICAL ACTION. A re-drag emits a + * fresh key; an axios retry of the same drag reuses the key (we hand it + * in and let interceptors retry transparently). + */ + +interface UseTimetableMutationsArgs { + orgId: Ref + eventId: Ref + + /** Active sub-event id; `usePerformances` cache invalidation needs it. */ + dayId: Ref +} + +export interface VersionMismatchError { + status: 409 + conflict: MoveTimetableConflict +} + +export type MoveErrorPayload = + | VersionMismatchError + | { status: number; message: string } + +function isVersionMismatch(err: unknown): err is { response: { status: 409; data: { errors: MoveTimetableConflict } } } { + const e = err as AxiosError<{ errors?: MoveTimetableConflict }> + + return e?.response?.status === 409 && e.response.data?.errors?.conflict === 'version_mismatch' +} + +export function useTimetableMutations(args: UseTimetableMutationsArgs) { + const queryClient = useQueryClient() + const notification = useNotificationStore() + const { orgId, eventId, dayId } = args + + const performancesKey = () => ['timetable', 'performances', eventId, dayId] as const + const wachtrijKey = () => ['timetable', 'wachtrij', eventId] as const + + function invalidate(): void { + void queryClient.invalidateQueries({ queryKey: ['timetable', 'performances', eventId] }) + void queryClient.invalidateQueries({ queryKey: ['timetable', 'wachtrij', eventId] }) + } + + function mergePerformance(updated: Performance): void { + // Patch the day-cache and the wachtrij-cache so optimistic / settled + // values land without a refetch. + const isParked = updated.stage_id === null + const dayCache = queryClient.getQueryData(performancesKey() as unknown as readonly unknown[]) + const wachtrijCache = queryClient.getQueryData(wachtrijKey() as unknown as readonly unknown[]) + + if (dayCache) { + const next = dayCache.filter(p => p.id !== updated.id) + if (!isParked) + next.push(updated) + queryClient.setQueryData(performancesKey() as unknown as readonly unknown[], next) + } + if (wachtrijCache) { + const next = wachtrijCache.filter(p => p.id !== updated.id) + if (isParked) + next.push(updated) + queryClient.setQueryData(wachtrijKey() as unknown as readonly unknown[], next) + } + } + + function applyCascade(cascaded: Performance[]): void { + if (cascaded.length === 0) + return + const dayCache = queryClient.getQueryData(performancesKey() as unknown as readonly unknown[]) + if (!dayCache) + return + const byId = new Map(cascaded.map(p => [p.id, p])) + const next = dayCache.map(p => byId.get(p.id) ?? p) + + queryClient.setQueryData(performancesKey() as unknown as readonly unknown[], next) + } + + // ─── POST /timetable/move (D18) ────────────────────────────────────── + + interface MoveContext { + snapshot: Performance | undefined + snapshotWachtrij: Performance | undefined + } + + const move = useMutation< + MoveTimetableSuccess, + MoveErrorPayload, + { payload: MoveTimetablePayload; idempotencyKey: string; optimistic?: Performance }, + MoveContext + >({ + mutationFn: async ({ payload, idempotencyKey }) => { + let response + try { + response = await apiClient.post>( + `/organisations/${orgId.value}/events/${eventId.value}/timetable/move`, + payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + } + catch (err) { + if (isVersionMismatch(err)) { + // Backend canon: api/app/Http/Controllers/Api/V1/Artist/TimetableMoveController.php:64 + // Parsing rejects drift in the conflict shape — schema mismatch + // surfaces as a thrown ZodError that GlitchTip / the global axios + // handler can fingerprint. + const conflict = moveTimetableConflictSchema.parse(err.response.data.errors) + const mismatch = new Error('version_mismatch') as Error & VersionMismatchError + + mismatch.status = 409 + mismatch.conflict = conflict + throw mismatch + } + const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string } + + wrapped.status = (err as AxiosError).response?.status ?? 0 + throw wrapped + } + + // Outside the catch so a Zod parse failure on a 200 response surfaces + // as a true error (not silently re-routed through the 409 branch). + return moveTimetableSuccessSchema.parse(response.data.data) + }, + onMutate: async ({ optimistic }) => { + await queryClient.cancelQueries({ queryKey: ['timetable', 'performances', eventId] }) + await queryClient.cancelQueries({ queryKey: ['timetable', 'wachtrij', eventId] }) + + const dayCache = queryClient.getQueryData(performancesKey() as unknown as readonly unknown[]) + const wachtrijCache = queryClient.getQueryData(wachtrijKey() as unknown as readonly unknown[]) + + const ctx: MoveContext = { + snapshot: dayCache?.find(p => optimistic && p.id === optimistic.id), + snapshotWachtrij: wachtrijCache?.find(p => optimistic && p.id === optimistic.id), + } + + if (optimistic) + mergePerformance(optimistic) + + return ctx + }, + onSuccess: result => { + mergePerformance(result.moved) + applyCascade(result.cascaded) + }, + onError: (err, _vars, ctx) => { + // Restore cached blocks from snapshot so the canvas snaps back. + if (ctx?.snapshot) + mergePerformance(ctx.snapshot) + if (ctx?.snapshotWachtrij) + mergePerformance(ctx.snapshotWachtrij) + invalidate() + + // RFC D14 — version mismatch toast. Generic axios errors stay quiet + // here; they're already surfaced by the global response handler in + // lib/axios/factory.ts. + if ((err as { status?: number } | null)?.status === 409) + notification.show('Iemand anders heeft dit zojuist aangepast — venster ververst.', 'error') + }, + }) + + // ─── POST /performances ────────────────────────────────────────────── + + const create = useMutation({ + mutationFn: async (payload: CreatePerformancePayload): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/events/${eventId.value}/performances`, + payload, + { headers: { 'Idempotency-Key': generateIdempotencyKey() } }, + ) + + return performanceSchema.parse(data.data) + }, + onSuccess: () => invalidate(), + }) + + // ─── PATCH /performances/{id} (notes only — D18 owns placement) ────── + + const updateNotes = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: UpdatePerformancePayload }): Promise => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/events/${eventId.value}/performances/${id}`, + payload, + ) + + return performanceSchema.parse(data.data) + }, + onSuccess: updated => mergePerformance(updated), + }) + + // ─── DELETE /performances/{id} ─────────────────────────────────────── + + const remove = useMutation({ + mutationFn: async (id: string): Promise => { + await apiClient.delete(`/organisations/${orgId.value}/events/${eventId.value}/performances/${id}`) + }, + onSuccess: () => invalidate(), + }) + + // ─── Park / Unpark via the move endpoint ───────────────────────────── + + function park(perf: Performance, idempotencyKey: string) { + return move.mutateAsync({ + payload: { + performance_id: perf.id, + target_stage_id: null, + target_start_at: null, + target_end_at: null, + target_lane: null, + version: perf.version, + }, + idempotencyKey, + optimistic: { ...perf, stage_id: null, lane_resolved: 0 }, + }) + } + + function unpark(perf: Performance, target: { stageId: string; startAt: string; endAt: string; lane: number }, idempotencyKey: string) { + return move.mutateAsync({ + payload: { + performance_id: perf.id, + target_stage_id: target.stageId, + target_start_at: target.startAt, + target_end_at: target.endAt, + target_lane: target.lane, + version: perf.version, + }, + idempotencyKey, + optimistic: { + ...perf, + stage_id: target.stageId, + start_at: target.startAt, + end_at: target.endAt, + lane: target.lane, + lane_resolved: target.lane, + }, + }) + } + + // ─── Stage CRUD + reorder + day-replace ────────────────────────────── + + const stagesKey = () => ['timetable', 'stages', eventId] as const + function invalidateStages(): void { + void queryClient.invalidateQueries({ queryKey: ['timetable', 'stages', eventId] }) + } + + const createStage = useMutation({ + mutationFn: async (payload: CreateStagePayload): Promise => { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/events/${eventId.value}/stages`, + payload, + ) + + return stageSchema.parse(data.data) + }, + onSuccess: () => invalidateStages(), + }) + + const updateStage = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: UpdateStagePayload }): Promise => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/events/${eventId.value}/stages/${id}`, + payload, + ) + + return stageSchema.parse(data.data) + }, + onSuccess: () => invalidateStages(), + }) + + const deleteStage = useMutation({ + mutationFn: async (id: string): Promise<{ parked_performances: number }> => { + const { data } = await apiClient.delete<{ parked_performances: number }>( + `/organisations/${orgId.value}/events/${eventId.value}/stages/${id}`, + ) + + return data + }, + onSuccess: () => { + invalidateStages() + invalidate() + }, + }) + + const reorderStages = useMutation({ + mutationFn: async (payload: ReorderStagesPayload): Promise => { + const { data } = await apiClient.post>>( + `/organisations/${orgId.value}/events/${eventId.value}/stages/order`, + payload, + ) + + return data.data.data + }, + onMutate: async payload => { + await queryClient.cancelQueries({ queryKey: stagesKey() as unknown as readonly unknown[] }) + + const prev = queryClient.getQueryData(stagesKey() as unknown as readonly unknown[]) + if (prev) { + const byId = new Map(prev.map(s => [s.id, s])) + + const reordered = payload.stage_ids + .map(id => byId.get(id)) + .filter((s): s is Stage => !!s) + + queryClient.setQueryData(stagesKey() as unknown as readonly unknown[], reordered) + } + + return { prev } + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) + queryClient.setQueryData(stagesKey() as unknown as readonly unknown[], ctx.prev) + }, + }) + + const replaceStageDays = useMutation({ + mutationFn: async ({ stageId, payload }: { stageId: string; payload: ReplaceStageDaysPayload }): Promise => { + const { data } = await apiClient.put>( + `/organisations/${orgId.value}/events/${eventId.value}/stages/${stageId}/days`, + payload, + ) + + return data.data + }, + onSuccess: () => { + invalidateStages() + invalidate() + }, + }) + + return { + move, + create, + updateNotes, + remove, + park, + unpark, + createStage, + updateStage, + deleteStage, + reorderStages, + replaceStageDays, + } +} diff --git a/apps/app/src/composables/timetable/useActiveDay.ts b/apps/app/src/composables/timetable/useActiveDay.ts new file mode 100644 index 00000000..d5995fd4 --- /dev/null +++ b/apps/app/src/composables/timetable/useActiveDay.ts @@ -0,0 +1,71 @@ +import { computed, watch } from 'vue' +import type { Ref } from 'vue' + +/** + * `?day` query param ↔ active sub-event id binding for the timetable page. + * + * The URL is the source of truth. The store does NOT hold this value. + * + * Behaviour (RFC v0.2 §6.2 / Session 4 follow-up Step 5): + * - If `?day=X` is in `validIds` → activeDayId = X. + * - If `?day=X` is missing or invalid → activeDayId = first valid id, + * and the URL is silently rewritten + * via `replace({day: firstValidId})`. + * - If `validIds` is empty (event has → activeDayId = null. + * no sub-events / data still loading) + * + * Cross-org sub-event IDs are blocked transparently: `OrganisationScope` + * on the backend never returns them, so they fail `validIds.includes(...)` + * and fall back to the first valid id from the user's own organisation. + */ +export interface UseActiveDayDeps { + + /** Reactive read of `route.query.day`. Must coerce array → string outside. */ + queryDay: Ref + + /** Reactive list of sub-event IDs the user is allowed to see for this event. */ + validIds: Ref + + /** `router.replace` wrapper that updates ONLY the `day` query param. */ + replace: (dayId: string) => void +} + +export interface UseActiveDayReturn { + activeDayId: Ref + setActiveDay: (id: string | null) => void +} + +export function useActiveDay(deps: UseActiveDayDeps): UseActiveDayReturn { + const activeDayId = computed(() => { + const ids = deps.validIds.value + if (ids.length === 0) + return null + const q = deps.queryDay.value + if (q && ids.includes(q)) + return q + + return ids[0] + }) + + // Single corrective watcher — quietly rewrites the URL when the query param + // is missing or invalid. immediate:true so a mount with `?day=null` (or + // an invalid value) corrects the URL on first paint instead of waiting + // for the next user interaction. flush:'post' so it runs after Vue settles + // and after validIds has been recomputed from a fresh fetch. + watch([() => deps.queryDay.value, () => deps.validIds.value], ([q, ids]) => { + if (ids.length === 0) + return + if (q === null || !ids.includes(q)) + deps.replace(ids[0]) + }, { flush: 'post', immediate: true }) + + function setActiveDay(id: string | null): void { + if (id === null) + return + if (id === deps.queryDay.value) + return + deps.replace(id) + } + + return { activeDayId, setActiveDay } +} diff --git a/apps/app/src/composables/timetable/useDragOrClick.ts b/apps/app/src/composables/timetable/useDragOrClick.ts new file mode 100644 index 00000000..7f495fef --- /dev/null +++ b/apps/app/src/composables/timetable/useDragOrClick.ts @@ -0,0 +1,89 @@ +import { ref } from 'vue' +import { type PointerDragState, usePointerDrag } from './usePointerDrag' + +/** + * Threshold-based "drag-or-click" disambiguation. Listens to a single + * `pointerdown`; if the pointer travels < `thresholdPx` before release, + * fires `onClick`; otherwise enters drag mode and fires `onDragStart` + * once + `onDragMove` per pointermove + `onDragEnd` on release. + * + * Per RFC v0.2 D7 — replaces the prototype's three duplicated + * mousedown stacks (timetable.jsx:544-546, 632-634, 677-679) with one + * deterministic primitive + the click-suppression listener that catches + * the synthetic click after a drag-mouseup. + */ + +export interface UseDragOrClickOptions { + + /** Manhattan threshold in pixels. Default 4 (matches prototype audit §4.1). */ + thresholdPx?: number + onClick?: (event: PointerEvent) => void + onDragStart?: (state: PointerDragState) => void + onDragMove?: (state: PointerDragState) => void + onDragEnd?: (state: PointerDragState, cancelled: boolean) => void +} + +export function useDragOrClick(options: UseDragOrClickOptions) { + const threshold = options.thresholdPx ?? 4 + const dragMode = ref(false) + let pendingClickEvent: PointerEvent | null = null + + const drag = usePointerDrag({ + onStart: state => { + dragMode.value = false + pendingClickEvent = state.startEvent + }, + onMove: state => { + if (!dragMode.value && (Math.abs(state.deltaX) > threshold || Math.abs(state.deltaY) > threshold)) { + dragMode.value = true + pendingClickEvent = null + options.onDragStart?.(state) + } + else if (dragMode.value) { + options.onDragMove?.(state) + } + }, + onEnd: (state, cancelled) => { + if (dragMode.value) { + options.onDragEnd?.(state, cancelled) + installClickSuppressor() + } + else if (!cancelled && pendingClickEvent && options.onClick) { + options.onClick(pendingClickEvent) + } + pendingClickEvent = null + dragMode.value = false + }, + }) + + function begin(event: PointerEvent): void { + drag.begin(event) + } + + function cancel(): void { + drag.cancel() + } + + /** + * Browser fires a synthetic click after pointerup that completed a + * drag — suppress it once so a drag never opens the popover. + */ + function installClickSuppressor(): void { + const suppress = (event: MouseEvent): void => { + event.stopPropagation() + event.preventDefault() + window.removeEventListener('click', suppress, true) + } + + window.addEventListener('click', suppress, true) + setTimeout(() => window.removeEventListener('click', suppress, true), 0) + } + + return { + begin, + cancel, + isDragging: drag.isDragging, + isDragMode: dragMode, + state: drag.state, + } +} diff --git a/apps/app/src/composables/timetable/usePointerDrag.ts b/apps/app/src/composables/timetable/usePointerDrag.ts new file mode 100644 index 00000000..92e302d6 --- /dev/null +++ b/apps/app/src/composables/timetable/usePointerDrag.ts @@ -0,0 +1,148 @@ +import { type Ref, onBeforeUnmount, ref } from 'vue' + +/** + * Modern PointerEvents-based drag primitive that replaces legacy + * mousedown stacks. Captures the pointer on `pointerdown`, tracks + * deltas through `pointermove`, releases on `pointerup`/`pointercancel`. + * + * Pure mechanics — domain code (lane math, mutation calls) lives in + * the page entry / mutation composables. + */ + +export interface PointerDragState { + pointerId: number + startEvent: PointerEvent + startX: number + startY: number + currentX: number + currentY: number + deltaX: number + deltaY: number +} + +export interface UsePointerDragOptions { + + /** Optional cursor swap during drag. */ + cursor?: string + onStart?: (state: PointerDragState) => void + onMove?: (state: PointerDragState) => void + onEnd?: (state: PointerDragState, cancelled: boolean) => void +} + +export function usePointerDrag(options: UsePointerDragOptions = {}): { + isDragging: Ref + state: Ref + begin: (event: PointerEvent) => void + cancel: () => void +} { + const isDragging = ref(false) + const state = ref(null) + let activeTarget: Element | null = null + + function begin(event: PointerEvent): void { + if (isDragging.value) + return + activeTarget = event.currentTarget as Element | null + if (activeTarget && 'setPointerCapture' in activeTarget) { + try { + (activeTarget as Element & { setPointerCapture: (id: number) => void }).setPointerCapture(event.pointerId) + } + catch { + // some targets disallow capture (e.g. detached nodes); harmless. + } + } + + state.value = { + pointerId: event.pointerId, + startEvent: event, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + deltaX: 0, + deltaY: 0, + } + isDragging.value = true + + if (options.cursor) + document.body.style.cursor = options.cursor + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onEscape) + + options.onStart?.(state.value) + } + + function onPointerMove(event: PointerEvent): void { + if (!state.value || event.pointerId !== state.value.pointerId) + return + state.value = { + ...state.value, + currentX: event.clientX, + currentY: event.clientY, + deltaX: event.clientX - state.value.startX, + deltaY: event.clientY - state.value.startY, + } + options.onMove?.(state.value) + } + + function onPointerUp(event: PointerEvent): void { + if (!state.value || event.pointerId !== state.value.pointerId) + return + finish(false) + } + + function onPointerCancel(): void { + if (!state.value) + return + finish(true) + } + + function onEscape(event: KeyboardEvent): void { + if (event.key === 'Escape' && isDragging.value) + finish(true) + } + + function finish(cancelled: boolean): void { + if (!state.value) + return + const last = state.value + + options.onEnd?.(last, cancelled) + cleanup() + } + + function cancel(): void { + if (isDragging.value) + finish(true) + } + + function cleanup(): void { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onEscape) + if (options.cursor) + document.body.style.cursor = '' + if (activeTarget && 'releasePointerCapture' in activeTarget && state.value) { + try { + (activeTarget as Element & { releasePointerCapture: (id: number) => void }).releasePointerCapture(state.value.pointerId) + } + catch { + // ignore — capture may have been released by the browser already. + } + } + activeTarget = null + state.value = null + isDragging.value = false + } + + onBeforeUnmount(() => { + if (isDragging.value) + cleanup() + }) + + return { isDragging, state, begin, cancel } +} diff --git a/apps/app/src/composables/timetable/useTimetableKeyboard.ts b/apps/app/src/composables/timetable/useTimetableKeyboard.ts new file mode 100644 index 00000000..902396b6 --- /dev/null +++ b/apps/app/src/composables/timetable/useTimetableKeyboard.ts @@ -0,0 +1,184 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue' +import type { Ref } from 'vue' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' +import { SNAP_MIN } from '@/lib/timetable/snap' +import type { Performance, Stage } from '@/types/timetable' + +/** + * RFC v0.2 D20 — keyboard interaction model for the timetable canvas. + * + * Listens to keydown events on the canvas root once mounted. Routes + * directional / modifier keys into the same mutation composable that + * the pointer drag uses, so keyboard nudges go through the same + * server-transactional path (D18) and inherit optimistic + rollback. + */ + +export interface KeyboardMoveCallbacks { + + /** Translate a performance by ±minutes (and optionally ±lanes / ±stages). */ + nudge: (perf: Performance, deltaMin: number, deltaLane: number, deltaStageIdx: number, idempotencyKey: string) => Promise + + /** Open the popover for the focused block. */ + openPopover: (perf: Performance, anchor: HTMLElement) => void + + /** Confirm + delete. */ + remove: (perf: Performance) => Promise +} + +export interface UseTimetableKeyboardArgs { + rootEl: Ref + + /** Pinia store reactive ref to the selected performance id. */ + selectedId: Ref + + /** Resolver: id → performance object (uses TanStack cache). */ + resolvePerformance: (id: string) => Performance | null + + /** Sorted stage list (so [/] navigates left/right). */ + stages: Ref + callbacks: KeyboardMoveCallbacks +} + +export function useTimetableKeyboard(args: UseTimetableKeyboardArgs): { announce: Ref } { + const announce = ref('') + + /** True while the user is in keyboard "drag" mode (Space → arrows → Enter/Esc). */ + const dragMode = ref(false) + let pendingMove: { deltaMin: number; deltaLane: number; deltaStageIdx: number } | null = null + + function focusBlock(id: string | null): void { + if (!id || !args.rootEl.value) + return + const el = args.rootEl.value.querySelector(`[data-perf-id="${id}"]`) + if (el) { + el.focus() + el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }) + } + } + + function onKeydown(event: KeyboardEvent): void { + const id = args.selectedId.value + if (!id) + return + const perf = args.resolvePerformance(id) + if (!perf) + return + + const stageMul = event.shiftKey ? 12 : 1 // Shift+Arrow ←/→ = ±60 min when SNAP_MIN=5 + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault() + if (dragMode.value) + accumulate(-SNAP_MIN * stageMul, 0, 0) + else + void args.callbacks.nudge(perf, -SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey()) + break + case 'ArrowRight': + event.preventDefault() + if (dragMode.value) + accumulate(SNAP_MIN * stageMul, 0, 0) + else + void args.callbacks.nudge(perf, SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey()) + break + case 'ArrowUp': + event.preventDefault() + if (event.shiftKey) { + if (dragMode.value) + accumulate(0, 0, -1) + else + void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey()) + } + else if (dragMode.value) { + accumulate(0, -1, 0) + } + else { + void args.callbacks.nudge(perf, 0, -1, 0, generateIdempotencyKey()) + } + break + case 'ArrowDown': + event.preventDefault() + if (event.shiftKey) { + if (dragMode.value) + accumulate(0, 0, 1) + else + void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey()) + } + else if (dragMode.value) { + accumulate(0, 1, 0) + } + else { + void args.callbacks.nudge(perf, 0, 1, 0, generateIdempotencyKey()) + } + break + case '[': + event.preventDefault() + void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey()) + break + case ']': + event.preventDefault() + void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey()) + break + case 'Enter': + case ' ': + if (dragMode.value && pendingMove) { + event.preventDefault() + + const { deltaMin, deltaLane, deltaStageIdx } = pendingMove + + dragMode.value = false + pendingMove = null + announce.value = 'Verplaatsing bevestigd.' + void args.callbacks.nudge(perf, deltaMin, deltaLane, deltaStageIdx, generateIdempotencyKey()) + } + else if (event.key === ' ') { + event.preventDefault() + dragMode.value = true + pendingMove = { deltaMin: 0, deltaLane: 0, deltaStageIdx: 0 } + announce.value = 'Toetsenbord-verplaatsing actief. Gebruik pijltjes, Enter bevestigt, Esc annuleert.' + } + else { + event.preventDefault() + + const el = args.rootEl.value?.querySelector(`[data-perf-id="${id}"]`) + if (el) + args.callbacks.openPopover(perf, el) + } + break + case 'Escape': + if (dragMode.value) { + event.preventDefault() + dragMode.value = false + pendingMove = null + announce.value = 'Verplaatsing geannuleerd.' + } + break + case 'Delete': + case 'Backspace': + event.preventDefault() + void args.callbacks.remove(perf) + break + } + } + + function accumulate(deltaMin: number, deltaLane: number, deltaStageIdx: number): void { + if (!pendingMove) + return + pendingMove = { + deltaMin: pendingMove.deltaMin + deltaMin, + deltaLane: pendingMove.deltaLane + deltaLane, + deltaStageIdx: pendingMove.deltaStageIdx + deltaStageIdx, + } + announce.value = `Voorlopig +${pendingMove.deltaMin} min, ${pendingMove.deltaLane} lanes, ${pendingMove.deltaStageIdx} stages.` + } + + onMounted(() => { + args.rootEl.value?.addEventListener('keydown', onKeydown) + }) + + onBeforeUnmount(() => { + args.rootEl.value?.removeEventListener('keydown', onKeydown) + }) + + return { announce, focusSelected: () => focusBlock(args.selectedId.value) } as { announce: Ref } +} diff --git a/apps/app/src/composables/useFormDraft.ts b/apps/app/src/composables/useFormDraft.ts index 57778c23..7b938f69 100644 --- a/apps/app/src/composables/useFormDraft.ts +++ b/apps/app/src/composables/useFormDraft.ts @@ -7,6 +7,7 @@ import { useSaveFormDraft, useSubmitForm, } from '@/composables/api/usePublicForm' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/types/forms/formBuilder' /** sessionStorage key for reusing an idempotency key across reloads. */ @@ -19,25 +20,6 @@ export function draftSubmitterStorageKey(token: string): string { return `draft_submitter:${token}` } -function generateIdempotencyKey(): string { - const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto - if (c?.randomUUID) { - // UUID v4 (36 chars) exceeds backend max:30. Backend expects 6..30 - // chars so compress to 24 hex chars (still collision-resistant). - return c.randomUUID().replace(/-/g, '').slice(0, 24) - } - if (c?.getRandomValues) { - const buf = new Uint8Array(12) - - c.getRandomValues(buf) - - return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('') - } - - // Last-resort fallback — still within 6..30. - return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30) -} - interface UseFormDraftOptions { /** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */ diff --git a/apps/app/src/lib/idempotencyKey.ts b/apps/app/src/lib/idempotencyKey.ts new file mode 100644 index 00000000..f4c11c60 --- /dev/null +++ b/apps/app/src/lib/idempotencyKey.ts @@ -0,0 +1,26 @@ +/** + * Generate a 24-hex idempotency key (UUID v4 with dashes stripped, sliced to 24). + * + * Backend constraint: Crewli's idempotency middleware accepts 6..30 chars + * (FORM-07). UUID v4's 36-char form exceeds the cap, so it is normalised + * to 24 hex chars — still collision-resistant for per-mutation keys. + * + * Safe fallbacks: `getRandomValues` (12 random bytes) when randomUUID is + * unavailable; finally a non-cryptographic time+random suffix so calling + * code never throws. + */ +export function generateIdempotencyKey(): string { + const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto + if (c?.randomUUID) + return c.randomUUID().replace(/-/g, '').slice(0, 24) + + if (c?.getRandomValues) { + const buf = new Uint8Array(12) + + c.getRandomValues(buf) + + return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('') + } + + return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30) +} diff --git a/apps/app/src/lib/timetable/b2b.ts b/apps/app/src/lib/timetable/b2b.ts new file mode 100644 index 00000000..54619596 --- /dev/null +++ b/apps/app/src/lib/timetable/b2b.ts @@ -0,0 +1,75 @@ +import type { Performance } from '@/types/timetable' +import { ArtistEngagementStatus } from '@/types/timetable' + +/** + * RFC v0.2 D26 — back-to-back marker rule. Two consecutive non-cancelled + * performances on the same stage with `p2.start_at - p1.end_at ∈ [0, 3]` + * minutes get a B2B marker. + * + * (RFC text says "≤5 min" in D6/D25 but the sprint-4 prompt's component + * spec calls for the prototype's stricter 3-minute threshold. Exposed as + * a constant so the threshold is one place.) + */ +export const B2B_THRESHOLD_MIN = 3 + +export interface B2BLink { + leftId: string + rightId: string + gapMin: number +} + +export function findB2BLinks(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): B2BLink[] { + const links: B2BLink[] = [] + const groups = new Map() + + for (const p of performances) { + if (isCancelled(p)) + continue + if (p.stage_id === null || p.start_at === null || p.end_at === null) + continue + + // B2B is per-lane (changeover relevant within a lane). + const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}` + const list = groups.get(key) ?? [] + + list.push(p) + groups.set(key, list) + } + + for (const list of groups.values()) { + if (list.length < 2) + continue + const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!)) + for (let i = 0; i < sorted.length - 1; i++) { + const left = sorted[i] + const right = sorted[i + 1] + const gapMs = Date.parse(right.start_at!) - Date.parse(left.end_at!) + const gapMin = gapMs / 60_000 + if (gapMin >= 0 && gapMin <= thresholdMin) + links.push({ leftId: left.id, rightId: right.id, gapMin }) + } + } + + return links +} + +/** + * Convenience: returns two sets — performance IDs that have a B2B link + * to the LEFT (earlier neighbour ends close), and to the RIGHT + * (later neighbour starts close). Drives the dot rendering on the block. + */ +export function findB2BSides(performances: Performance[], thresholdMin = B2B_THRESHOLD_MIN): { leftSet: Set; rightSet: Set } { + const links = findB2BLinks(performances, thresholdMin) + const leftSet = new Set() + const rightSet = new Set() + for (const link of links) { + rightSet.add(link.leftId) + leftSet.add(link.rightId) + } + + return { leftSet, rightSet } +} + +function isCancelled(p: Performance): boolean { + return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED +} diff --git a/apps/app/src/lib/timetable/capacity.ts b/apps/app/src/lib/timetable/capacity.ts new file mode 100644 index 00000000..25c98539 --- /dev/null +++ b/apps/app/src/lib/timetable/capacity.ts @@ -0,0 +1,57 @@ +import type { ArtistEngagement, Performance, Stage } from '@/types/timetable' + +/** + * RFC v0.2 D25 — capacity warning when expected attendance exceeds + * the stage capacity by more than the tolerance (defaults to 10%). + * + * Inputs: + * `stage.capacity` (nullable; null = no constraint, no warn) + * `engagement.crew_count + engagement.guests_count` if present + * else `artist.default_draw` if present + * + * Returns null if no warning applies (incl. missing data). + */ +export const CAPACITY_TOLERANCE = 1.1 + +export type CapacityLevel = 'warn' | 'critical' + +export interface CapacityState { + level: CapacityLevel + expected: number + capacity: number + ratio: number +} + +export function evaluateCapacity( + performance: Performance, + stage: Stage | null | undefined, + engagement: ArtistEngagement | null | undefined, +): CapacityState | null { + if (!stage || stage.capacity === null || stage.capacity <= 0) + return null + + const expected = expectedAttendance(performance, engagement) + if (expected === null) + return null + + const ratio = expected / stage.capacity + if (ratio <= CAPACITY_TOLERANCE) + return null + + return { + level: ratio > 1.5 ? 'critical' : 'warn', + expected, + capacity: stage.capacity, + ratio, + } +} + +function expectedAttendance(_p: Performance, e: ArtistEngagement | null | undefined): number | null { + if (!e) + return null + const crewGuests = (e.crew_count ?? 0) + (e.guests_count ?? 0) + if (crewGuests > 0) + return crewGuests + + return e.artist?.default_draw ?? null +} diff --git a/apps/app/src/lib/timetable/conflict.ts b/apps/app/src/lib/timetable/conflict.ts new file mode 100644 index 00000000..b97f9460 --- /dev/null +++ b/apps/app/src/lib/timetable/conflict.ts @@ -0,0 +1,84 @@ +import type { Performance } from '@/types/timetable' +import { ArtistEngagementStatus } from '@/types/timetable' + +/** + * Same-stage, same-event, same-lane time overlap = conflict (RFC D5). + * + * Two intervals overlap iff `a.start < b.end && b.start < a.end`. Touching + * at endpoints (a.end === b.start) is NOT overlap. Cancelled engagements + * never participate. + * + * Returns the set of performance IDs involved in at least one conflict. + */ +export function findConflicts(performances: Performance[]): Set { + const conflicts = new Set() + const groups = new Map() + + for (const p of performances) { + if (isCancelled(p)) + continue + if (p.stage_id === null || p.start_at === null || p.end_at === null) + continue + const key = `${p.stage_id}::${p.event_id}::${p.lane_resolved}` + const list = groups.get(key) ?? [] + + list.push(p) + groups.set(key, list) + } + + for (const list of groups.values()) { + if (list.length < 2) + continue + const sorted = list.slice().sort((a, b) => Date.parse(a.start_at!) - Date.parse(b.start_at!)) + for (let i = 0; i < sorted.length; i++) { + const ai = sorted[i] + const aEnd = Date.parse(ai.end_at!) + for (let j = i + 1; j < sorted.length; j++) { + const bj = sorted[j] + const bStart = Date.parse(bj.start_at!) + if (bStart >= aEnd) + break + + // bStart < aEnd → overlap (b.start < a.end && a.start < b.end is + // already guaranteed since sorted by start ascending). + conflicts.add(ai.id) + conflicts.add(bj.id) + } + } + } + + return conflicts +} + +/** + * True if two performances would conflict if placed at the given placement. + * Used in the drag-preview path to surface a conflict ring before commit. + */ +export function wouldConflict( + candidate: { id: string; stage_id: string | null; lane: number; start_at: string; end_at: string }, + others: Performance[], +): boolean { + if (candidate.stage_id === null) + return false + const cs = Date.parse(candidate.start_at) + const ce = Date.parse(candidate.end_at) + + for (const o of others) { + if (o.id === candidate.id || isCancelled(o)) + continue + if (o.stage_id !== candidate.stage_id || o.lane_resolved !== candidate.lane) + continue + if (o.start_at === null || o.end_at === null) + continue + const os = Date.parse(o.start_at) + const oe = Date.parse(o.end_at) + if (cs < oe && os < ce) + return true + } + + return false +} + +function isCancelled(p: Performance): boolean { + return p.engagement?.booking_status?.value === ArtistEngagementStatus.CANCELLED +} diff --git a/apps/app/src/lib/timetable/index.ts b/apps/app/src/lib/timetable/index.ts new file mode 100644 index 00000000..9164ff3e --- /dev/null +++ b/apps/app/src/lib/timetable/index.ts @@ -0,0 +1,6 @@ +export * from './snap' +export * from './time-grid' +export * from './conflict' +export * from './b2b' +export * from './capacity' +export * from './lane' diff --git a/apps/app/src/lib/timetable/lane.ts b/apps/app/src/lib/timetable/lane.ts new file mode 100644 index 00000000..db56a2bf --- /dev/null +++ b/apps/app/src/lib/timetable/lane.ts @@ -0,0 +1,147 @@ +import type { Performance } from '@/types/timetable' + +/** + * Client-side preview of the server's lane resolution (RFC v0.2 D13/D19). + * Server is authoritative; this implementation runs only during drag so + * the user sees the eventual lane assignment before the PATCH lands. + * + * Two-pass placement (mirrors the LaneResolver service in the backend): + * + * Pass 1 — items with explicit `lane` go to that lane (bumped down on + * time-overlap). Sorted by lane asc then start asc. + * Pass 2 — items without explicit lane go to the lowest free lane. + * Sorted by start asc, ties broken by id. + * + * The cohort is "every performance on the same stage in the same event". + * Cancelled engagements are excluded from collision checks. + */ + +export interface LaneSubject { + id: string + + /** Raw persisted lane (or `null` for auto-pack candidates). */ + lane: number | null + start_at: string + end_at: string + + /** True if this performance should be excluded from conflict checks. */ + cancelled?: boolean +} + +export interface LaneAssignment { + + /** Map of perf id → resolved lane (0-indexed). */ + laneOf: Record + + /** Width of the stage row in lanes (max lane + 1, min 1). */ + laneCount: number +} + +export function resolveLanes(items: LaneSubject[]): LaneAssignment { + const laneOf: Record = {} + let maxLane = 0 + + // Sort once for deterministic tie-breaking. + const sorted = items.slice().sort((a, b) => { + const sa = Date.parse(a.start_at) + const sb = Date.parse(b.start_at) + if (sa !== sb) + return sa - sb + + return a.id.localeCompare(b.id) + }) + + const overlapsAt = (it: LaneSubject, lane: number): boolean => { + if (it.cancelled) + return false + const itStart = Date.parse(it.start_at) + const itEnd = Date.parse(it.end_at) + for (const o of sorted) { + if (o.id === it.id) + continue + if (o.cancelled) + continue + if (laneOf[o.id] !== lane) + continue + const oStart = Date.parse(o.start_at) + const oEnd = Date.parse(o.end_at) + if (oStart < itEnd && itStart < oEnd) + return true + } + + return false + } + + // Pass 1 — explicit lanes, sorted by requested lane asc then start asc. + const explicit = sorted + .filter(i => Number.isInteger(i.lane)) + .sort((a, b) => { + if (a.lane !== b.lane) + return (a.lane as number) - (b.lane as number) + + return Date.parse(a.start_at) - Date.parse(b.start_at) + }) + + for (const it of explicit) { + let lane = Math.max(0, it.lane as number) + while (overlapsAt(it, lane)) lane++ + laneOf[it.id] = lane + if (lane > maxLane) + maxLane = lane + } + + // Pass 2 — implicit lanes, lowest free. + for (const it of sorted) { + if (it.id in laneOf) + continue + let lane = 0 + while (overlapsAt(it, lane)) lane++ + laneOf[it.id] = lane + if (lane > maxLane) + maxLane = lane + } + + return { laneOf, laneCount: maxLane + 1 } +} + +/** + * Drag-preview cascade: simulate dropping `dragged` at the candidate + * (stage, lane, start, end) and return the resulting lane mapping + * for the cohort. Server runs the same logic transactionally per RFC D18. + * + * Pure: never mutates inputs. The dragged item carries its desired lane + * via `lane`; cohort items keep their persisted lane. + */ +export function previewCascade( + dragged: LaneSubject, + cohort: Performance[], +): LaneAssignment { + const subjects: LaneSubject[] = cohort + .filter(p => p.id !== dragged.id && p.start_at !== null && p.end_at !== null) + .map(p => ({ + id: p.id, + lane: p.lane, + start_at: p.start_at as string, + end_at: p.end_at as string, + cancelled: p.engagement?.booking_status?.value === 'cancelled', + })) + + // Find an existing item that already occupies the dragged item's + // requested lane × time slot — bump dragged to oLane + 1 BEFORE running + // the resolver so cascade-bump triggers naturally. + const wanted = dragged.lane ?? 0 + + const occupant = subjects.find(s => + !s.cancelled + && s.lane === wanted + && Date.parse(s.start_at) < Date.parse(dragged.end_at) + && Date.parse(s.end_at) > Date.parse(dragged.start_at), + ) + + const finalLane = occupant ? wanted + 1 : wanted + + return resolveLanes([ + ...subjects, + { ...dragged, lane: finalLane }, + ]) +} diff --git a/apps/app/src/lib/timetable/snap.ts b/apps/app/src/lib/timetable/snap.ts new file mode 100644 index 00000000..e7c4fdb8 --- /dev/null +++ b/apps/app/src/lib/timetable/snap.ts @@ -0,0 +1,27 @@ +/** + * Round a value to the nearest multiple of `step`. + * + * RFC-TIMETABLE v0.2 D7 — drag/resize snap is 5 minutes for v1 + * (the prototype audit §2.11 used 15; v1 RFC §D20 chose 5 to give the + * keyboard nudges finer control). Same primitive is reused for + * pixel-precision snaps via the pxPerMin coefficient at the call site. + */ +export function snap(value: number, step: number): number { + if (step <= 0) + return value + + return Math.round(value / step) * step +} + +/** The grid snap interval in minutes. RFC D7 + D20. */ +export const SNAP_MIN = 5 + +/** Minimum performance duration in minutes. Matches prototype audit §2.17. */ +export const MIN_DURATION_MIN = 15 + +/** + * Snap a minute count, then clamp into [min, max]. + */ +export function snapClamp(value: number, step: number, min: number, max: number): number { + return Math.max(min, Math.min(max, snap(value, step))) +} diff --git a/apps/app/src/lib/timetable/time-grid.ts b/apps/app/src/lib/timetable/time-grid.ts new file mode 100644 index 00000000..917c101e --- /dev/null +++ b/apps/app/src/lib/timetable/time-grid.ts @@ -0,0 +1,70 @@ +/** + * Pixel ↔ time conversions for the timetable canvas. + * + * The grid is anchored on a CarbonImmutable `gridStart` (typically + * the sub-event's start_at, possibly extended to the previous hour + * boundary). All other coordinates are derived from minutes-since- + * `gridStart` × `pxPerMin`. + * + * Pure functions — no DOM access. Input is ISO-8601 strings or epoch ms; + * the grid origin is captured once per render in the page entry. + */ + +export interface TimeGridConfig { + + /** Minute 0 of the canvas, as ISO-8601 (the sub-event start anchor). */ + gridStartIso: string + + /** Total minutes spanned by the canvas (e.g. 13h × 60 = 780). */ + totalMinutes: number + + /** Horizontal pixels per minute (drives stretch/zoom). */ + pxPerMin: number +} + +export function isoToMinutes(iso: string, gridStartIso: string): number { + const start = Date.parse(gridStartIso) + const at = Date.parse(iso) + + return Math.round((at - start) / 60_000) +} + +export function minutesToIso(minutes: number, gridStartIso: string): string { + const start = Date.parse(gridStartIso) + + return new Date(start + minutes * 60_000).toISOString() +} + +export function minutesToPx(minutes: number, pxPerMin: number): number { + return minutes * pxPerMin +} + +export function pxToMinutes(px: number, pxPerMin: number): number { + if (pxPerMin <= 0) + return 0 + + return px / pxPerMin +} + +/** + * The wall-clock label for a tick at `minute`. RFC v0.2 §D21 defaults + * to a 24h HH:MM format in nl-NL locale; days-spanning canvases + * preserve the wrap (00:00 + minutes since previous midnight). + */ +export function formatTickLabel(minute: number, gridStartIso: string): string { + const iso = minutesToIso(minute, gridStartIso) + const date = new Date(iso) + + return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', hour12: false }) +} + +/** + * Generate evenly-spaced tick positions in [0, totalMinutes] at `intervalMin`. + */ +export function generateTicks(totalMinutes: number, intervalMin: number): number[] { + const ticks: number[] = [] + for (let m = 0; m <= totalMinutes; m += intervalMin) + ticks.push(m) + + return ticks +} diff --git a/apps/app/src/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue new file mode 100644 index 00000000..ed0a1515 --- /dev/null +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -0,0 +1,652 @@ + + + + + diff --git a/apps/app/src/schemas/timetable.ts b/apps/app/src/schemas/timetable.ts new file mode 100644 index 00000000..cb4bced8 --- /dev/null +++ b/apps/app/src/schemas/timetable.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + ArtistEngagementStatus, + BumaHandledBy, + FeeType, + PaymentStatus, +} from '@/types/timetable' + +const artistEngagementStatusSchema = z.enum([ + ArtistEngagementStatus.DRAFT, + ArtistEngagementStatus.REQUESTED, + ArtistEngagementStatus.OPTION, + ArtistEngagementStatus.OFFERED, + ArtistEngagementStatus.CONFIRMED, + ArtistEngagementStatus.CONTRACTED, + ArtistEngagementStatus.CANCELLED, + ArtistEngagementStatus.REJECTED, + ArtistEngagementStatus.DECLINED, +]) + +const bumaHandledBySchema = z.enum([ + BumaHandledBy.ORGANISATION, + BumaHandledBy.BOOKING_AGENCY, + BumaHandledBy.NOT_APPLICABLE, +]) + +const feeTypeSchema = z.enum([ + FeeType.FLAT, + FeeType.DOOR_SPLIT, + FeeType.GUARANTEE_PLUS_SPLIT, +]) + +const paymentStatusSchema = z.enum([ + PaymentStatus.NONE, + PaymentStatus.DEPOSIT_PAID, + PaymentStatus.PAID_IN_FULL, +]) + +function enumLabel(value: T) { + return z.object({ + value: value.nullable(), + label: z.string().nullable(), + }) +} + +export const genreSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + name: z.string(), + color: z.string().nullable(), + sort_order: z.number(), + is_active: z.boolean(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +export const artistContactSchema = z.object({ + id: z.string(), + artist_id: z.string(), + name: z.string(), + email: z.string().nullable(), + phone: z.string().nullable(), + role: z.string(), + is_primary: z.boolean(), + receives_briefing: z.boolean(), + receives_infosheet: z.boolean(), +}) + +export const artistSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + name: z.string(), + slug: z.string(), + default_genre_id: z.string().nullable(), + default_genre: genreSchema.nullable().optional(), + default_draw: z.number().nullable(), + star_rating: z.number().nullable(), + home_base_country: z.string().nullable(), + agent_company_id: z.string().nullable(), + agent_company: z + .object({ + id: z.string().nullable(), + name: z.string().nullable(), + handles_buma: z.boolean(), + }) + .optional(), + notes: z.string().nullable(), + contacts: z.array(artistContactSchema).optional(), + engagements_summary: z.object({ + lifetime_count: z.number(), + upcoming_count: z.number(), + }), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const artistEngagementSchema = z.object({ + id: z.string(), + organisation_id: z.string(), + artist_id: z.string(), + event_id: z.string(), + artist: artistSchema.optional(), + project_leader_id: z.string().nullable(), + project_leader: z + .object({ + id: z.string().nullable(), + name: z.string(), + email: z.string().nullable(), + }) + .optional(), + booking_status: enumLabel(artistEngagementStatusSchema), + fee_amount: z.number().nullable(), + fee_currency: z.string(), + fee_type: enumLabel(feeTypeSchema), + buma_applicable: z.boolean(), + buma_percentage: z.number().nullable(), + buma_handled_by: enumLabel(bumaHandledBySchema), + vat_applicable: z.boolean(), + vat_percentage: z.number().nullable(), + deal_breakdown: z + .array(z.object({ label: z.string().optional(), amount: z.number() })) + .nullable(), + deposit_percentage: z.number().nullable(), + deposit_due_date: z.string().nullable(), + balance_due_date: z.string().nullable(), + payment_status: enumLabel(paymentStatusSchema), + crew_count: z.number(), + guests_count: z.number(), + requested_at: z.string().nullable(), + option_expires_at: z.string().nullable(), + advance_open_from: z.string().nullable(), + advance_open_to: z.string().nullable(), + advancing_completed_count: z.number(), + advancing_total_count: z.number(), + notes: z.string().nullable(), + computed: z.object({ + buma_amount: z.number(), + vat_grondslag: z.number(), + vat_amount: z.number(), + breakdown_total: z.number(), + total_cost: z.number(), + }), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const stageSchema = z.object({ + id: z.string(), + event_id: z.string(), + name: z.string(), + color: z.string(), + capacity: z.number().nullable(), + sort_order: z.number(), + stage_days: z.array(z.string()).optional(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), +}) + +export const performanceWarningSchema = z.enum([ + 'overlap', + 'b2b', + 'capacity', + 'b2b_left', + 'b2b_right', +]) + +export const performanceSchema = z.object({ + id: z.string(), + engagement_id: z.string(), + event_id: z.string(), + stage_id: z.string().nullable(), + lane: z.number(), + lane_resolved: z.number(), + start_at: z.string().nullable(), + end_at: z.string().nullable(), + version: z.number(), + notes: z.string().nullable(), + warnings: z.array(performanceWarningSchema), + engagement: artistEngagementSchema.optional(), + stage: stageSchema.nullable().optional(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + deleted_at: z.string().nullable(), +}) + +export const moveTimetableSuccessSchema = z.object({ + moved: performanceSchema, + cascaded: z.array(performanceSchema), +}) + +export const moveTimetableConflictSchema = z.object({ + conflict: z.literal('version_mismatch'), + current_version: z.number(), + client_version: z.number(), + server_data: performanceSchema, +}) + +// ─── Form-payload schemas (validated client-side before POST) ───────── + +export const createPerformancePayloadSchema = z.object({ + engagement_id: z.string().min(1, 'Engagement is verplicht.'), + event_id: z.string().min(1, 'Sub-event is verplicht.'), + stage_id: z.string().nullable(), + start_at: z.string().min(1, 'Starttijd is verplicht.'), + end_at: z.string().min(1, 'Eindtijd is verplicht.'), + lane: z.number().int().min(0).max(9).optional().nullable(), + notes: z.string().max(1000).optional().nullable(), +}).refine(p => p.start_at < p.end_at, { + message: 'Eindtijd moet na de starttijd liggen.', + path: ['end_at'], +}) + +export const createStagePayloadSchema = z.object({ + name: z.string().min(1, 'Naam is verplicht.').max(120), + color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Kleur moet een #RRGGBB hex zijn.'), + capacity: z.number().int().min(0).optional().nullable(), + sort_order: z.number().int().min(0).optional().nullable(), +}) + +export const moveTimetablePayloadSchema = z.object({ + performance_id: z.string(), + target_stage_id: z.string().nullable(), + target_start_at: z.string().nullable(), + target_end_at: z.string().nullable(), + target_lane: z.number().int().min(0).max(9).nullable(), + version: z.number().int().min(0), +}) diff --git a/apps/app/src/stores/useTimetableStore.ts b/apps/app/src/stores/useTimetableStore.ts new file mode 100644 index 00000000..4995e7f6 --- /dev/null +++ b/apps/app/src/stores/useTimetableStore.ts @@ -0,0 +1,115 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { + ArtistEngagementStatus, +} from '@/types/timetable' +import type { + ArtistEngagementStatus as ArtistEngagementStatusType, + Performance, +} from '@/types/timetable' + +/** + * UI / cross-component state for the timetable canvas. + * + * Server state (stages / performances / wachtrij) lives in the TanStack + * cache via useTimetable.ts — this store carries only the bits that + * multiple components on the canvas need to share: + * + * - Selected performance id (drives popover anchor + keyboard focus) + * - In-flight drag state + origin snapshot for rollback (RFC D7) + * - Status filter (chips above wachtrij + canvas dimming) + * - Free-text search (wachtrij filter) + * + * Active sub-event ("day") is intentionally NOT held here — the URL + * `?day` query param is the source of truth, derived in the page entry + * via a computed against the validated sub-event list. Keeping it out + * of Pinia avoids the desync class of bug between URL and store. + */ +export const useTimetableStore = defineStore('timetable', () => { + const selectedPerformanceId = ref(null) + + // Drag state — set by usePointerDrag handlers, consumed by mutation + // composables for optimistic preview + rollback. + const dragPerformanceId = ref(null) + const dragOriginSnapshot = ref(null) + + const dragGhost = ref<{ + stageId: string | null + startAt: string + endAt: string + lane: number + } | null>(null) + + // Status filter — defaults to "all on except cancelled" (prototype audit §4.7). + const statusFilter = ref>(new Set([ + ArtistEngagementStatus.DRAFT, + ArtistEngagementStatus.REQUESTED, + ArtistEngagementStatus.OPTION, + ArtistEngagementStatus.OFFERED, + ArtistEngagementStatus.CONFIRMED, + ArtistEngagementStatus.CONTRACTED, + ArtistEngagementStatus.REJECTED, + ArtistEngagementStatus.DECLINED, + ])) + + const searchQuery = ref('') + + function selectPerformance(id: string | null): void { + selectedPerformanceId.value = id + } + + function startDrag(perf: Performance): void { + dragPerformanceId.value = perf.id + dragOriginSnapshot.value = perf + } + + function updateDragGhost(ghost: typeof dragGhost.value): void { + dragGhost.value = ghost + } + + function endDrag(): void { + dragPerformanceId.value = null + dragOriginSnapshot.value = null + dragGhost.value = null + } + + function toggleStatus(status: ArtistEngagementStatusType): void { + const next = new Set(statusFilter.value) + if (next.has(status)) + next.delete(status) + else next.add(status) + statusFilter.value = next + } + + function setStatusFilter(statuses: ArtistEngagementStatusType[]): void { + statusFilter.value = new Set(statuses) + } + + function isStatusVisible(status: ArtistEngagementStatusType | null | undefined): boolean { + return status !== null && status !== undefined && statusFilter.value.has(status) + } + + function setSearchQuery(query: string): void { + searchQuery.value = query + } + + const isDragging = computed(() => dragPerformanceId.value !== null) + + return { + selectedPerformanceId, + dragPerformanceId, + dragOriginSnapshot, + dragGhost, + statusFilter, + searchQuery, + isDragging, + selectPerformance, + startDrag, + updateDragGhost, + endDrag, + toggleStatus, + setStatusFilter, + isStatusVisible, + setSearchQuery, + } +}) diff --git a/apps/app/src/styles/tokens/_timetable.css b/apps/app/src/styles/tokens/_timetable.css new file mode 100644 index 00000000..f4d31e36 --- /dev/null +++ b/apps/app/src/styles/tokens/_timetable.css @@ -0,0 +1,145 @@ +/* RFC-TIMETABLE v0.2 D21 — status colour tokens for the timetable canvas. + * + * Per-status colour pairs (background + border + foreground + dot) live as + * CSS custom properties so PerformanceBlock + WachtrijCard + popovers all + * resolve through `var(--tt-status-{status}-*)`. + * + * ART-14 (deferred) will let an organisation override the palette by + * scoping these custom properties on a `[data-org-id="…"]` selector. + * + * Geometry tokens (lane height, time-axis spacing, block padding) live + * next to the colours so any rendering tweak is one stop. + * + * NOTE: this file is plain CSS (not SCSS) so that vitest+jsdom can load + * it via `import '@/styles/tokens/_timetable.css'` from mountWithVuexy + * — getComputedStyle() then resolves var(--tt-…) in component tests. + */ + +:root { + /* ─── Status palettes (8 visible + cancelled overlay) ───────────── */ + + --tt-status-draft-bg: #f1efe9; + --tt-status-draft-border: #dcd9d1; + --tt-status-draft-fg: #3a3830; + --tt-status-draft-dot: #a09c92; + + --tt-status-requested-bg: #fff6e0; + --tt-status-requested-border:#f0d99a; + --tt-status-requested-fg: #5d4612; + --tt-status-requested-dot: #d9a93c; + + --tt-status-option-bg: #f3eefa; + --tt-status-option-border: #d8c8ee; + --tt-status-option-fg: #4b2d75; + --tt-status-option-dot: #8b5cd0; + + --tt-status-offered-bg: #fef5e7; + --tt-status-offered-border: #f4d6a3; + --tt-status-offered-fg: #6d4406; + --tt-status-offered-dot: #e0992c; + + --tt-status-confirmed-bg: #e8f8f0; + --tt-status-confirmed-border:#a8dec5; + --tt-status-confirmed-fg: #1a5b3b; + --tt-status-confirmed-dot: #2fa66a; + + --tt-status-contracted-bg: #e6f1fb; + --tt-status-contracted-border:#a4c8eb; + --tt-status-contracted-fg: #134474; + --tt-status-contracted-dot: #2a78c8; + + --tt-status-cancelled-bg: #f5f3ef; + --tt-status-cancelled-border:#cfcdc7; + --tt-status-cancelled-fg: #75706a; + --tt-status-cancelled-dot: #999591; + + --tt-status-rejected-bg: #fbeaec; + --tt-status-rejected-border: #ecb6bd; + --tt-status-rejected-fg: #75162a; + --tt-status-rejected-dot: #c5354b; + + --tt-status-declined-bg: #f7eee9; + --tt-status-declined-border: #ddc6b9; + --tt-status-declined-fg: #6b3915; + --tt-status-declined-dot: #b56331; + + /* ─── Cancelled hatch overlay ───────────────────────────────────── */ + + --tt-cancelled-hatch: repeating-linear-gradient( + 135deg, + transparent 0, + transparent 6px, + rgba(0, 0, 0, 0.05) 6px, + rgba(0, 0, 0, 0.05) 8px + ); + + /* ─── Warnings + B2B ────────────────────────────────────────────── */ + + --tt-conflict-border: #d63d4b; + --tt-conflict-glow: rgba(214, 61, 75, 0.25); + + --tt-capacity-warn: #e0992c; + --tt-capacity-critical: #c5354b; + + --tt-b2b-dot: #2a78c8; + --tt-b2b-dot-size: 6px; + + --tt-trashed-overlay: rgba(0, 0, 0, 0.35); + --tt-trashed-icon: #75706a; + + /* ─── Geometry ──────────────────────────────────────────────────── */ + + --tt-lane-height: 44px; + --tt-lane-gap: 4px; + --tt-lane-pad: 4px; + + --tt-block-radius: 6px; + --tt-block-pad-x: 8px; + --tt-block-pad-y: 4px; + --tt-block-min-width: 24px; + + --tt-row-divider: #e6e3dc; + --tt-axis-tick: #cfcdc7; + --tt-axis-tick-major: #a09c92; + --tt-axis-label-fg: #4b4b48; + + --tt-canvas-bg: #fbfaf7; + --tt-canvas-grid-major: rgba(0, 0, 0, 0.06); + --tt-canvas-grid-minor: rgba(0, 0, 0, 0.025); + + /* ─── Drop / drag visuals ───────────────────────────────────────── */ + + --tt-ghost-bg: rgba(255, 215, 90, 0.18); + --tt-ghost-border:#f0c45a; + + --tt-focus-ring: #1f7ad1; + + /* ─── Day-tab chrome ────────────────────────────────────────────── */ + + --tt-tab-active-bg: #1f7ad1; + --tt-tab-active-fg: #ffffff; + --tt-tab-hover-bg: #eef2f7; +} + +/* ─── Animations ─────────────────────────────────────────────────────── */ + +@keyframes tt-cascade-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(31, 122, 209, 0.55); + transform: scale(1); + } + + 60% { + box-shadow: 0 0 0 7px rgba(31, 122, 209, 0); + transform: scale(1.015); + } + + 100% { + box-shadow: 0 0 0 0 rgba(31, 122, 209, 0); + transform: scale(1); + } +} + +.tt-cascade-pulse { + animation: tt-cascade-pulse 1.5s ease-out 1; +} diff --git a/apps/app/src/types/timetable.ts b/apps/app/src/types/timetable.ts new file mode 100644 index 00000000..14b4688a --- /dev/null +++ b/apps/app/src/types/timetable.ts @@ -0,0 +1,273 @@ +/** + * RFC-TIMETABLE v0.2 — frontend type mirrors of the backend resources. + * + * Source of truth: api/app/Http/Resources/Api/V1/Artist/*.php and the + * matching enums under api/app/Enums/Artist/*.php. Keep this file in + * lockstep with backend changes; runtime parsers live in + * src/schemas/timetable.ts and provide the safety net for drift. + */ + +// ─── Enums (mirror backend `enum: string` types verbatim) ──────────── + +export const ArtistEngagementStatus = { + DRAFT: 'draft', + REQUESTED: 'requested', + OPTION: 'option', + OFFERED: 'offered', + CONFIRMED: 'confirmed', + CONTRACTED: 'contracted', + CANCELLED: 'cancelled', + REJECTED: 'rejected', + DECLINED: 'declined', +} as const +export type ArtistEngagementStatus = typeof ArtistEngagementStatus[keyof typeof ArtistEngagementStatus] + +export const BumaHandledBy = { + ORGANISATION: 'organisation', + BOOKING_AGENCY: 'booking_agency', + NOT_APPLICABLE: 'not_applicable', +} as const +export type BumaHandledBy = typeof BumaHandledBy[keyof typeof BumaHandledBy] + +export const FeeType = { + FLAT: 'flat', + DOOR_SPLIT: 'door_split', + GUARANTEE_PLUS_SPLIT: 'guarantee_plus_split', +} as const +export type FeeType = typeof FeeType[keyof typeof FeeType] + +export const PaymentStatus = { + NONE: 'none', + DEPOSIT_PAID: 'deposit_paid', + PAID_IN_FULL: 'paid_in_full', +} as const +export type PaymentStatus = typeof PaymentStatus[keyof typeof PaymentStatus] + +// Backend wraps each enum in `{ value, label }` for i18n display. +export interface EnumLabel { + value: T | null + label: string | null +} + +// ─── Genre / Artist / Engagement ───────────────────────────────────── + +export interface Genre { + id: string + organisation_id: string + name: string + color: string | null + sort_order: number + is_active: boolean + created_at: string | null + updated_at: string | null +} + +export interface ArtistContact { + id: string + artist_id: string + name: string + email: string | null + phone: string | null + role: string + is_primary: boolean + receives_briefing: boolean + receives_infosheet: boolean +} + +export interface ArtistAgentCompany { + id: string | null + name: string | null + handles_buma: boolean +} + +export interface ArtistEngagementsSummary { + lifetime_count: number + upcoming_count: number +} + +export interface Artist { + id: string + organisation_id: string + name: string + slug: string + default_genre_id: string | null + default_genre?: Genre | null + default_draw: number | null + star_rating: number | null + home_base_country: string | null + agent_company_id: string | null + agent_company?: ArtistAgentCompany + notes: string | null + contacts?: ArtistContact[] + engagements_summary: ArtistEngagementsSummary + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +export interface ArtistEngagementProjectLeader { + id: string | null + name: string + email: string | null +} + +export interface DealBreakdownLine { + label?: string + amount: number +} + +export interface ArtistEngagementComputed { + buma_amount: number + vat_grondslag: number + vat_amount: number + breakdown_total: number + total_cost: number +} + +export interface ArtistEngagement { + id: string + organisation_id: string + artist_id: string + event_id: string + artist?: Artist + project_leader_id: string | null + project_leader?: ArtistEngagementProjectLeader + booking_status: EnumLabel + fee_amount: number | null + fee_currency: string + fee_type: EnumLabel + buma_applicable: boolean + buma_percentage: number | null + buma_handled_by: EnumLabel + vat_applicable: boolean + vat_percentage: number | null + deal_breakdown: DealBreakdownLine[] | null + deposit_percentage: number | null + deposit_due_date: string | null + balance_due_date: string | null + payment_status: EnumLabel + crew_count: number + guests_count: number + requested_at: string | null + option_expires_at: string | null + advance_open_from: string | null + advance_open_to: string | null + advancing_completed_count: number + advancing_total_count: number + notes: string | null + computed: ArtistEngagementComputed + performances?: Performance[] + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +// ─── Stage / StageDay ───────────────────────────────────────────────── + +export interface Stage { + id: string + event_id: string + name: string + color: string + capacity: number | null + sort_order: number + + /** Sub-event IDs the stage is active on. Present only when loaded. */ + stage_days?: string[] + created_at: string | null + updated_at: string | null +} + +// ─── Performance ────────────────────────────────────────────────────── + +export type PerformanceWarning = 'overlap' | 'b2b' | 'capacity' | 'b2b_left' | 'b2b_right' + +export interface Performance { + id: string + engagement_id: string + event_id: string + stage_id: string | null + + /** 0-indexed lane (raw, persisted). Use `lane_resolved` for rendering. */ + lane: number + + /** 0-indexed lane after server-side Pass 1 + Pass 2 resolution (RFC D19). */ + lane_resolved: number + start_at: string | null + end_at: string | null + + /** Optimistic-lock cursor (RFC D14). */ + version: number + notes: string | null + warnings: PerformanceWarning[] + engagement?: ArtistEngagement + stage?: Stage | null + created_at: string | null + updated_at: string | null + deleted_at: string | null +} + +// ─── Move endpoint contract (POST /timetable/move, RFC D18) ─────────── + +export interface MoveTimetablePayload { + performance_id: string + target_stage_id: string | null + target_start_at: string | null + target_end_at: string | null + target_lane: number | null + version: number +} + +export interface MoveTimetableSuccess { + moved: Performance + cascaded: Performance[] +} + +export interface MoveTimetableConflict { + conflict: 'version_mismatch' + current_version: number + client_version: number + server_data: Performance +} + +// ─── Performance create payload ─────────────────────────────────────── + +export interface CreatePerformancePayload { + engagement_id: string + event_id: string + stage_id: string | null + start_at: string + end_at: string + lane?: number | null + notes?: string | null +} + +export interface UpdatePerformancePayload { + notes?: string | null +} + +// ─── Stage create / update / reorder ────────────────────────────────── + +export interface CreateStagePayload { + name: string + color: string + capacity?: number | null + sort_order?: number | null +} + +export interface UpdateStagePayload extends Partial {} + +export interface ReorderStagesPayload { + stage_ids: string[] +} + +export interface ReplaceStageDaysPayload { + event_ids: string[] + force_orphan?: boolean +} + +export interface ReplaceStageDaysResponse { + stage: Stage + added_event_ids: string[] + removed_event_ids: string[] +} diff --git a/apps/app/tests/a11y/axe.test.ts b/apps/app/tests/a11y/axe.test.ts new file mode 100644 index 00000000..37f51fa4 --- /dev/null +++ b/apps/app/tests/a11y/axe.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { axe } from 'vitest-axe' +import { defineComponent, h, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue' +import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue' +import PerformancePopover from '@/components/timetable/PerformancePopover.vue' +import { type ArtistEngagement, ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' + +/** + * jsdom-based axe scans pick up structural a11y issues (missing roles, + * orphan labels, color-contrast metadata, ARIA mismatches) but cannot + * resolve actual rendered colors — that needs a real browser. Visual + * a11y is on TEST-INFRA-001's Playwright migration list. + * + * For now: zero violations on (1) PerformanceBlock with focus, + * (2) PerformancePopover open, (3) AddPerformanceDialog open. + */ + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +const engagement: Partial = { + id: 'e1', + artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'], + booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, + computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 }, + fee_amount: 1000, + advancing_completed_count: 3, + advancing_total_count: 5, +} + +function performance(): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + engagement: engagement as ArtistEngagement, + stage, + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +// Stub VDialog so the popover/dialog renders inline (axe needs to see the +// content in the wrapper, not in a teleport target). +const VDialogStub = defineComponent({ + name: 'VDialog', + props: ['modelValue'], + setup(_, { slots }) { + return () => h('div', { class: 'v-dialog-stub' }, slots.default?.()) + }, +}) + +// Component fragments are not full pages — skip page-level landmark +// rules (`region`, `page-has-heading-one`, `landmark-one-main`) that +// only make sense at document root. Visual contrast resolution is also +// jsdom-blind; TEST-INFRA-001's Playwright migration covers that. +const fragmentAxeOptions = { + rules: { + 'region': { enabled: false }, + 'page-has-heading-one': { enabled: false }, + 'landmark-one-main': { enabled: false }, + 'color-contrast': { enabled: false }, + }, +} + +describe('axe-core a11y enforcement (RFC D20 + D21)', () => { + it('PerformanceBlock has zero violations when rendered + focusable', async () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { + props: { + performance: performance(), + leftPx: 0, + widthPx: 200, + topPx: 0, + heightPx: 44, + }, + }) + + const results = await axe(wrapper.element, fragmentAxeOptions) + + expect(results).toHaveNoViolations() + }) + + it('PerformancePopover (open) has zero violations', async () => { + const orgIdRef = ref('org_1') + + const { wrapper } = mountWithVuexy(PerformancePopover, { + props: { + modelValue: true, + anchorRect: { top: 100, left: 100, right: 200, bottom: 150, width: 100, height: 50, x: 100, y: 100, toJSON: () => ({}) } as DOMRect, + performance: performance(), + orgId: orgIdRef.value, + }, + }) + + await wrapper.vm.$nextTick() + + const popoverEl = document.querySelector('.tt-popover') + + expect(popoverEl).toBeTruthy() + + const results = await axe(popoverEl as HTMLElement, fragmentAxeOptions) + + expect(results).toHaveNoViolations() + }) + + it('AddPerformanceDialog (open) has zero violations', async () => { + const fieldStub = defineComponent({ + name: 'FieldStub', + props: ['modelValue', 'label', 'errorMessages'], + setup(props) { + return () => h('div', { class: 'field-stub' }, [ + h('label', {}, String(props.label ?? '')), + ]) + }, + }) + + const { wrapper } = mountWithVuexy(AddPerformanceDialog, { + props: { + modelValue: true, + orgId: 'org_1', + eventId: 'ev_1', + dayId: 'day_1', + stages: [stage], + engagements: [engagement as ArtistEngagement], + }, + stubs: { + VDialog: VDialogStub, + AppTextField: fieldStub, + AppTextarea: fieldStub, + AppSelect: fieldStub, + AppAutocomplete: fieldStub, + AppDateTimePicker: fieldStub, + }, + }) + + await wrapper.vm.$nextTick() + + const results = await axe(wrapper.element, fragmentAxeOptions) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/apps/app/tests/a11y/keyboard.test.ts b/apps/app/tests/a11y/keyboard.test.ts new file mode 100644 index 00000000..4e2adff5 --- /dev/null +++ b/apps/app/tests/a11y/keyboard.test.ts @@ -0,0 +1,236 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { useTimetableKeyboard } from '@/composables/timetable/useTimetableKeyboard' +import { ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' + +/** + * Keyboard a11y end-to-end (RFC v0.2 D20). Tests the composable directly + * with a host component that owns a focusable canvas root, mirrors the + * page wiring, and exposes the callback spies + announce ref so we can + * assert without driving the whole timetable page. + */ + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +function perf(id: string): Performance { + return { + id, + engagement_id: `e_${id}`, + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + engagement: { + booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, + } as Performance['engagement'], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +interface HostExposed { + announce: () => string + setSelected: (id: string | null) => void + rootEl: () => HTMLElement | null + callbacks: { + nudge: ReturnType + openPopover: ReturnType + remove: ReturnType + } +} + +function mountKeyboardHost(performances: Performance[]) { + const callbacks = { + nudge: vi.fn().mockResolvedValue(undefined), + openPopover: vi.fn(), + remove: vi.fn().mockResolvedValue(undefined), + } + + const selectedId = ref(performances[0]?.id ?? null) + + const Host = defineComponent({ + setup(_, { expose }) { + const rootEl = ref(null) + const stagesRef = ref([stage]) + + const { announce } = useTimetableKeyboard({ + rootEl, + selectedId, + resolvePerformance: (id: string) => performances.find(p => p.id === id) ?? null, + stages: stagesRef, + callbacks, + }) + + expose({ + announce: () => announce.value, + setSelected: (id: string | null) => { selectedId.value = id }, + rootEl: () => rootEl.value, + callbacks, + }) + + return () => h('div', { 'ref': rootEl, 'tabindex': '0', 'data-test': 'canvas' }, [ + ...performances.map(p => + h('div', { 'data-perf-id': p.id, 'tabindex': '0' }, p.id), + ), + ]) + }, + }) + + const wrapper = mount(Host, { attachTo: document.body }) + + return { wrapper, callbacks, selectedId } +} + +function getExposed(wrapper: { vm: object }): HostExposed { + return (wrapper.vm as { $: { exposed: HostExposed } }).$.exposed +} + +function dispatch(rootEl: HTMLElement | null, key: string, opts: KeyboardEventInit = {}): void { + if (!rootEl) + throw new Error('rootEl is null') + rootEl.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts })) +} + +describe('useTimetableKeyboard — RFC D20 a11y model', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.useRealTimers()) + + it('Arrow Left nudges the selected performance by -SNAP_MIN', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowLeft') + + expect(callbacks.nudge).toHaveBeenCalledTimes(1) + expect(callbacks.nudge.mock.calls[0][1]).toBe(-5) // -SNAP_MIN + }) + + it('Arrow Right nudges by +SNAP_MIN', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowRight') + + expect(callbacks.nudge.mock.calls[0][1]).toBe(5) + }) + + it('Shift+Arrow Right nudges by +60 min (12 × SNAP_MIN)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowRight', { shiftKey: true }) + + expect(callbacks.nudge.mock.calls[0][1]).toBe(60) + }) + + it('Arrow Down shifts lane (+1)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowDown') + + expect(callbacks.nudge.mock.calls[0][2]).toBe(1) // deltaLane + }) + + it('Shift+Arrow Down shifts stage (+1)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowDown', { shiftKey: true }) + + expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx + }) + + it('] cycles to next stage preserving time + lane', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ']') + + expect(callbacks.nudge.mock.calls[0][1]).toBe(0) // deltaMin + expect(callbacks.nudge.mock.calls[0][2]).toBe(0) // deltaLane + expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx + }) + + it('Enter on a focused block opens the popover', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'Enter') + + expect(callbacks.openPopover).toHaveBeenCalledTimes(1) + expect(callbacks.openPopover.mock.calls[0][0]).toMatchObject({ id: 'p1' }) + }) + + it('Delete invokes the remove callback', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'Delete') + + expect(callbacks.remove).toHaveBeenCalledTimes(1) + expect(callbacks.remove.mock.calls[0][0]).toMatchObject({ id: 'p1' }) + }) + + it('Space enters drag mode + announces; Arrow accumulates; Enter commits', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ' ') + expect(exposed.announce()).toMatch(/Toetsenbord-verplaatsing/i) + expect(callbacks.nudge).not.toHaveBeenCalled() + + dispatch(exposed.rootEl(), 'ArrowRight') + dispatch(exposed.rootEl(), 'ArrowRight') + expect(exposed.announce()).toMatch(/Voorlopig.*\+10/) // 2 × SNAP_MIN + + // Commit + dispatch(exposed.rootEl(), 'Enter') + expect(callbacks.nudge).toHaveBeenCalledTimes(1) + expect(callbacks.nudge.mock.calls[0][1]).toBe(10) + expect(exposed.announce()).toMatch(/bevestigd/i) + }) + + it('Esc cancels keyboard drag without invoking the mutation', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ' ') + dispatch(exposed.rootEl(), 'ArrowRight') + dispatch(exposed.rootEl(), 'Escape') + + expect(callbacks.nudge).not.toHaveBeenCalled() + expect(exposed.announce()).toMatch(/geannuleerd/i) + }) + + it('keys do nothing when no performance is selected', () => { + const { wrapper, callbacks, selectedId } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + selectedId.value = null + + dispatch(exposed.rootEl(), 'ArrowLeft') + dispatch(exposed.rootEl(), 'Enter') + dispatch(exposed.rootEl(), 'Delete') + + expect(callbacks.nudge).not.toHaveBeenCalled() + expect(callbacks.openPopover).not.toHaveBeenCalled() + expect(callbacks.remove).not.toHaveBeenCalled() + }) +}) diff --git a/apps/app/tests/component/AddPerformanceDialog.test.ts b/apps/app/tests/component/AddPerformanceDialog.test.ts new file mode 100644 index 00000000..da938d99 --- /dev/null +++ b/apps/app/tests/component/AddPerformanceDialog.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue' +import { apiClient } from '@/lib/axios' +import { + type ArtistEngagement, + ArtistEngagementStatus, + type Stage, +} from '@/types/timetable' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +const engagement: Partial = { + id: 'e1', + artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'], + booking_status: { value: ArtistEngagementStatus.REQUESTED, label: 'Aangevraagd' }, +} + +// VDialog teleports to body, which puts content outside the wrapper. Stub it +// so the form renders inline. App* wrappers stubbed too — we don't want to +// drive Flatpickr / VAutocomplete plumbing here; we want to assert that the +// schema validation + submit pipeline does the right thing. +const VDialogStub = defineComponent({ + name: 'VDialog', + props: ['modelValue'], + setup(_, { slots }) { + return () => h('div', { class: 'v-dialog-stub' }, slots.default?.()) + }, +}) + +interface AppFieldProps { + modelValue?: unknown + label?: string + errorMessages?: string +} + +function makeAppFieldStub(name: string) { + return defineComponent({ + name, + props: ['modelValue', 'label', 'errorMessages'], + setup(props: AppFieldProps) { + return () => h('div', { 'class': `${name}-stub`, 'data-label': props.label }, [ + props.errorMessages ? h('span', { 'class': 'error', 'data-test': `error-${String(props.label).toLowerCase()}` }, String(props.errorMessages)) : null, + ]) + }, + }) +} + +const appStubs = { + VDialog: VDialogStub, + AppTextField: makeAppFieldStub('AppTextField'), + AppTextarea: makeAppFieldStub('AppTextarea'), + AppSelect: makeAppFieldStub('AppSelect'), + AppAutocomplete: makeAppFieldStub('AppAutocomplete'), + AppDateTimePicker: makeAppFieldStub('AppDateTimePicker'), +} + +interface ExposedShape { + form: { value: { engagement_id: string; stage_id: string | null; start_at: string; end_at: string; lane: number; notes: string } } + errors: { value: Record } + submit: () => Promise +} + +function getExposed(wrapper: { vm: object }): ExposedShape { + // VTU2 surfaces defineExpose() entries on `wrapper.vm.$.exposed`. + return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed +} + +describe('AddPerformanceDialog — validation + submit', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.clearAllMocks()) + + function mountDialog() { + return mountWithVuexy(AddPerformanceDialog, { + props: { + modelValue: true, + orgId: 'org_1', + eventId: 'ev_1', + dayId: 'day_1', + stages: [stage], + engagements: [engagement as ArtistEngagement], + }, + stubs: appStubs, + }) + } + + it('calls createPerformance mutation with the validated payload on happy path', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev_1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 0, + notes: null, + warnings: [], + created_at: '2026-07-10T18:00:00.000Z', + updated_at: '2026-07-10T18:00:00.000Z', + deleted_at: null, + }, + }, + }) + + const { wrapper } = mountDialog() + const exposed = getExposed(wrapper) + + exposed.form.value.engagement_id = 'e1' + exposed.form.value.stage_id = 's1' + exposed.form.value.start_at = '2026-07-10 18:00:00' + exposed.form.value.end_at = '2026-07-10 19:00:00' + exposed.form.value.lane = 0 + exposed.form.value.notes = '' + + await exposed.submit() + + expect(mocked.post).toHaveBeenCalledTimes(1) + + const [url, body] = mocked.post.mock.calls[0] + + expect(url).toContain('/events/ev_1/performances') + expect(body).toMatchObject({ + engagement_id: 'e1', + event_id: 'day_1', + stage_id: 's1', + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + }) + }) + + it('blocks submit when end_at is before start_at and surfaces an error on the field', async () => { + const { wrapper } = mountDialog() + const exposed = getExposed(wrapper) + + exposed.form.value.engagement_id = 'e1' + exposed.form.value.stage_id = 's1' + exposed.form.value.start_at = '2026-07-10 19:00:00' + exposed.form.value.end_at = '2026-07-10 18:00:00' + + await exposed.submit() + + expect(mocked.post).not.toHaveBeenCalled() + expect(exposed.errors.value.end_at).toMatch(/Eindtijd/) + }) + + it('blocks submit when engagement_id is empty', async () => { + const { wrapper } = mountDialog() + const exposed = getExposed(wrapper) + + exposed.form.value.engagement_id = '' + exposed.form.value.stage_id = 's1' + exposed.form.value.start_at = '2026-07-10 18:00:00' + exposed.form.value.end_at = '2026-07-10 19:00:00' + + await exposed.submit() + + expect(mocked.post).not.toHaveBeenCalled() + expect(exposed.errors.value.engagement_id).toBeTruthy() + }) + + it('emits update:modelValue=false when the cancel button is clicked', async () => { + const { wrapper } = mountDialog() + + const cancelBtn = wrapper.findAll('button').find(b => b.text().includes('Annuleer')) + + expect(cancelBtn).toBeDefined() + await cancelBtn!.trigger('click') + + const closeEvents = (wrapper.emitted('update:modelValue') ?? []).filter(e => e[0] === false) + + expect(closeEvents.length).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/apps/app/tests/component/EventTabsNav.test.ts b/apps/app/tests/component/EventTabsNav.test.ts new file mode 100644 index 00000000..d2316c7c --- /dev/null +++ b/apps/app/tests/component/EventTabsNav.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { computed, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import EventTabsNav from '@/components/events/EventTabsNav.vue' + +/** + * Programma tab smoke (Session 4 follow-up): asserts the timetable + * navigation entry is present, labelled, iconed, and routed correctly + * — both for flat events and for festivals (which re-order the tabs). + * + * useEvents queries are mocked so the component skips its skeleton/error + * branches and renders the real tabs immediately. useTransitionEventStatus + * is mocked too — the header status menu is out of scope here. + */ + +interface MinimalEvent { + id: string + organisation_id: string + name: string + slug: string + status: string + start_date: string + end_date: string + event_type: 'event' | 'festival' | 'series' + event_type_label: string + is_festival: boolean + is_sub_event: boolean + parent_event_id: string | null + parent: null + sub_event_label: string | null + children_count: number + allowed_transitions: string[] +} + +let eventFixtureCurrent: MinimalEvent + +function eventFixture(overrides: Partial = {}): MinimalEvent { + return { + id: 'ev_1', + organisation_id: 'org_1', + name: 'Test Event', + slug: 'test-event', + status: 'draft', + start_date: '2026-07-10', + end_date: '2026-07-12', + event_type: 'event', + event_type_label: 'Evenement', + is_festival: false, + is_sub_event: false, + parent_event_id: null, + parent: null, + sub_event_label: null, + children_count: 0, + allowed_transitions: [], + ...overrides, + } +} + +vi.mock('@/composables/api/useEvents', () => ({ + useEventDetail: () => ({ + data: computed(() => eventFixtureCurrent), + isLoading: ref(false), + isError: ref(false), + refetch: vi.fn(), + }), + useEventChildren: () => ({ + data: computed(() => [] as MinimalEvent[]), + isLoading: ref(false), + isError: ref(false), + }), + useTransitionEventStatus: () => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), + + // EditEventDialog is rendered as a child component; it consults + // useUpdateEvent + useUploadEventImage on setup. Mock both so they + // return safe no-op shapes. + useUpdateEvent: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), + useUploadEventImage: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), +})) + +const eventsRoutes = [ + { path: '/events/:id', name: 'events-id', component: { template: '
' } }, + { path: '/events/:id/persons', name: 'events-id-persons', component: { template: '
' } }, + { path: '/events/:id/crowd-lists', name: 'events-id-crowd-lists', component: { template: '
' } }, + { path: '/events/:id/time-slots', name: 'events-id-time-slots', component: { template: '
' } }, + { path: '/events/:id/sections', name: 'events-id-sections', component: { template: '
' } }, + { path: '/events/:id/artists', name: 'events-id-artists', component: { template: '
' } }, + { path: '/events/:id/briefings', name: 'events-id-briefings', component: { template: '
' } }, + { path: '/events/:id/timetable', name: 'events-id-timetable', component: { template: '
' } }, + { path: '/events/:id/settings', name: 'events-id-settings', component: { template: '
' } }, + { path: '/events/:id/programmaonderdelen', name: 'events-id-programmaonderdelen', component: { template: '
' } }, + { path: '/events', name: 'events', component: { template: '
' } }, + { path: '/', name: 'home', component: { template: '
' } }, +] + +async function mountTabs(event: MinimalEvent) { + eventFixtureCurrent = event + + const result = mountWithVuexy(EventTabsNav, { + routes: eventsRoutes, + initialPath: `/events/${event.id}`, + initialState: { + auth: { currentOrganisation: { id: 'org_1', name: 'Org' } }, + }, + }) + + await (result.wrapper as unknown as { __routerReady: Promise }).__routerReady + await result.wrapper.vm.$nextTick() + await result.wrapper.vm.$nextTick() + + return result +} + +describe('EventTabsNav — Programma tab', () => { + it('renders a tab labeled "Programma"', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const tabs = wrapper.findAll('.v-tab') + const labels = tabs.map(t => t.text()) + + expect(labels).toContain('Programma') + }) + + it('uses the tabler-calendar-time icon on the Programma tab', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const programmaTab = wrapper.findAll('.v-tab').find(t => t.text() === 'Programma') + + expect(programmaTab).toBeDefined() + expect(programmaTab!.html()).toContain('tabler-calendar-time') + }) + + it('the Programma tab targets the events-id-timetable route', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const programmaTab = wrapper.findAll('.v-tab').find(t => t.text() === 'Programma') + + expect(programmaTab).toBeDefined() + + // Two independent proofs that the tab is wired to the right route: + // - the rendered VTab `value` attribute equals the route name + // - the resolved href (with whatever id is in the test route) ends in /timetable + expect(programmaTab!.attributes('value')).toBe('events-id-timetable') + expect(programmaTab!.html()).toMatch(/href="\/events\/[^/"]+\/timetable"/) + }) + + it('also exposes the Programma tab on a festival (re-ordered tabs)', async () => { + const { wrapper } = await mountTabs(eventFixture({ + is_festival: true, + event_type: 'festival', + sub_event_label: 'dag', + })) + + const labels = wrapper.findAll('.v-tab').map(t => t.text()) + + expect(labels).toContain('Programma') + }) +}) diff --git a/apps/app/tests/component/PerformanceBlock.test.ts b/apps/app/tests/component/PerformanceBlock.test.ts new file mode 100644 index 00000000..02754202 --- /dev/null +++ b/apps/app/tests/component/PerformanceBlock.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function makePerformance(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + engagement: { + id: 'e1', + organisation_id: 'o1', + artist_id: 'a1', + event_id: 'ev1', + booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, + project_leader_id: null, + fee_amount: 1000, + fee_currency: 'EUR', + fee_type: { value: null, label: null }, + buma_applicable: true, + buma_percentage: 7, + buma_handled_by: { value: null, label: null }, + vat_applicable: true, + vat_percentage: 21, + deal_breakdown: null, + deposit_percentage: null, + deposit_due_date: null, + balance_due_date: null, + payment_status: { value: null, label: null }, + crew_count: 0, + guests_count: 0, + requested_at: null, + option_expires_at: null, + advance_open_from: null, + advance_open_to: null, + advancing_completed_count: 3, + advancing_total_count: 5, + notes: null, + computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 }, + created_at: null, + updated_at: null, + deleted_at: null, + artist: { + id: 'a1', + organisation_id: 'o1', + name: 'Devin Wild', + slug: 'devin-wild', + default_genre_id: null, + default_draw: null, + star_rating: null, + home_base_country: null, + agent_company_id: null, + notes: null, + engagements_summary: { lifetime_count: 1, upcoming_count: 1 }, + created_at: null, + updated_at: null, + deleted_at: null, + }, + }, + stage: { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, + }, + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +const baseProps = { + leftPx: 0, + widthPx: 200, + topPx: 0, + heightPx: 44, +} + +describe('PerformanceBlock — visual states', () => { + it.each([ + [ArtistEngagementStatus.OPTION, 'tt-perf-block--status-option'], + [ArtistEngagementStatus.CONFIRMED, 'tt-perf-block--status-confirmed'], + [ArtistEngagementStatus.CANCELLED, 'tt-perf-block--status-cancelled'], + ])('renders status %s with the matching CSS-token class', (status, expectedClass) => { + const perf = makePerformance({ + engagement: { + ...makePerformance().engagement!, + booking_status: { value: status, label: 'X' }, + }, + }) + + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } }) + + const block = wrapper.find('[data-perf-id="p1"]') + + expect(block.classes()).toContain(expectedClass) + expect(block.attributes('data-status')).toBe(status) + + // CSS variable resolves via the loaded token sheet — proves the class + // truly maps to the CSS custom property. + const cssVarName = `--tt-status-${status}-bg` + const value = getComputedStyle(document.documentElement).getPropertyValue(cssVarName).trim() + + expect(value.length).toBeGreaterThan(0) + }) + + it('renders the capacity icon when crew + guests > stage.capacity', () => { + const perf = makePerformance({ + engagement: { + ...makePerformance().engagement!, + crew_count: 600, + guests_count: 600, + }, + }) + + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(true) + }) + + it('omits the capacity icon when sum ≤ capacity', () => { + const perf = makePerformance({ + engagement: { + ...makePerformance().engagement!, + crew_count: 100, + guests_count: 100, + }, + }) + + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(false) + }) + + it('omits capacity icon when stage.capacity is null', () => { + const perf = makePerformance({ + stage: { ...makePerformance().stage!, capacity: null }, + engagement: { + ...makePerformance().engagement!, + crew_count: 5000, + guests_count: 5000, + }, + }) + + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(false) + }) + + it('renders B2B left dot when b2bLeft prop true', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), b2bLeft: true, ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__b2b--left').exists()).toBe(true) + expect(wrapper.find('.tt-perf-block__b2b--right').exists()).toBe(false) + }) + + it('renders B2B right dot when b2bRight prop true', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), b2bRight: true, ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__b2b--right').exists()).toBe(true) + expect(wrapper.find('.tt-perf-block__b2b--left').exists()).toBe(false) + }) + + it('renders neither dot when both props false', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + expect(wrapper.find('.tt-perf-block__b2b').exists()).toBe(false) + }) + + it('applies conflict ring when warnings include "overlap"', () => { + const perf = makePerformance({ warnings: ['overlap'] }) + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } }) + + expect(wrapper.find('[data-perf-id="p1"]').classes()).toContain('tt-perf-block--conflict') + }) + + it('applies cascade-pulse class when pulse=true', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), pulse: true, ...baseProps } }) + + expect(wrapper.find('[data-perf-id="p1"]').classes()).toContain('tt-cascade-pulse') + }) + + it('aria-label includes artist, time window, stage, status', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + const label = wrapper.find('[data-perf-id="p1"]').attributes('aria-label') ?? '' + + expect(label).toContain('Devin Wild') + expect(label).toContain('Hardstyle District') + expect(label).toContain('Bevestigd') + expect(label).toMatch(/\d{2}:\d{2}/) // time formatted + }) + + it('exposes tabindex=0 for keyboard focus', () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + expect(wrapper.find('[data-perf-id="p1"]').attributes('tabindex')).toBe('0') + }) +}) + +describe('PerformanceBlock — interactions', () => { + it('emits select on click with the performance + DOMRect', async () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + await wrapper.find('[data-perf-id="p1"]').trigger('click') + + const events = wrapper.emitted('select') + + expect(events).toHaveLength(1) + expect(events![0][0]).toMatchObject({ id: 'p1' }) + }) + + it('emits pointerdown with (event, performance) on pointerdown', async () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + await wrapper.find('[data-perf-id="p1"]').trigger('pointerdown') + + const events = wrapper.emitted('pointerdown') + + expect(events).toHaveLength(1) + expect(events![0][1]).toMatchObject({ id: 'p1' }) + }) + + it('emits delete on Delete keypress', async () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + await wrapper.find('[data-perf-id="p1"]').trigger('keydown', { key: 'Delete' }) + + expect(wrapper.emitted('delete')).toHaveLength(1) + }) + + it('emits select on Enter keypress', async () => { + const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } }) + + await wrapper.find('[data-perf-id="p1"]').trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('select')).toHaveLength(1) + }) +}) diff --git a/apps/app/tests/component/StageRow.test.ts b/apps/app/tests/component/StageRow.test.ts new file mode 100644 index 00000000..ddaf1ef9 --- /dev/null +++ b/apps/app/tests/component/StageRow.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import StageRow from '@/components/timetable/StageRow.vue' +import { ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +function perf(id: string, lane_resolved: number, start: string, end: string): Performance { + return { + id, + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: lane_resolved, + lane_resolved, + start_at: start, + end_at: end, + version: 1, + notes: null, + warnings: [], + engagement: { + booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, + } as Performance['engagement'], + stage, + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +const baseProps = { + stage, + gridStartIso: '2026-07-10T18:00:00.000Z', + totalMinutes: 13 * 60, + pxPerMin: 2, + b2bLeftSet: new Set(), + b2bRightSet: new Set(), + pulseSet: new Set(), + selectedId: null, + dragOriginId: null, +} + +describe('StageRow — lane stacking + rendering', () => { + it('renders one PerformanceBlock per performance', () => { + const { wrapper } = mountWithVuexy(StageRow, { + props: { + ...baseProps, + performances: [ + perf('a', 0, '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z'), + perf('b', 0, '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ], + laneCount: 1, + }, + }) + + expect(wrapper.findAll('[data-perf-id]')).toHaveLength(2) + }) + + it('stacks performances by lane_resolved (different lanes = different topPx)', () => { + const { wrapper } = mountWithVuexy(StageRow, { + props: { + ...baseProps, + performances: [ + perf('a', 0, '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z'), + perf('b', 1, '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ], + laneCount: 2, + }, + }) + + const blockA = wrapper.find('[data-perf-id="a"]') + const blockB = wrapper.find('[data-perf-id="b"]') + + const topA = Number(/inset-block-start: (\d+)px/.exec(blockA.attributes('style') ?? '')?.[1] ?? -1) + const topB = Number(/inset-block-start: (\d+)px/.exec(blockB.attributes('style') ?? '')?.[1] ?? -1) + + expect(topA).toBeGreaterThanOrEqual(0) + expect(topB).toBeGreaterThan(topA) + }) + + it('renders zero blocks for an empty stage row', () => { + const { wrapper } = mountWithVuexy(StageRow, { + props: { + ...baseProps, + performances: [], + laneCount: 1, + }, + }) + + expect(wrapper.findAll('[data-perf-id]')).toHaveLength(0) + }) + + it('positions blocks horizontally based on minutes-since-gridStart × pxPerMin', () => { + const { wrapper } = mountWithVuexy(StageRow, { + props: { + ...baseProps, + + // Anchor 18:00; perf starts 19:00 (60 min later) at 2px/min → leftPx=120 + performances: [perf('a', 0, '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z')], + laneCount: 1, + }, + }) + + const block = wrapper.find('[data-perf-id="a"]') + const left = Number(/inset-inline-start: (\d+)px/.exec(block.attributes('style') ?? '')?.[1] ?? -1) + + expect(left).toBe(120) + }) + + it('forwards block-pointerdown events from a child PerformanceBlock', async () => { + const { wrapper } = mountWithVuexy(StageRow, { + props: { + ...baseProps, + performances: [perf('a', 0, '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z')], + laneCount: 1, + }, + }) + + await wrapper.find('[data-perf-id="a"]').trigger('pointerdown') + expect(wrapper.emitted('blockPointerdown')).toHaveLength(1) + }) +}) diff --git a/apps/app/tests/component/Wachtrij.test.ts b/apps/app/tests/component/Wachtrij.test.ts new file mode 100644 index 00000000..aaaf20df --- /dev/null +++ b/apps/app/tests/component/Wachtrij.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import Wachtrij from '@/components/timetable/Wachtrij.vue' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function parked(id: string, name: string, status = ArtistEngagementStatus.REQUESTED): Performance { + return { + id, + engagement_id: `e_${id}`, + event_id: 'ev1', + stage_id: null, + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 1, + notes: null, + warnings: [], + engagement: { + booking_status: { value: status, label: status }, + artist: { name } as Performance['engagement']['artist'], + } as Performance['engagement'], + stage: null, + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +describe('Wachtrij — parked-card list', () => { + it('renders one card per parked performance', () => { + const { wrapper } = mountWithVuexy(Wachtrij, { + props: { + performances: [parked('p1', 'Devin Wild'), parked('p2', 'Da Tweekaz')], + selectedId: null, + }, + }) + + expect(wrapper.findAll('[data-perf-id]')).toHaveLength(2) + }) + + it('renders the empty-state copy when no performances are parked', () => { + const { wrapper } = mountWithVuexy(Wachtrij, { + props: { performances: [], selectedId: null }, + }) + + expect(wrapper.text()).toMatch(/Geen optredens/i) + }) + + it('forwards a card pointerdown as cardPointerdown', async () => { + const { wrapper } = mountWithVuexy(Wachtrij, { + props: { + performances: [parked('p1', 'Devin Wild')], + selectedId: null, + }, + }) + + await wrapper.find('[data-perf-id="p1"]').trigger('pointerdown') + + expect(wrapper.emitted('cardPointerdown')).toHaveLength(1) + }) + + it('forwards a card click as cardSelect with performance + DOMRect', async () => { + const { wrapper } = mountWithVuexy(Wachtrij, { + props: { + performances: [parked('p1', 'Devin Wild')], + selectedId: null, + }, + }) + + await wrapper.find('[data-perf-id="p1"]').trigger('click') + + const events = wrapper.emitted('cardSelect') + + expect(events).toHaveLength(1) + expect(events![0][0]).toMatchObject({ id: 'p1' }) + }) + + it('shows the wachtrij item count badge', () => { + const { wrapper } = mountWithVuexy(Wachtrij, { + props: { + performances: [parked('a', 'A'), parked('b', 'B'), parked('c', 'C')], + selectedId: null, + }, + }) + + expect(wrapper.find('.tt-wachtrij__count').text()).toBe('3') + }) +}) diff --git a/apps/app/tests/component/_smoke.test.ts b/apps/app/tests/component/_smoke.test.ts new file mode 100644 index 00000000..a3056ca1 --- /dev/null +++ b/apps/app/tests/component/_smoke.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { defineComponent, h } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' + +describe('mountWithVuexy harness', () => { + it('mounts a trivial component with the full Vuexy stack', () => { + const Trivial = defineComponent({ + setup() { + return () => h('div', { 'data-test': 'ok' }, 'hello') + }, + }) + + const { wrapper, queryClient, pinia, router, notificationMock } = mountWithVuexy(Trivial) + + expect(wrapper.find('[data-test="ok"]').text()).toBe('hello') + expect(queryClient).toBeDefined() + expect(pinia).toBeDefined() + expect(router).toBeDefined() + expect(notificationMock.show).toBeTypeOf('function') + }) + + it('loads the timetable CSS token sheet so var(--tt-…) resolves on :root', () => { + const Probe = defineComponent({ + setup() { + return () => h('div', { id: 'probe' }) + }, + }) + + mountWithVuexy(Probe) + + // The CSS file is imported at module load time inside mountWithVuexy. + // Resolving against documentElement (=:root) avoids ambiguity around + // jsdom's default style-cascade behaviour on arbitrary elements. + const value = getComputedStyle(document.documentElement).getPropertyValue('--tt-status-confirmed-bg').trim() + + expect(value).toBe('#e8f8f0') + }) +}) diff --git a/apps/app/tests/component/useTimetableMutations.test.ts b/apps/app/tests/component/useTimetableMutations.test.ts new file mode 100644 index 00000000..7f767d06 --- /dev/null +++ b/apps/app/tests/component/useTimetableMutations.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import { apiClient } from '@/lib/axios' +import { useTimetableMutations } from '@/composables/api/useTimetableMutations' +import type { Performance } from '@/types/timetable' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function perf(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev_1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 3, + notes: null, + warnings: [], + created_at: '2026-07-10T18:00:00.000Z', + updated_at: '2026-07-10T18:00:00.000Z', + deleted_at: null, + ...overrides, + } +} + +interface ExposedShape { + api: ReturnType +} + +function mountMutations() { + const Host = defineComponent({ + setup(_, { expose }) { + const api = useTimetableMutations({ + orgId: ref('org_1'), + eventId: ref('ev_1'), + dayId: ref('day_1'), + }) + + expose({ api }) + + return () => h('div') + }, + }) + + return mountWithVuexy(Host) +} + +function getApi(wrapper: { vm: object }): ReturnType { + return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed.api +} + +describe('useTimetableMutations.move — optimistic + 409 + idempotency-key', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.useRealTimers()) + + it('on success: returns the server payload with the bumped version + sends Idempotency-Key', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ version: 4, start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }), + cascaded: [], + }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const result = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-A', + optimistic: perf({ start_at: '2026-07-10T20:00:00.000Z', end_at: '2026-07-10T21:00:00.000Z' }), + }) + + expect(result.moved.version).toBe(4) + expect(mocked.post).toHaveBeenCalledTimes(1) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-A') + }) + + it('409 path: rolls back, surfaces VersionMismatchError, and shows notification', async () => { + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + current_version: 7, + client_version: 3, + server_data: perf({ version: 7 }), + }, + }, + }, + }) + + const { wrapper, notificationMock } = mountMutations() + const api = getApi(wrapper) + + await expect(api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-409', + })).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 7 } }) + + expect(notificationMock.show).toHaveBeenCalledTimes(1) + expect(notificationMock.show).toHaveBeenCalledWith(expect.stringMatching(/zojuist aangepast/i), 'error') + }) + + it('cascade: success with cascaded[] non-empty puts those peers into the cache', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ version: 4 }), + cascaded: [perf({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })], + }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const result = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-cascade', + }) + + expect(result.cascaded).toHaveLength(1) + expect(result.cascaded[0].id).toBe('p2') + }) + + it('Idempotency-Key: each logical move() call sends the exact key the caller supplied', async () => { + mocked.post + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 4 }), cascaded: [] }, + }, + }) + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 5 }), cascaded: [] }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-action-A', + }) + + await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 21:00:00', + target_end_at: '2026-07-10 22:00:00', + target_lane: 0, + version: 4, + }, + idempotencyKey: 'idem-action-B', + }) + + expect(mocked.post).toHaveBeenCalledTimes(2) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe('idem-action-A') + expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe('idem-action-B') + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']) + .not.toBe(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']) + }) + + it('Idempotency-Key: explicit retry within the same logical action reuses the same key', async () => { + // Caller-controlled retry: the page catches a transient network error + // and re-invokes move() with the SAME idempotencyKey it generated for + // that drag. That call must carry the same header on the wire so the + // backend dedupes it. + mocked.post + .mockRejectedValueOnce({ message: 'Network down', response: undefined }) + .mockResolvedValueOnce({ + data: { + success: true, + data: { moved: perf({ version: 4 }), cascaded: [] }, + }, + }) + + const { wrapper } = mountMutations() + const api = getApi(wrapper) + + const sameKey = 'idem-drag-XYZ' + + const payload = { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 20:00:00', + target_end_at: '2026-07-10 21:00:00', + target_lane: 0, + version: 3, + } as const + + await expect(api.move.mutateAsync({ payload, idempotencyKey: sameKey })).rejects.toBeTruthy() + await api.move.mutateAsync({ payload, idempotencyKey: sameKey }) + + expect(mocked.post).toHaveBeenCalledTimes(2) + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBe(sameKey) + expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe(sameKey) + }) +}) diff --git a/apps/app/tests/integration/timetable-flow.test.ts b/apps/app/tests/integration/timetable-flow.test.ts new file mode 100644 index 00000000..1d2b6113 --- /dev/null +++ b/apps/app/tests/integration/timetable-flow.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import { apiClient } from '@/lib/axios' +import { useTimetableMutations } from '@/composables/api/useTimetableMutations' +import { type Performance } from '@/types/timetable' + +/** + * Full add → move → resize → park → delete lifecycle, exercised through + * the mutation composable + TanStack cache to validate the wire format + * and cache transitions end-to-end. + * + * Why not the full page: mounting events/[id]/timetable/index.vue in + * jsdom requires EventTabsNav, useEventDetail, useEventChildren, all + * VTabs/VBtn/VDialog teleports — too fragile to be load-bearing in CI. + * That's exactly the gap TEST-INFRA-001 fills with Playwright CT. + * + * What this test guarantees: + * - POST /performances + Idempotency-Key on add + * - POST /timetable/move with the right payload on drag + * - POST /timetable/move with stage_id=null on park + * - cascaded[] sibling appears in the resolved Promise on a cascade + * - DELETE /performances/{id} on delete + * - the mutation composable's cache patching keeps the day cache and + * wachtrij cache in sync across the whole flow + */ + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} +const mocked = apiClient as unknown as MockApi + +function perf(overrides: Partial = {}): Performance { + // Engagement intentionally omitted — the Zod schema treats it as optional + // and a partial object would fail parse. Tests that care about engagement + // fields hand in a fully-shaped one via overrides. + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev_1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + created_at: '2026-07-10T18:00:00.000Z', + updated_at: '2026-07-10T18:00:00.000Z', + deleted_at: null, + ...overrides, + } +} + +interface ExposedShape { + api: ReturnType +} + +function mountFlow() { + const Host = defineComponent({ + setup(_, { expose }) { + const api = useTimetableMutations({ + orgId: ref('org_1'), + eventId: ref('ev_1'), + dayId: ref('day_1'), + }) + + expose({ api }) + + return () => h('div') + }, + }) + + return mountWithVuexy(Host) +} + +function getApi(wrapper: { vm: object }): ReturnType { + return (wrapper.vm as { $: { exposed: ExposedShape } }).$.exposed.api +} + +describe('Integration flow: add → drag → resize → park → delete', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.clearAllMocks()) + + it('walks a single performance through the entire RFC D17 lifecycle', async () => { + const { wrapper } = mountFlow() + const api = getApi(wrapper) + + // ─── Step 1: ADD a new performance ────────────────────────────────── + mocked.post.mockResolvedValueOnce({ + data: { success: true, data: perf({ id: 'p1', version: 1 }) }, + }) + + const created = await api.create.mutateAsync({ + engagement_id: 'e1', + event_id: 'day_1', + stage_id: 's1', + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + lane: 0, + notes: null, + }) + + expect(created.id).toBe('p1') + expect(mocked.post).toHaveBeenCalledTimes(1) + expect(mocked.post.mock.calls[0][0]).toContain('/events/ev_1/performances') + expect(mocked.post.mock.calls[0][2]?.headers?.['Idempotency-Key']).toBeTruthy() + + // ─── Step 2: DRAG the new block to a different lane ──────────────── + // Simulate a cascaded peer (p2) whose lane bumps from 0 → 1 in the same + // server transaction (RFC D18). + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ id: 'p1', lane: 1, lane_resolved: 1, version: 2 }), + cascaded: [perf({ id: 'p2', lane: 2, lane_resolved: 2, version: 3 })], + }, + }, + }) + + const moveResult = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 18:00:00', + target_end_at: '2026-07-10 19:00:00', + target_lane: 1, + version: 1, + }, + idempotencyKey: 'idem-drag-1', + optimistic: perf({ lane: 1, lane_resolved: 1 }), + }) + + expect(moveResult.moved.lane_resolved).toBe(1) + expect(moveResult.cascaded).toHaveLength(1) + expect(moveResult.cascaded[0].id).toBe('p2') + expect(mocked.post.mock.calls[1][0]).toContain('/timetable/move') + expect(mocked.post.mock.calls[1][2]?.headers?.['Idempotency-Key']).toBe('idem-drag-1') + + // ─── Step 3: RESIZE — extend end_at by 30 min via update ────────── + // Resize is implemented as another move(): the prototype audit §4.2 + // and our useTimetableMutations both route placement edits through + // POST /timetable/move so the version bumps + cascade re-runs. + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ id: 'p1', lane: 1, lane_resolved: 1, end_at: '2026-07-10T19:30:00.000Z', version: 3 }), + cascaded: [], + }, + }, + }) + + const resizeResult = await api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 18:00:00', + target_end_at: '2026-07-10 19:30:00', + target_lane: 1, + version: 2, + }, + idempotencyKey: 'idem-resize-1', + }) + + expect(resizeResult.moved.end_at).toBe('2026-07-10T19:30:00.000Z') + expect(resizeResult.moved.version).toBe(3) + + // ─── Step 4: PARK — drag block to wachtrij ───────────────────────── + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: perf({ id: 'p1', stage_id: null, version: 4 }), + cascaded: [], + }, + }, + }) + + const parkResult = await api.park(perf({ id: 'p1', version: 3 }), 'idem-park-1') + + expect(parkResult.moved.stage_id).toBeNull() + + const parkBody = mocked.post.mock.calls[3][1] + + expect(parkBody).toMatchObject({ + performance_id: 'p1', + target_stage_id: null, + target_start_at: null, + target_end_at: null, + target_lane: null, + }) + + // ─── Step 5: DELETE the parked performance ───────────────────────── + mocked.delete.mockResolvedValueOnce({}) + + await api.remove.mutateAsync('p1') + + expect(mocked.delete).toHaveBeenCalledTimes(1) + expect(mocked.delete.mock.calls[0][0]).toContain('/performances/p1') + + // ─── Wire summary: 4 POSTs (create + drag + resize + park) + 1 DELETE + expect(mocked.post).toHaveBeenCalledTimes(4) + expect(mocked.delete).toHaveBeenCalledTimes(1) + }) + + it('rolls back optimistic drag when the server returns 409', async () => { + const { wrapper, notificationMock } = mountFlow() + const api = getApi(wrapper) + + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + current_version: 9, + client_version: 1, + server_data: perf({ version: 9 }), + }, + }, + }, + }) + + await expect(api.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 18:00:00', + target_end_at: '2026-07-10 19:00:00', + target_lane: 1, + version: 1, + }, + idempotencyKey: 'idem-drag-409', + optimistic: perf({ lane: 1 }), + })).rejects.toMatchObject({ status: 409 }) + + expect(notificationMock.show).toHaveBeenCalledWith(expect.stringMatching(/zojuist aangepast/i), 'error') + }) +}) diff --git a/apps/app/tests/setup.component.ts b/apps/app/tests/setup.component.ts new file mode 100644 index 00000000..f68e9754 --- /dev/null +++ b/apps/app/tests/setup.component.ts @@ -0,0 +1,55 @@ +import 'vitest-axe/extend-expect' +import { expect } from 'vitest' +import * as matchers from 'vitest-axe/matchers' + +// Register vitest-axe's `toHaveNoViolations` matcher so a11y tests can call +// `expect(await axe(node)).toHaveNoViolations()`. +expect.extend(matchers) + +// Deterministic crypto polyfill (mirrors tests/setup.ts) so generateIdempotencyKey() +// returns a stable value across component-test runs without bringing in the +// router mock from tests/setup.ts. +if (!globalThis.crypto) { + ;(globalThis as { crypto: Crypto }).crypto = { + randomUUID: () => '00000000-0000-4000-8000-000000000000', + getRandomValues: (buf: Uint8Array) => { + for (let i = 0; i < buf.length; i++) buf[i] = 0 + + return buf + }, + } as unknown as Crypto +} + +// JSDOM's `Element.scrollIntoView` is not implemented by default; Vuetify's +// list/menu components call it during opening transitions. Stub it so the +// test environment doesn't throw. +if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) + Element.prototype.scrollIntoView = () => undefined + +// JSDOM's `getBoundingClientRect` returns zeros, which is fine for most +// assertions but breaks Vuetify positioning math in some menus. Provide a +// minimal viewport size on document body so anchored components can render. +if (typeof document !== 'undefined') { + Object.defineProperty(document.body, 'getBoundingClientRect', { + configurable: true, + value: () => ({ top: 0, left: 0, right: 1024, bottom: 768, width: 1024, height: 768, x: 0, y: 0, toJSON: () => ({}) }), + }) +} + +// `window.visualViewport` is consulted by Vuetify; happy-dom has it but +// jsdom does not. Stub the minimum surface the lib reads. +if (typeof window !== 'undefined' && !window.visualViewport) { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { width: 1024, height: 768, offsetLeft: 0, offsetTop: 0, scale: 1, addEventListener: () => undefined, removeEventListener: () => undefined }, + }) +} + +// `ResizeObserver` is required by Vuetify VOverlay and friends; jsdom lacks it. +if (typeof globalThis.ResizeObserver === 'undefined') { + ;(globalThis as { ResizeObserver: unknown }).ResizeObserver = class ResizeObserver { + observe(): void { /* noop */ } + unobserve(): void { /* noop */ } + disconnect(): void { /* noop */ } + } +} diff --git a/apps/app/tests/unit/composables/api/zodParseFailure.test.ts b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts new file mode 100644 index 00000000..610380a5 --- /dev/null +++ b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts @@ -0,0 +1,133 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { ZodError } from 'zod' +import { apiClient } from '@/lib/axios' +import { useTimetableMutations } from '@/composables/api/useTimetableMutations' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function mountWithMutations() { + const api: { value: ReturnType | null } = { value: null } + const orgId = ref('org_1') + const eventId = ref('ev_1') + const dayId = ref('day_1') + + const Host = defineComponent({ + setup() { + api.value = useTimetableMutations({ orgId, eventId, dayId }) + + return () => h('div') + }, + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const pinia = createPinia() + + mount(Host, { global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] } }) + + return { api } +} + +describe('Zod parse failure on API responses', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('move() throws a ZodError when the success payload omits required fields', async () => { + // Backend renamed `cascaded` → `cascadedItems`, or removed `version`, etc. + // Whatever the drift, our Zod schema must reject it loudly so GlitchTip + // sees a contract violation instead of silently coercing into runtime + // crashes deep in components that read `.lane_resolved`. + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: { id: 'p1' /* missing nearly every required field */ }, + cascaded: [], + }, + }, + }) + + const { api } = mountWithMutations() + + await expect(api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-test', + })).rejects.toBeInstanceOf(ZodError) + }) + + it('move() 409 with malformed errors payload also throws a ZodError', async () => { + // The 409 path parses err.response.data.errors against + // moveTimetableConflictSchema. A drift in the conflict shape (e.g. + // backend renames `current_version` → `currentVersion`) must surface as + // a ZodError, not as a "missing field" ReferenceError downstream. + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + + // current_version + client_version + server_data are missing + }, + }, + }, + }) + + const { api } = mountWithMutations() + + await expect(api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-test', + })).rejects.toBeInstanceOf(ZodError) + }) + + it('createStage() throws a ZodError when response is malformed', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { id: 's2', name: 'X' /* missing color, sort_order, etc. */ }, + }, + }) + + const { api } = mountWithMutations() + + await expect( + api.value!.createStage.mutateAsync({ name: 'X', color: '#aabbcc' }), + ).rejects.toBeInstanceOf(ZodError) + }) +}) diff --git a/apps/app/tests/unit/composables/useDragOrClick.test.ts b/apps/app/tests/unit/composables/useDragOrClick.test.ts new file mode 100644 index 00000000..4c023352 --- /dev/null +++ b/apps/app/tests/unit/composables/useDragOrClick.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { useDragOrClick } from '@/composables/timetable/useDragOrClick' + +interface ComponentInstance { + begin: (e: Event) => void +} + +function makeHost(opts: Parameters[0]) { + return defineComponent({ + setup(_, { expose }) { + const ctl = useDragOrClick(opts) + + expose({ begin: ctl.begin }) + + return () => h('div') + }, + }) +} + +function makePointerEvent(type: string, x: number, y: number, pointerId = 1): PointerEvent { + const e = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent + + Object.defineProperty(e, 'pointerId', { value: pointerId }) + Object.defineProperty(e, 'clientX', { value: x }) + Object.defineProperty(e, 'clientY', { value: y }) + + return e +} + +describe('useDragOrClick', () => { + it('fires onClick when movement < threshold', async () => { + const onClick = vi.fn() + const onDragStart = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onClick, onDragStart })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 11, 11)) + window.dispatchEvent(makePointerEvent('pointerup', 11, 11)) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDragStart).not.toHaveBeenCalled() + }) + + it('enters drag mode and emits onDragStart + onDragEnd when movement crosses threshold', async () => { + const onClick = vi.fn() + const onDragStart = vi.fn() + const onDragEnd = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onClick, onDragStart, onDragEnd })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 50, 10)) + window.dispatchEvent(makePointerEvent('pointerup', 50, 10)) + + expect(onDragStart).toHaveBeenCalledTimes(1) + expect(onDragEnd).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + + it('Esc cancels an in-flight drag', async () => { + const onDragEnd = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onDragEnd })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 50, 10)) + + const esc = new KeyboardEvent('keydown', { key: 'Escape' }) + + window.dispatchEvent(esc) + + expect(onDragEnd).toHaveBeenCalledWith(expect.anything(), true) + }) +}) diff --git a/apps/app/tests/unit/composables/useTimetableMutations.test.ts b/apps/app/tests/unit/composables/useTimetableMutations.test.ts new file mode 100644 index 00000000..6385a674 --- /dev/null +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -0,0 +1,216 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { useTimetableMutations } from '@/composables/api/useTimetableMutations' +import type { Performance } from '@/types/timetable' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function p(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 3, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +function mountWithMutations() { + const api: { value: ReturnType | null } = { value: null } + const orgId = ref('org_1') + const eventId = ref('ev_1') + const dayId = ref('day_1') + + const Host = defineComponent({ + setup() { + api.value = useTimetableMutations({ orgId, eventId, dayId }) + + return () => h('div') + }, + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const pinia = createPinia() + + const wrapper = mount(Host, { + global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] }, + }) + + return { wrapper, api, queryClient } +} + +describe('useTimetableMutations', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('move', () => { + it('sends Idempotency-Key header on POST /timetable/move', async () => { + mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ version: 4 }), cascaded: [] } } }) + + const { api } = mountWithMutations() + + await api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-test-key-12345', + }) + + expect(mocked.post).toHaveBeenCalledTimes(1) + + const [, , config] = mocked.post.mock.calls[0] + + expect(config.headers['Idempotency-Key']).toBe('idem-test-key-12345') + }) + + it('applies optimistic patch + cascade on success', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: p({ id: 'p1', version: 4 }), + cascaded: [p({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })], + }, + }, + }) + + const { api, queryClient } = mountWithMutations() + const eventId = ref('ev_1') + const dayId = ref('day_1') + + queryClient.setQueryData(['timetable', 'performances', eventId, dayId], [ + p({ id: 'p1' }), + p({ id: 'p2', lane: 0, lane_resolved: 0 }), + ]) + + const result = await api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem', + }) + + expect(result.cascaded).toHaveLength(1) + expect(result.moved.version).toBe(4) + }) + + it('surfaces VersionMismatch on 409', async () => { + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + current_version: 5, + client_version: 3, + server_data: p({ version: 5 }), + }, + }, + }, + }) + + const { api } = mountWithMutations() + + await expect(api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem', + })).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 5 } }) + }) + }) + + describe('park / unpark via move', () => { + it('park sends target_stage_id null', async () => { + mocked.post.mockResolvedValueOnce({ + data: { success: true, data: { moved: p({ stage_id: null, version: 4 }), cascaded: [] } }, + }) + + const { api } = mountWithMutations() + + await api.value!.park(p(), 'key1') + + const [, body] = mocked.post.mock.calls[0] + + expect(body.target_stage_id).toBe(null) + expect(body.version).toBe(3) + }) + }) + + describe('createStage', () => { + it('hits POST /stages', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + id: 's2', + name: 'New Stage', + color: '#aabbcc', + capacity: 1000, + sort_order: 1, + event_id: 'ev_1', + created_at: '2026-07-10T18:00:00.000Z', + updated_at: '2026-07-10T18:00:00.000Z', + }, + }, + }) + + const { api } = mountWithMutations() + + await api.value!.createStage.mutateAsync({ name: 'New Stage', color: '#aabbcc', capacity: 1000 }) + + expect(mocked.post.mock.calls[0][0]).toContain('/stages') + }) + }) +}) diff --git a/apps/app/tests/unit/lib/idempotencyKey.test.ts b/apps/app/tests/unit/lib/idempotencyKey.test.ts new file mode 100644 index 00000000..ce43b161 --- /dev/null +++ b/apps/app/tests/unit/lib/idempotencyKey.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' + +describe('generateIdempotencyKey', () => { + it('returns a string within the backend 6..30 char range', () => { + const key = generateIdempotencyKey() + + expect(key.length).toBeGreaterThanOrEqual(6) + expect(key.length).toBeLessThanOrEqual(30) + }) + + it('produces 24-hex output when crypto.randomUUID is available', () => { + const key = generateIdempotencyKey() + + expect(key).toMatch(/^[0-9a-f]{24}$/) + }) + + it('successive calls return different values (very high probability)', () => { + const a = generateIdempotencyKey() + const b = generateIdempotencyKey() + + expect(a).not.toBe(b) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/b2b.test.ts b/apps/app/tests/unit/lib/timetable/b2b.test.ts new file mode 100644 index 00000000..1026ab6a --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/b2b.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { B2B_THRESHOLD_MIN, findB2BLinks, findB2BSides } from '@/lib/timetable/b2b' +import type { Performance } from '@/types/timetable' + +function p(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +describe('findB2BLinks', () => { + it('returns empty when no consecutive pair exists', () => { + expect(findB2BLinks([p({ id: 'a' })])).toEqual([]) + }) + + it('marks 0-min gap as B2B', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(1) + expect(links[0]).toEqual({ leftId: 'a', rightId: 'b', gapMin: 0 }) + }) + + it('marks 2:59 gap as B2B (under 3-min threshold)', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:02:59Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(1) + }) + + it('does NOT mark 3:01 gap as B2B', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:03:01Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(0) + }) + + it('overlap (negative gap) is NOT a B2B link', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }), + ]) + + expect(links).toHaveLength(0) + }) + + it('threshold constant is 3 minutes', () => { + expect(B2B_THRESHOLD_MIN).toBe(3) + }) +}) + +describe('findB2BSides', () => { + it('produces left+right sets reflecting neighbour position', () => { + const { leftSet, rightSet } = findB2BSides([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(rightSet.has('a')).toBe(true) + expect(leftSet.has('b')).toBe(true) + expect(leftSet.has('a')).toBe(false) + expect(rightSet.has('b')).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/capacity.test.ts b/apps/app/tests/unit/lib/timetable/capacity.test.ts new file mode 100644 index 00000000..7d15c581 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/capacity.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { CAPACITY_TOLERANCE, evaluateCapacity } from '@/lib/timetable/capacity' +import type { ArtistEngagement, Performance, Stage } from '@/types/timetable' + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle', + color: '#ff0000', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +const perf: Performance = { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, +} + +function eng(crew: number, guests: number, draw: number | null = null): ArtistEngagement { + return { + crew_count: crew, + guests_count: guests, + artist: draw === null ? undefined : { default_draw: draw } as ArtistEngagement['artist'], + } as ArtistEngagement +} + +describe('evaluateCapacity', () => { + it('returns null when stage has no capacity', () => { + expect(evaluateCapacity(perf, { ...stage, capacity: null }, eng(0, 0, 500))).toBeNull() + }) + + it('returns null when no expected attendance is available', () => { + expect(evaluateCapacity(perf, stage, eng(0, 0))).toBeNull() + }) + + it('returns null when below the tolerance', () => { + expect(evaluateCapacity(perf, stage, eng(0, 0, 1100))).toBeNull() + }) + + it('returns warn when ratio between tolerance and 1.5×', () => { + const result = evaluateCapacity(perf, stage, eng(0, 0, 1200)) + + expect(result?.level).toBe('warn') + }) + + it('returns critical when ratio > 1.5', () => { + const result = evaluateCapacity(perf, stage, eng(0, 0, 1700)) + + expect(result?.level).toBe('critical') + }) + + it('prefers crew + guests when present', () => { + const result = evaluateCapacity(perf, stage, eng(800, 800)) + + expect(result?.expected).toBe(1600) + expect(result?.level).toBe('critical') + }) + + it('exposes the tolerance constant', () => { + expect(CAPACITY_TOLERANCE).toBeGreaterThan(1) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/conflict.test.ts b/apps/app/tests/unit/lib/timetable/conflict.test.ts new file mode 100644 index 00000000..6e1ab456 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/conflict.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { findConflicts, wouldConflict } from '@/lib/timetable/conflict' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function p(overrides: Partial = {}): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +describe('findConflicts', () => { + it('flags two overlapping performances on the same lane', () => { + const conflicts = findConflicts([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }), + ]) + + expect(conflicts).toEqual(new Set(['a', 'b'])) + }) + + it('endpoint-touching is NOT overlap', () => { + const conflicts = findConflicts([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('different lanes on same stage = no conflict', () => { + const conflicts = findConflicts([ + p({ id: 'a', lane_resolved: 0 }), + p({ id: 'b', lane_resolved: 1 }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('different stages = no conflict', () => { + const conflicts = findConflicts([ + p({ id: 'a', stage_id: 's1' }), + p({ id: 'b', stage_id: 's2' }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('cancelled performances do not participate', () => { + const cancelled = p({ + id: 'c', + engagement: { + booking_status: { value: ArtistEngagementStatus.CANCELLED, label: 'Geannuleerd' }, + } as Performance['engagement'], + }) + + const conflicts = findConflicts([ + p({ id: 'a' }), + cancelled, + ]) + + expect(conflicts.size).toBe(0) + }) + + it('parked performances (stage_id null) do not participate', () => { + const conflicts = findConflicts([ + p({ id: 'a', stage_id: null }), + p({ id: 'b' }), + ]) + + expect(conflicts.size).toBe(0) + }) +}) + +describe('wouldConflict', () => { + it('detects 1-pixel overlap', () => { + const others = [p({ id: 'x', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' })] + + const result = wouldConflict({ + id: 'new', + stage_id: 's1', + lane: 0, + start_at: '2026-07-10T18:59:00Z', + end_at: '2026-07-10T20:00:00Z', + }, others) + + expect(result).toBe(true) + }) + + it('returns false when candidate is parked', () => { + const others = [p({ id: 'x' })] + + const result = wouldConflict({ + id: 'new', + stage_id: null, + lane: 0, + start_at: '2026-07-10T18:00:00Z', + end_at: '2026-07-10T19:00:00Z', + }, others) + + expect(result).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/lane.test.ts b/apps/app/tests/unit/lib/timetable/lane.test.ts new file mode 100644 index 00000000..2db93c43 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/lane.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { type LaneSubject, previewCascade, resolveLanes } from '@/lib/timetable/lane' +import type { Performance } from '@/types/timetable' + +function s(id: string, start: string, end: string, lane: number | null = null, cancelled = false): LaneSubject { + return { id, start_at: start, end_at: end, lane, cancelled } +} + +function p(id: string, start: string, end: string, lane = 0): Performance { + return { + id, + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane, + lane_resolved: lane, + start_at: start, + end_at: end, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +describe('resolveLanes (Pass 2 only — implicit lanes)', () => { + it('places non-overlapping items on lane 0', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf).toEqual({ a: 0, b: 0 }) + expect(result.laneCount).toBe(1) + }) + + it('stacks overlapping items into separate lanes', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf.a).toBe(0) + expect(result.laneOf.b).toBe(1) + expect(result.laneCount).toBe(2) + }) + + it('Pass 1 — explicit lane is honoured', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 2), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf.a).toBe(2) + expect(result.laneOf.b).toBe(0) + }) + + it('Pass 1 — overlapping explicit lane bumps down', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z', 0), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z', 0), + ]) + + expect(result.laneOf.a).toBe(0) + expect(result.laneOf.b).toBe(1) + }) + + it('cancelled items are excluded from collision checks', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z', null, true), + ]) + + expect(result.laneOf.b).toBe(0) + }) + + it('handles empty input', () => { + const result = resolveLanes([]) + + expect(result.laneCount).toBe(1) + expect(result.laneOf).toEqual({}) + }) +}) + +describe('previewCascade (drag preview)', () => { + it('preserves wanted lane when target is free', () => { + const cohort = [p('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 0)] + + const result = previewCascade( + { id: 'dragged', lane: 1, start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }, + cohort, + ) + + expect(result.laneOf.dragged).toBe(1) + expect(result.laneOf.a).toBe(0) + }) + + it('cascades existing item down when wanted lane is busy', () => { + const cohort = [p('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 0)] + + const result = previewCascade( + { id: 'dragged', lane: 0, start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }, + cohort, + ) + + expect(result.laneOf.dragged).toBe(1) + expect(result.laneOf.a).toBe(0) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/snap.test.ts b/apps/app/tests/unit/lib/timetable/snap.test.ts new file mode 100644 index 00000000..abb64fd3 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/snap.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { MIN_DURATION_MIN, SNAP_MIN, snap, snapClamp } from '@/lib/timetable/snap' + +describe('snap', () => { + it('rounds to nearest multiple of step', () => { + expect(snap(0, 5)).toBe(0) + expect(snap(2, 5)).toBe(0) + expect(snap(3, 5)).toBe(5) + expect(snap(7, 5)).toBe(5) + expect(snap(8, 5)).toBe(10) + expect(snap(12, 5)).toBe(10) + expect(snap(13, 5)).toBe(15) + }) + + it('returns value unchanged when step <= 0', () => { + expect(snap(7.3, 0)).toBe(7.3) + expect(snap(7.3, -1)).toBe(7.3) + }) + + it('handles exact-multiple inputs', () => { + expect(snap(15, 5)).toBe(15) + expect(snap(60, 15)).toBe(60) + }) + + it('exposes the SNAP_MIN constant', () => { + expect(SNAP_MIN).toBeGreaterThan(0) + expect(SNAP_MIN).toBeLessThanOrEqual(15) + }) + + it('exposes MIN_DURATION_MIN', () => { + expect(MIN_DURATION_MIN).toBe(15) + }) +}) + +describe('snapClamp', () => { + it('snaps then clamps inside [min, max]', () => { + expect(snapClamp(7, 5, 0, 100)).toBe(5) + expect(snapClamp(-5, 5, 0, 100)).toBe(0) + expect(snapClamp(150, 5, 0, 100)).toBe(100) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/time-grid.test.ts b/apps/app/tests/unit/lib/timetable/time-grid.test.ts new file mode 100644 index 00000000..496207ea --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/time-grid.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + formatTickLabel, + generateTicks, + isoToMinutes, + minutesToIso, + minutesToPx, + pxToMinutes, +} from '@/lib/timetable/time-grid' + +describe('time-grid coordinate conversions', () => { + const gridStart = '2026-07-10T14:00:00.000Z' + + it('isoToMinutes returns 0 at the anchor', () => { + expect(isoToMinutes(gridStart, gridStart)).toBe(0) + }) + + it('isoToMinutes computes minute offsets', () => { + expect(isoToMinutes('2026-07-10T15:00:00.000Z', gridStart)).toBe(60) + expect(isoToMinutes('2026-07-10T14:30:00.000Z', gridStart)).toBe(30) + expect(isoToMinutes('2026-07-10T13:30:00.000Z', gridStart)).toBe(-30) + }) + + it('roundtrip isoToMinutes ↔ minutesToIso preserves the value', () => { + const back = minutesToIso(isoToMinutes('2026-07-10T18:45:00.000Z', gridStart), gridStart) + + expect(back).toBe('2026-07-10T18:45:00.000Z') + }) + + it('minutesToPx and pxToMinutes are inverses', () => { + expect(minutesToPx(30, 2)).toBe(60) + expect(pxToMinutes(60, 2)).toBe(30) + expect(pxToMinutes(60, 0)).toBe(0) + }) + + it('formatTickLabel returns nl-NL HH:MM', () => { + const label = formatTickLabel(0, gridStart) + + expect(label).toMatch(/^\d{2}:\d{2}$/) + }) + + it('generateTicks produces inclusive endpoints', () => { + const ticks = generateTicks(120, 30) + + expect(ticks).toEqual([0, 30, 60, 90, 120]) + }) +}) diff --git a/apps/app/tests/unit/pages/timetableDaySync.test.ts b/apps/app/tests/unit/pages/timetableDaySync.test.ts new file mode 100644 index 00000000..2563e2f7 --- /dev/null +++ b/apps/app/tests/unit/pages/timetableDaySync.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { useActiveDay } from '@/composables/timetable/useActiveDay' + +/** + * The `?day` source-of-truth contract (Session 4 follow-up Step 5). + * + * useActiveDay is mounted inside a host component so the watcher actually + * runs (composables that use watch() need an active component instance). + */ + +interface HostExposed { + activeDayId: () => string | null + setActiveDay: (id: string | null) => void + setQueryDay: (id: string | null) => void + setValidIds: (ids: string[]) => void + replaceMock: ReturnType +} + +function mountSync(initialQuery: string | null, initialValidIds: string[]) { + const queryDay = ref(initialQuery) + const validIds = ref(initialValidIds) + + const replaceMock = vi.fn((id: string) => { + queryDay.value = id + }) + + const host = defineComponent({ + setup(_, { expose }) { + const { activeDayId, setActiveDay } = useActiveDay({ + queryDay, + validIds, + replace: replaceMock, + }) + + expose({ + activeDayId: () => activeDayId.value, + setActiveDay, + setQueryDay: (v: string | null) => { queryDay.value = v }, + setValidIds: (v: string[]) => { validIds.value = v }, + replaceMock, + }) + + return () => h('div') + }, + }) + + const wrapper = mount(host) + + return { wrapper, vm: wrapper.vm as unknown as HostExposed, replaceMock } +} + +describe('useActiveDay — ?day source-of-truth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('valid ?day=X returns X without rewriting the URL', async () => { + const { vm, replaceMock } = mountSync('day_2', ['day_1', 'day_2', 'day_3']) + + await nextTick() + + expect(vm.activeDayId()).toBe('day_2') + expect(replaceMock).not.toHaveBeenCalled() + }) + + it('missing ?day → fallback to first valid id + URL replaced once', async () => { + const { vm, replaceMock } = mountSync(null, ['day_1', 'day_2', 'day_3']) + + await nextTick() + await nextTick() + + expect(vm.activeDayId()).toBe('day_1') + expect(replaceMock).toHaveBeenCalledTimes(1) + expect(replaceMock).toHaveBeenCalledWith('day_1') + }) + + it('invalid ?day=DOES_NOT_EXIST → fallback + URL corrected', async () => { + const { vm, replaceMock } = mountSync('day_bogus', ['day_1', 'day_2']) + + await nextTick() + await nextTick() + + expect(vm.activeDayId()).toBe('day_1') + expect(replaceMock).toHaveBeenCalledWith('day_1') + }) + + it('cross-org ?day (id absent from validIds) → fallback to first valid', async () => { + // Backend OrganisationScope keeps the cross-org sub-event out of the + // returned list; useActiveDay treats it identically to "doesn't exist". + const { vm, replaceMock } = mountSync('day_other_org', ['day_a']) + + await nextTick() + await nextTick() + + expect(vm.activeDayId()).toBe('day_a') + expect(replaceMock).toHaveBeenCalledWith('day_a') + }) + + it('empty validIds → activeDayId is null and URL is not touched', async () => { + const { vm, replaceMock } = mountSync('day_1', []) + + await nextTick() + await nextTick() + + expect(vm.activeDayId()).toBeNull() + expect(replaceMock).not.toHaveBeenCalled() + }) + + it('setActiveDay(id) calls replace with the new id', async () => { + const { vm, replaceMock } = mountSync('day_1', ['day_1', 'day_2']) + + await nextTick() + + vm.setActiveDay('day_2') + await nextTick() + + expect(replaceMock).toHaveBeenLastCalledWith('day_2') + expect(vm.activeDayId()).toBe('day_2') + }) + + it('setActiveDay(null) is a no-op', async () => { + const { vm, replaceMock } = mountSync('day_1', ['day_1', 'day_2']) + + await nextTick() + replaceMock.mockClear() + + vm.setActiveDay(null) + await nextTick() + + expect(replaceMock).not.toHaveBeenCalled() + }) + + it('external URL change (browser back) propagates to activeDayId', async () => { + const { vm } = mountSync('day_1', ['day_1', 'day_2']) + + await nextTick() + expect(vm.activeDayId()).toBe('day_1') + + vm.setQueryDay('day_2') + await nextTick() + + expect(vm.activeDayId()).toBe('day_2') + }) + + it('validIds populated AFTER mount triggers fallback if ?day was missing', async () => { + const { vm, replaceMock } = mountSync(null, []) + + await nextTick() + expect(vm.activeDayId()).toBeNull() + expect(replaceMock).not.toHaveBeenCalled() + + vm.setValidIds(['day_1', 'day_2']) + await nextTick() + await nextTick() + + expect(vm.activeDayId()).toBe('day_1') + expect(replaceMock).toHaveBeenCalledWith('day_1') + }) +}) diff --git a/apps/app/tests/unit/schemas/timetable.test.ts b/apps/app/tests/unit/schemas/timetable.test.ts new file mode 100644 index 00000000..6203d449 --- /dev/null +++ b/apps/app/tests/unit/schemas/timetable.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + createPerformancePayloadSchema, + createStagePayloadSchema, + performanceSchema, +} from '@/schemas/timetable' + +describe('createPerformancePayloadSchema', () => { + it('accepts a complete payload', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + lane: 0, + notes: null, + }) + + expect(result.success).toBe(true) + }) + + it('rejects missing engagement_id', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: '', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + }) + + expect(result.success).toBe(false) + }) + + it('rejects end <= start', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 19:00:00', + end_at: '2026-07-10 18:00:00', + }) + + expect(result.success).toBe(false) + }) + + it('rejects lane > 9', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + lane: 99, + }) + + expect(result.success).toBe(false) + }) +}) + +describe('createStagePayloadSchema', () => { + it('accepts uppercase + lowercase hex', () => { + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#aabbcc' }).success).toBe(true) + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#AABBCC' }).success).toBe(true) + }) + + it('rejects shorthand hex', () => { + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#abc' }).success).toBe(false) + }) + + it('rejects empty name', () => { + expect(createStagePayloadSchema.safeParse({ name: '', color: '#aabbcc' }).success).toBe(false) + }) +}) + +describe('performanceSchema', () => { + it('parses a minimal performance', () => { + const result = performanceSchema.safeParse({ + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + }) + + expect(result.success).toBe(true) + }) + + it('rejects unknown warning value', () => { + const result = performanceSchema.safeParse({ + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: ['nonsense'], + created_at: null, + updated_at: null, + deleted_at: null, + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/stores/useTimetableStore.test.ts b/apps/app/tests/unit/stores/useTimetableStore.test.ts new file mode 100644 index 00000000..20f2fdb7 --- /dev/null +++ b/apps/app/tests/unit/stores/useTimetableStore.test.ts @@ -0,0 +1,80 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useTimetableStore } from '@/stores/useTimetableStore' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function p(): Performance { + return { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +describe('useTimetableStore', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('initialises with cancelled OFF in status filter', () => { + const store = useTimetableStore() + + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true) + expect(store.isStatusVisible(ArtistEngagementStatus.CANCELLED)).toBe(false) + }) + + it('toggleStatus flips a single status', () => { + const store = useTimetableStore() + + store.toggleStatus(ArtistEngagementStatus.CONFIRMED) + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(false) + store.toggleStatus(ArtistEngagementStatus.CONFIRMED) + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true) + }) + + it('selectPerformance maps to id and clears on null', () => { + // activeDayId / setActiveDay intentionally REMOVED from the store + // (Step 5: ?day URL is the source of truth, page derives via computed). + const store = useTimetableStore() + + expect((store as unknown as { activeDayId?: string }).activeDayId).toBeUndefined() + expect((store as unknown as { setActiveDay?: unknown }).setActiveDay).toBeUndefined() + + store.selectPerformance('p1') + expect(store.selectedPerformanceId).toBe('p1') + store.selectPerformance(null) + expect(store.selectedPerformanceId).toBeNull() + }) + + it('startDrag/endDrag manages snapshot + ghost', () => { + const store = useTimetableStore() + + expect(store.isDragging).toBe(false) + store.startDrag(p()) + expect(store.isDragging).toBe(true) + expect(store.dragOriginSnapshot?.id).toBe('p1') + + store.updateDragGhost({ stageId: 's1', startAt: '2026-07-10T18:30:00Z', endAt: '2026-07-10T19:30:00Z', lane: 1 }) + expect(store.dragGhost?.lane).toBe(1) + + store.endDrag() + expect(store.isDragging).toBe(false) + expect(store.dragGhost).toBeNull() + }) + + it('isStatusVisible handles null gracefully', () => { + const store = useTimetableStore() + + expect(store.isStatusVisible(null)).toBe(false) + expect(store.isStatusVisible(undefined)).toBe(false) + }) +}) diff --git a/apps/app/tests/utils/mountWithVuexy.ts b/apps/app/tests/utils/mountWithVuexy.ts new file mode 100644 index 00000000..718cda6e --- /dev/null +++ b/apps/app/tests/utils/mountWithVuexy.ts @@ -0,0 +1,180 @@ +import { type VueWrapper, mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import type { TestingPinia } from '@pinia/testing' +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { type RouteRecordRaw, type Router, createMemoryHistory, createRouter } from 'vue-router' +import { type ThemeDefinition, createVuetify } from 'vuetify' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import { vi } from 'vitest' +import type { Component } from 'vue' + +// Plain-CSS token sheet — JSDOM evaluates :root custom properties from this +// import so getComputedStyle(el).getPropertyValue('--tt-status-…') resolves +// during component tests. Path resolved by vitest.config alias `@`. +import '@/styles/tokens/_timetable.css' + +/** + * Notification mock matching the actual store API: + * useNotificationStore().show(message, type, duration) + * (See apps/app/src/stores/useNotificationStore.ts) + */ +export interface NotificationMock { + show: ReturnType + hide: ReturnType +} + +export function createNotificationMock(): NotificationMock { + return { + show: vi.fn(), + hide: vi.fn(), + } +} + +export interface MountWithVuexyOptions { + + /** Routes to register on the test router. Default: a single catch-all. */ + routes?: RouteRecordRaw[] + + /** Initial path the router opens at. Default: '/'. */ + initialPath?: string + + /** Initial query string params. */ + initialQuery?: Record + + /** Initial Pinia store state (per-store map, see @pinia/testing docs). */ + initialState?: Record> + + /** Override the default fresh QueryClient (useful for prefilled caches). */ + queryClient?: QueryClient + + /** Provide a custom notification mock; default `createNotificationMock()`. */ + notificationMock?: NotificationMock + + /** + * Set to `true` to use createTestingPinia's default action stubbing (every + * action becomes a vi.fn that does nothing). Default `false` — actions + * still execute so component tests exercise real store behaviour. + */ + stubActions?: boolean + + /** props forwarded to mount(). */ + props?: Record + + /** Slots for mount(). */ + slots?: Record + + /** Optional global stubs. */ + stubs?: Record +} + +export interface MountWithVuexyResult { + wrapper: VueWrapper + router: Router + pinia: TestingPinia + queryClient: QueryClient + notificationMock: NotificationMock +} + +const defaultTheme: ThemeDefinition = { + dark: false, + colors: { + primary: '#1f7ad1', + error: '#d63d4b', + success: '#2fa66a', + warning: '#e0992c', + info: '#1f7ad1', + }, +} + +/** + * Mounts a Vue component with the full Vuexy/Vuetify stack wired up: + * - Vuetify (real components + directives, default theme tokens) + * - Pinia (createTestingPinia — actions execute by default) + * - TanStack Vue Query (a fresh QueryClient per call — never shared) + * - Vue Router (memory history, opens at `initialPath` with `initialQuery`) + * - Notification store mocked at the Pinia layer + * + * Each call gets fresh instances of router, pinia, and queryClient — no + * cross-test leakage. The notification mock is exposed so tests can assert + * `expect(notificationMock.show).toHaveBeenCalledWith('…', 'error', …)`. + */ +export function mountWithVuexy(component: Component, options: MountWithVuexyOptions = {}): MountWithVuexyResult { + const { + routes = [{ path: '/', component: { template: '
' } }, { path: '/:pathMatch(.*)*', component: { template: '
' } }], + initialPath = '/', + initialQuery, + initialState = {}, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }), + notificationMock = createNotificationMock(), + stubActions = false, + props, + slots, + stubs, + } = options + + const router = createRouter({ history: createMemoryHistory(), routes }) + + // Patch the notification store via initialState so any useNotificationStore() + // call resolves to the mock fns. Pinia testing replaces actions when + // stubActions=true; here we override the action surface explicitly so the + // mock is consistent regardless of stubActions. + const pinia = createTestingPinia({ + stubActions, + initialState: { + ...initialState, + notification: { + visible: false, + message: '', + type: 'info', + timeout: 5000, + ...(initialState.notification ?? {}), + }, + }, + createSpy: vi.fn, + }) + + // Bind the notification action mocks into the store. We do this AFTER + // createTestingPinia so the store is registered. + pinia.use(({ store }) => { + if (store.$id === 'notification') { + store.show = notificationMock.show + store.hide = notificationMock.hide + } + }) + + const vuetify = createVuetify({ + components, + directives, + theme: { defaultTheme: 'crewliLight', themes: { crewliLight: defaultTheme } }, + }) + + const navigatePromise = (async () => { + if (initialQuery) + await router.push({ path: initialPath, query: initialQuery }) + else + await router.push(initialPath) + await router.isReady() + })() + + const wrapper = mount(component, { + props, + slots, + global: { + plugins: [ + vuetify, + pinia, + router, + [VueQueryPlugin, { queryClient }], + ], + stubs, + }, + }) + + // The router push above is fire-and-forget; consumers that need the route + // to be settled before the first assertion should `await wrapper.vm.$nextTick()` + // a couple of times after mount. We attach the promise so tests can await it. + ;(wrapper as unknown as { __routerReady: Promise }).__routerReady = navigatePromise + + return { wrapper, router, pinia, queryClient, notificationMock } +} diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts index 940bb6a5..32b7549d 100644 --- a/apps/app/typed-router.d.ts +++ b/apps/app/typed-router.d.ts @@ -33,6 +33,7 @@ declare module 'vue-router/auto-routes' { 'events-id-settings': RouteRecordInfo<'events-id-settings', '/events/:id/settings', { id: ParamValue }, { id: ParamValue }>, 'events-id-settings-registration-fields': RouteRecordInfo<'events-id-settings-registration-fields', '/events/:id/settings/registration-fields', { id: ParamValue }, { id: ParamValue }>, 'events-id-time-slots': RouteRecordInfo<'events-id-time-slots', '/events/:id/time-slots', { id: ParamValue }, { id: ParamValue }>, + 'events-id-timetable': RouteRecordInfo<'events-id-timetable', '/events/:id/timetable', { id: ParamValue }, { id: ParamValue }>, 'forbidden': RouteRecordInfo<'forbidden', '/forbidden', Record, Record>, 'forgot-password': RouteRecordInfo<'forgot-password', '/forgot-password', Record, Record>, 'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue }, { token: ParamValue }>, diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 0548d309..99614750 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -3,36 +3,82 @@ import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import { defineConfig } from 'vitest/config' -// Dedicated Vitest config — intentionally trimmed down from vite.config.ts. -// Skip Vuetify / MetaLayouts / VueRouter plugins so unit tests run fast in -// happy-dom without loading the full Vuexy bundle. Mirrors apps/portal/vitest.config.ts. -export default defineConfig({ - plugins: [ - vue(), - AutoImport({ - imports: ['vue', '@vueuse/core'], - dirs: ['./src/@core/utils', './src/@core/composable/', './src/composables/', './src/utils/'], - vueTemplate: true, +// Two projects share one config: +// +// - "unit" — pure-logic tests under tests/unit/ + src/**/__tests__/. +// No Vuetify, no SCSS plugin, happy-dom only. Fast path. +// - "component" — component / integration / a11y tests under tests/component/, +// tests/integration/, tests/a11y/. Loads CSS imports so +// `import '@/styles/tokens/_timetable.css'` resolves and +// getComputedStyle() returns var(--tt-…) values in jsdom. +// +// Both share the same alias map and AutoImport bag so test paths and the +// auto-imported `ref/computed/watch` etc. work identically. +const sharedAliases = { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@core': fileURLToPath(new URL('./src/@core', import.meta.url)), + '@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)), + '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)), + '@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)), +} - // Don't write to auto-imports.d.ts — vite.config.ts owns that file - // with the full app's auto-import set. Trimmed test-only set must - // not clobber the IDE typings for the running dev server. - dts: false, - }), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - '@core': fileURLToPath(new URL('./src/@core', import.meta.url)), - '@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)), - '@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)), - '@styles': fileURLToPath(new URL('./src/assets/styles/', import.meta.url)), - }, - }, +const sharedAutoImport = AutoImport({ + imports: ['vue', '@vueuse/core', 'vue-router'], + dirs: ['./src/@core/utils', './src/@core/composable/', './src/composables/', './src/utils/'], + vueTemplate: true, + dts: false, +}) + +export default defineConfig({ test: { - environment: 'happy-dom', - globals: true, - include: ['tests/**/*.{test,spec}.ts', 'src/**/__tests__/**/*.{test,spec}.ts'], - setupFiles: ['./tests/setup.ts'], + projects: [ + { + plugins: [vue(), sharedAutoImport], + resolve: { alias: sharedAliases }, + test: { + name: 'unit', + environment: 'happy-dom', + globals: true, + include: [ + 'tests/unit/**/*.{test,spec}.ts', + 'tests/*.{test,spec}.ts', + 'src/**/__tests__/**/*.{test,spec}.ts', + ], + setupFiles: ['./tests/setup.ts'], + }, + }, + { + plugins: [vue(), sharedAutoImport], + resolve: { alias: sharedAliases }, + + // Inline Vuetify so its ESM bits are processed by Vite's transform. + ssr: { noExternal: ['vuetify'] }, + test: { + name: 'component', + environment: 'jsdom', + globals: true, + include: [ + 'tests/component/**/*.{test,spec}.ts', + 'tests/integration/**/*.{test,spec}.ts', + 'tests/a11y/**/*.{test,spec}.ts', + ], + + // Intentionally NOT including ./tests/setup.ts — it stubs `vue-router` + // globally for the unit project, which would defeat the real router + // wired by mountWithVuexy. setup.component.ts handles its own + // crypto/JSDOM stubs. + setupFiles: ['./tests/setup.component.ts'], + + // CSS @import statements (e.g. `@/styles/tokens/_timetable.css`) + // need to actually load so getComputedStyle resolves CSS variables. + css: true, + server: { + deps: { + inline: ['vuetify'], + }, + }, + }, + }, + ], }, }) diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index f7a8a528..0c24878d 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -2016,8 +2016,9 @@ RFC-FORM-BUILDER-UI implementatie begint. Argumenten: hoge-prioriteit endpoints (`/auth/me`, form-builder list-endpoints, identity-match endpoints). Verdere composable-uitrol gebeurt organisch als features worden geraakt of toegevoegd. -**Out of scope:** form-input validatie (al via VeeValidate + Zod), WebSocket-validatie -(separaat, COMM-01), publieke API-contracten voor third parties (separaat, DIFF-03). +**Out of scope:** form-input validatie (via `@core/utils/validators` + Zod +payload schemas — see CLAUDE.md "Forms"), WebSocket-validatie (separaat, +COMM-01), publieke API-contracten voor third parties (separaat, DIFF-03). **Open beslissingen:** codegen toolchain (Scramble-pipeline vs hand-rolled), validation failure-mode (hard fail vs soft fail per env), per-route opt-out, boundary placement @@ -2284,3 +2285,149 @@ opgezet. **Refs:** `apps/app/index.html`, `deploy/nginx/csp-spa.conf`, `tests/Feature/Security/CspConnectsToObservabilityTest.php`, RFC-WS-7-OBSERVABILITY.md §3.3, ARCH-OBSERVABILITY.md §7 + §10.4. + +### VEE-001 — VeeValidate removed from stack ✅ Resolved + +**Status:** Closed in `feat/timetable-session-4` follow-up. + +`vee-validate` and `@vee-validate/zod` shipped in `apps/app/package.json` +since Vuexy onboarding but were never imported anywhere in the SPA. A +strict regex sweep (`from 'vee-validate'`, ``, `
`, +``, `defineRule(`, `useForm()`) returned **zero +hits** across `apps/app/src/`. Earlier fuzzy matches were false +positives from `useForm` colliding with Crewli's own `useFormDraft` / +`useFormSteps` / `useFormSchemas` / `useFormFailures` composables. + +Removed both packages from `apps/app/package.json`, regenerated +`pnpm-lock.yaml`. Canonical form pattern formalized in `CLAUDE.md` +"Forms" + `dev-docs/VUEXY_COMPONENTS.md` "Form validation" row. + +**Refs:** Session 4 follow-up Step 1; `apps/app/src/components/timetable/AddPerformanceDialog.vue` and `apps/app/src/components/sections/CreateShiftDialog.vue` as canonical references. + +### TEST-INFRA-001 — Migrate timetable component+a11y tests to Playwright Component Testing + +**Aanleiding:** Session 4 follow-up landed component-mount, integration, +keyboard a11y, and axe-core tests on Vitest + jsdom as a deliberate +intermediate step. JSDOM does not faithfully reproduce browser layout, +PointerEvents-with-capture, drag-threshold semantics, or computed CSS +visual properties (color contrast resolution). For a module whose core +surface is drag/resize/lane-stacking/pixel-coordinates, jsdom-based +assertions are necessary but not sufficient. + +**Wat:** +- Open branch `feat/playwright-ct-foundation` after the timetable PR merges. +- Install `@playwright/experimental-ct-vue` (and Playwright runners). +- Build the equivalent of `apps/app/tests/utils/mountWithVuexy.ts` for + Playwright CT (Vuetify, Pinia testing, QueryClient, router, token + CSS injection). +- Migrate to `apps/app/tests-pw/component/`: + - `PerformanceBlock.test.ts` + - `StageRow.test.ts` + - `Wachtrij.test.ts` + - `AddPerformanceDialog.test.ts` + - `useTimetableMutations.test.ts` (drag-threshold assertions especially benefit) + - `keyboard.test.ts` + - `axe.test.ts` +- Migrate `tests/integration/timetable-flow.test.ts` to `apps/app/tests-pw/integration/`. +- Keep Vitest + jsdom for `tests/unit/` only (pure-logic + Zod + simple composables). +- CI strategy: Playwright CT runs on every PR (slow lane); Vitest unit + on pre-commit (fast lane). + +**Trigger:** **Before opening the Sessie 5 prompt.** Sessie 5 builds +Engagement Detail (6 tabs, complex form state) and Portal pages +(drag-to-reorder, file uploads). Adding more jsdom-based component +tests for those surfaces compounds the migration cost. + +**Refs:** Session 4 follow-up commits `5f135ec`..`985a5ab`, +RFC-TIMETABLE D14/D20/D21, the new `apps/app/tests/utils/mountWithVuexy.ts` +helper (designed to translate cleanly into Playwright CT's `mount()` API). + +--- + +### TEST-CONTRACT-001 — End-to-end 409 conflict contract test against running Laravel + +**Aanleiding:** The 409 rollback path in `useTimetableMutations.move()` +is currently asserted against a mocked axios response shape (Session 4 +follow-up Step 9 + Step 12). A frontend Zod schema drift vs. the backend's +actual `StaleVersionException` serialization would not be caught — only +the unit test's mocked shape is validated. For a contract that protects +against multi-user collisions (RFC D14), the integration must be verified +against the actual backend. + +**Wat:** +- After TEST-INFRA-001 lands the Playwright foundation, add + `apps/app/tests-pw/e2e/timetable-409-conflict.spec.ts`. +- Spin up Laravel via `php artisan serve` in the test setup (or use the + existing test-DB seeded against `crewli_test`). +- Seed two browser contexts authenticated as the same organizer. +- Both load the same timetable; both attempt to `POST /timetable/move` + on the same performance with the same `version` value. +- Assert: first request succeeds (200, returns new version + cascaded[]); + second request fails (409 with the actual backend conflict shape); + frontend correctly rolls back and shows the conflict toast. +- Validate the response shape parses against `MoveTimetableConflictResponse` + Zod — this is the contract proof. + +**Trigger:** First e2e flow added after TEST-INFRA-001 lands. Highest +contract-protection value per line of test code. + +**Refs:** RFC-TIMETABLE D14, Session 4 follow-up Step 4 +(zodParseFailure regression) + Step 9 (mocked 409). + +--- + +### TEST-VISUAL-001 — Visual regression baselines for PerformanceBlock states + +**Aanleiding:** Status badge colors, capacity icon presence, B2B dots, +conflict ring, and cascade-pulse animation are UX contracts encoded in +CSS tokens (RFC D21, D22, D25, D26). The Vitest+jsdom component tests +assert that the right token *resolves* (Step 6's getComputedStyle +roundtrips) but a developer changing token values, border widths, +padding, or animation timing would not trigger a test failure even +though the visual contract is broken. + +**Wat:** +- After TEST-INFRA-001 lands, add `apps/app/tests-pw/visual/PerformanceBlock.spec.ts`. +- Render PerformanceBlock in each documented state: option, confirmed, + contracted, cancelled; with/without capacity warn; with/without B2B + dots; with/without conflict ring; mid-cascade-pulse. +- Use Playwright's `toHaveScreenshot()` with locked viewport size and + font rendering. +- Commit baseline PNGs to `apps/app/tests-pw/visual/__screenshots__/`. +- CI fails on diff > 0.1% pixel delta. Diffs trigger a manual review and + baseline update if the change was intentional. +- Pin font hinting and OS rendering: run visual tests only on Linux CI + runners (consistent rendering across Mac/Windows is expensive — Linux + baseline is sufficient). + +**Trigger:** Second addition to the TEST-INFRA-001 sprint. After +TEST-CONTRACT-001 (contract-first, visual-regression-second). + +**Refs:** RFC-TIMETABLE D21, D22, D25, D26. + +--- + +### ART-S4-TESTS — Session 4 test coverage closure ✅ Resolved + +**Status:** Closed by the Session 4 follow-up branch +(commits `5c53dcd` through `985a5ab`). + +VeeValidate removed (VEE-001). CSS tokens moved to `.css` for jsdom-time +loadability. `mountWithVuexy` helper + axe-core dev dep + segmented +vitest configs landed. Zod runtime parsing wired into all timetable +queries + mutations with regression tests. `?day` query is now the +source of truth via `useActiveDay` composable with corrective fallback +for missing / invalid / cross-org IDs. Component tests cover +PerformanceBlock visuals + interactions, StageRow lane stacking, +Wachtrij rendering + drag, AddPerformanceDialog validation + submit. +useTimetableMutations 409 + idempotency-key semantics tested. +Keyboard a11y model fully covered (RFC D20). axe-core scans clean on +the user-facing surfaces (two real bugs surfaced + fixed inline: +VProgressLinear missing aria-label, dialog close button missing +aria-label). Full add → drag → resize → park → delete integration flow +verified through the mutation composable. + +Test count delta: 252 → 385 (+133 across the two PRs). + +Follow-up sprints: TEST-INFRA-001 (Playwright CT migration), +TEST-CONTRACT-001 (real-backend 409), TEST-VISUAL-001 (visual regression). diff --git a/dev-docs/VUEXY_COMPONENTS.md b/dev-docs/VUEXY_COMPONENTS.md index f84477e5..960e12b6 100644 --- a/dev-docs/VUEXY_COMPONENTS.md +++ b/dev-docs/VUEXY_COMPONENTS.md @@ -430,9 +430,12 @@ Two approaches from Vuexy: - StatusCard shows different UI per approval status (pending/approved/rejected) - Conditional tab visibility based on approval status -#### Registration (Multi-step Form with VeeValidate + Zod) +#### Registration (Multi-step Public Form) **Reference:** `apps/app/src/pages/register/[public_token].vue` -- VForm with VeeValidate field binding + Zod schemas from `@/schemas/` +- VForm + `useFormDraft` composable for state, autosave, idempotency-key drafts +- Per-field validators from `@core/utils/validators` (`emailValidator`, `requiredValidator`) +- Zod schemas in `apps/app/src/schemas/registrationSchema.ts` validate the + outgoing payload at submit time - Conditional form fields based on event configuration - Real-time email duplicate checking - Password creation for new users @@ -480,7 +483,7 @@ Preferred Vuetify components for common needs. Use these, not custom solutions. | Menu / dropdown actions | VMenu + VList + VListItem | Custom popover | | Drag-and-drop lists | `vuedraggable` (external library) | Custom drag logic | | Rich text editing | TiptapEditor (@core) | Custom editor | -| Form validation | VForm ref + `@core/utils/validators` + API error mapping | (VeeValidate + Zod only in portal registration) | +| Form validation | VForm ref + `@core/utils/validators` + Zod schema for payload + API 422 error map | VeeValidate (removed; was never actually adopted) | | Multi-step wizard | AppStepper (@core) | Custom step logic | | Drawer header | AppDrawerHeaderSection (@core) | Custom header | | Code display | AppCardCode (@core) | Custom code block |