From 0a533a65fd25ef919a0aa64acd87b6577a37acf2 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:37:00 +0200 Subject: [PATCH 01/26] feat(timetable): types + zod schemas + idempotency-key helper (Session 4 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract generateIdempotencyKey() from useFormDraft into reusable lib/ - New types/timetable.ts mirrors PerformanceResource, ArtistEngagementResource, StageResource, GenreResource and the four enums verbatim - New schemas/timetable.ts adds zod parsers for runtime validation of API responses + form payloads (createPerformance, createStage, moveTimetable) RFC v0.2 §10 contract surface for the upcoming timetable canvas. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/composables/useFormDraft.ts | 20 +- apps/app/src/lib/idempotencyKey.ts | 26 +++ apps/app/src/schemas/timetable.ts | 229 +++++++++++++++++++ apps/app/src/types/timetable.ts | 273 +++++++++++++++++++++++ 4 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 apps/app/src/lib/idempotencyKey.ts create mode 100644 apps/app/src/schemas/timetable.ts create mode 100644 apps/app/src/types/timetable.ts 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/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/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[] +} -- 2.39.5 From 36525e729a951045d3cf29ed6d5d9d22fc04355e Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:39:14 +0200 Subject: [PATCH 02/26] =?UTF-8?q?feat(timetable):=20pure=20logic=20ports?= =?UTF-8?q?=20=E2=80=94=20snap,=20lane,=20conflict,=20b2b,=20capacity,=20t?= =?UTF-8?q?ime-grid=20(Session=204=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the prototype's helpers.js + cascade-bump algorithm into typed TypeScript modules in apps/app/src/lib/timetable/: - snap.ts — 5-minute snap (RFC D7) + 15-min minimum duration - time-grid.ts — pixel ↔ minute ↔ ISO-8601 coordinate conversions - conflict.ts — same-stage same-lane overlap detection (RFC D5) - b2b.ts — back-to-back marker links, 3-min threshold (RFC D26) - capacity.ts — 110% over-capacity warn level (RFC D25) - lane.ts — two-pass resolver + drag-preview cascade (D13/D18/D19, client-side preview only; server is authoritative) All functions are pure (no Vue, no DOM). Tested in Phase C. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/lib/timetable/b2b.ts | 75 ++++++++++++ apps/app/src/lib/timetable/capacity.ts | 57 +++++++++ apps/app/src/lib/timetable/conflict.ts | 84 ++++++++++++++ apps/app/src/lib/timetable/index.ts | 6 + apps/app/src/lib/timetable/lane.ts | 147 ++++++++++++++++++++++++ apps/app/src/lib/timetable/snap.ts | 27 +++++ apps/app/src/lib/timetable/time-grid.ts | 70 +++++++++++ 7 files changed, 466 insertions(+) create mode 100644 apps/app/src/lib/timetable/b2b.ts create mode 100644 apps/app/src/lib/timetable/capacity.ts create mode 100644 apps/app/src/lib/timetable/conflict.ts create mode 100644 apps/app/src/lib/timetable/index.ts create mode 100644 apps/app/src/lib/timetable/lane.ts create mode 100644 apps/app/src/lib/timetable/snap.ts create mode 100644 apps/app/src/lib/timetable/time-grid.ts 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 +} -- 2.39.5 From 3536358a59f3584dbdc8345604483ac889fe02eb Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:41:04 +0200 Subject: [PATCH 03/26] feat(timetable): TanStack queries + mutations with optimistic move + cascade pulse (Session 4 steps 3+4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTimetable.ts (read side): - useStages / usePerformances(?day=) / useWachtrij(?stage_id=null) - useEngagement (popover deal info + advancing aggregate) - useTimetable() aggregate with isLoading/isError/refetch - 30s staleTime + refetchOnWindowFocus for multi-user awareness (RFC D14 — Echo deferred to ART-15) useTimetableMutations.ts (write side): - move (RFC D18) — optimistic patch on mutate, applies cascaded[] on success, snapshot rollback on 409 (VersionMismatch surfaced to caller for toast) - park / unpark via the move endpoint with optimistic stage_id flip - create / updateNotes / remove + stage CRUD + reorderStages (optimistic) + replaceStageDays - Idempotency-Key generated per logical action (re-drag = new key) Skipped a separate src/api/timetable.ts module to stay consistent with the codebase's "api+composables together" pattern (useShifts.ts, useSections.ts). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/composables/api/useTimetable.ts | 162 ++++++++ .../composables/api/useTimetableMutations.ts | 352 ++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 apps/app/src/composables/api/useTimetable.ts create mode 100644 apps/app/src/composables/api/useTimetableMutations.ts diff --git a/apps/app/src/composables/api/useTimetable.ts b/apps/app/src/composables/api/useTimetable.ts new file mode 100644 index 00000000..f882e77f --- /dev/null +++ b/apps/app/src/composables/api/useTimetable.ts @@ -0,0 +1,162 @@ +import { useQuery } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { computed } from 'vue' +import { apiClient } from '@/lib/axios' +import type { + ArtistEngagement, + Performance, + Stage, +} from '@/types/timetable' + +/** + * 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 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 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 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 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..0adef797 --- /dev/null +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -0,0 +1,352 @@ +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 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 { 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 }) => { + try { + const { data } = await apiClient.post>( + `/organisations/${orgId.value}/events/${eventId.value}/timetable/move`, + payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + + return data.data + } + catch (err) { + if (isVersionMismatch(err)) { + throw { + status: 409, + conflict: err.response.data.errors, + } as VersionMismatchError + } + throw { + status: (err as AxiosError).response?.status ?? 0, + message: (err as AxiosError).message, + } as { status: number; message: string } + } + }, + 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() + }, + }) + + // ─── 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 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 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 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 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, + } +} -- 2.39.5 From 6eb8ae7aa40c7fabcfc9daef480271e5d8f6aba5 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:42:18 +0200 Subject: [PATCH 04/26] feat(timetable): pinia store + CSS tokens (Session 4 steps 5+7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTimetableStore — pinia composition store carrying: - activeDayId synced to ?day query param at the page level - selectedPerformanceId for popover anchor + keyboard focus - drag state (dragPerformanceId / dragOriginSnapshot / dragGhost) for optimistic preview + 409 rollback - statusFilter (defaults: all on except cancelled, per prototype §4.7) - searchQuery for the wachtrij filter styles/tokens/_timetable.scss — RFC v0.2 D21: - 9 status palettes (bg / border / fg / dot custom properties) - cancelled-hatch repeating gradient - conflict / capacity-warn / capacity-critical / B2B / trashed colours - lane geometry (height, gap, padding, block radius) - canvas + axis backgrounds and tick lines - drag-ghost + focus-ring + day-tab chrome - tt-cascade-pulse keyframe animation for D18 cascaded[] visualisation Imported once via assets/styles/styles.scss so the variables are available everywhere via var(--tt-…). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/assets/styles/styles.scss | 3 + apps/app/src/stores/useTimetableStore.ts | 118 +++++++++++++++++ apps/app/src/styles/tokens/_timetable.scss | 140 +++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 apps/app/src/stores/useTimetableStore.ts create mode 100644 apps/app/src/styles/tokens/_timetable.scss diff --git a/apps/app/src/assets/styles/styles.scss b/apps/app/src/assets/styles/styles.scss index 3118a334..9cdb530e 100644 --- a/apps/app/src/assets/styles/styles.scss +++ b/apps/app/src/assets/styles/styles.scss @@ -1 +1,4 @@ // Write your overrides + +// RFC-TIMETABLE v0.2 D21 — status palette + geometry custom properties. +@use "@/styles/tokens/timetable"; diff --git a/apps/app/src/stores/useTimetableStore.ts b/apps/app/src/stores/useTimetableStore.ts new file mode 100644 index 00000000..e5618a6d --- /dev/null +++ b/apps/app/src/stores/useTimetableStore.ts @@ -0,0 +1,118 @@ +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: + * + * - Active sub-event id (synced ↔ ?day query) + * - 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) + */ +export const useTimetableStore = defineStore('timetable', () => { + const activeDayId = ref(null) + 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 setActiveDay(id: string | null): void { + activeDayId.value = id + } + + 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 { + activeDayId, + selectedPerformanceId, + dragPerformanceId, + dragOriginSnapshot, + dragGhost, + statusFilter, + searchQuery, + isDragging, + setActiveDay, + selectPerformance, + startDrag, + updateDragGhost, + endDrag, + toggleStatus, + setStatusFilter, + isStatusVisible, + setSearchQuery, + } +}) diff --git a/apps/app/src/styles/tokens/_timetable.scss b/apps/app/src/styles/tokens/_timetable.scss new file mode 100644 index 00000000..1a223b87 --- /dev/null +++ b/apps/app/src/styles/tokens/_timetable.scss @@ -0,0 +1,140 @@ +// 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. + +: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; +} -- 2.39.5 From 4ed470ac35bbabc12438e1e55f3d15d9672304f9 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:44:59 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat(timetable):=20leaf=20visual=20compon?= =?UTF-8?q?ents=20=E2=80=94=20TimeAxis,=20GridBg,=20StageHeaderCell,=20Per?= =?UTF-8?q?formanceBlock,=20StageRow,=20EmptyDayState=20(Session=204=20ste?= =?UTF-8?q?p=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerformanceBlock is the heart of the canvas: - Status palette via CSS tokens (D21) — one class per booking_status enum value - Cancelled hatch overlay + line-through (D5) - Trashed-artist dashed border + ⌂ overlay icon (D27) - Conflict ring + glow when warnings.includes('overlap') (D5) - Capacity icon driven by evaluateCapacity() with warn/critical levels (D25) - B2B left/right dots (D26 — 3-min threshold) - Cascade-pulse class fired by parent on cascaded[] non-empty (D18) - aria-label structure per D20: artist, stage, time window, status, advancing - tabindex 0 + Enter/Space → select; Delete → emit delete StageRow positions blocks by lane_resolved (D19) — server is authoritative. StageHeaderCell uses Vuexy VMenu pattern for the per-stage actions. EmptyDayState routes the user to LineupMatrix when no stages are active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/timetable/EmptyDayState.vue | 29 ++ apps/app/src/components/timetable/GridBg.vue | 47 +++ .../components/timetable/PerformanceBlock.vue | 338 ++++++++++++++++++ .../components/timetable/StageHeaderCell.vue | 117 ++++++ .../app/src/components/timetable/StageRow.vue | 93 +++++ .../app/src/components/timetable/TimeAxis.vue | 62 ++++ 6 files changed, 686 insertions(+) create mode 100644 apps/app/src/components/timetable/EmptyDayState.vue create mode 100644 apps/app/src/components/timetable/GridBg.vue create mode 100644 apps/app/src/components/timetable/PerformanceBlock.vue create mode 100644 apps/app/src/components/timetable/StageHeaderCell.vue create mode 100644 apps/app/src/components/timetable/StageRow.vue create mode 100644 apps/app/src/components/timetable/TimeAxis.vue 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/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/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 @@ + + + + + -- 2.39.5 From 5b812771de5fe697b2e957310e5d8d75fd1a98f8 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:46:02 +0200 Subject: [PATCH 06/26] feat(timetable): usePointerDrag + useDragOrClick composables (Session 4 step 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePointerDrag — PointerEvents primitive with capture, escape-cancel, keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy mousedown stack the prototype used. useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan, matches prototype audit §4.1). Emits onClick when the pointer never crossed the threshold; otherwise enters drag mode and emits onDragStart / onDragMove / onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end so the synthetic click never opens the popover. RFC v0.2 D7 — implemented once instead of three times like the prototype. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/timetable/useDragOrClick.ts | 89 +++++++++++ .../composables/timetable/usePointerDrag.ts | 148 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 apps/app/src/composables/timetable/useDragOrClick.ts create mode 100644 apps/app/src/composables/timetable/usePointerDrag.ts 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 } +} -- 2.39.5 From 288aebcd699bc0dde35c7849c76febcadd3f14ad Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:53:02 +0200 Subject: [PATCH 07/26] =?UTF-8?q?feat(timetable):=20interactive=20componen?= =?UTF-8?q?ts=20=E2=80=94=20Popover,=20AddPerformanceDialog,=20StageEditor?= =?UTF-8?q?,=20LineupMatrix,=20Wachtrij=20+=20WachtrijCard=20(Session=204?= =?UTF-8?q?=20step=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PerformancePopover.vue — teleported floating panel; closes on Esc; shows status chip, advancing %, computed Buma/VAT/total cost; deal-summary + delete + open-detail buttons. Position math (340px wide, 12px margin, flip side if no room) ports prototype's pickPos verbatim. - AddPerformanceDialog.vue — Vuetify VDialog + raw ref form pattern (matches CreateShiftDialog and the rest of the codebase). Uses createPerformancePayloadSchema for client-side validation; falls back to surface-level errors map per field. - StageEditor.vue — single-stage CRUD modal with name + capacity + 10-swatch palette picker. Window.confirm cascade-park warning on delete. - LineupMatrix.vue — stages × sub-events checkbox matrix; only dirty stages fire replaceStageDays (atomic per stage). - Wachtrij.vue — sidebar with search + 9 toggleable status chips with counts; reads/writes useTimetableStore.statusFilter and searchQuery. - WachtrijCard.vue — initials avatar + status dot + dot label + cancelled strike-through. role=button, tabindex=0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../timetable/AddPerformanceDialog.vue | 195 ++++++++++++++ .../src/components/timetable/LineupMatrix.vue | 218 ++++++++++++++++ .../timetable/PerformancePopover.vue | 238 ++++++++++++++++++ .../src/components/timetable/StageEditor.vue | 215 ++++++++++++++++ .../app/src/components/timetable/Wachtrij.vue | 215 ++++++++++++++++ .../src/components/timetable/WachtrijCard.vue | 143 +++++++++++ 6 files changed, 1224 insertions(+) create mode 100644 apps/app/src/components/timetable/AddPerformanceDialog.vue create mode 100644 apps/app/src/components/timetable/LineupMatrix.vue create mode 100644 apps/app/src/components/timetable/PerformancePopover.vue create mode 100644 apps/app/src/components/timetable/StageEditor.vue create mode 100644 apps/app/src/components/timetable/Wachtrij.vue create mode 100644 apps/app/src/components/timetable/WachtrijCard.vue diff --git a/apps/app/src/components/timetable/AddPerformanceDialog.vue b/apps/app/src/components/timetable/AddPerformanceDialog.vue new file mode 100644 index 00000000..1e36aea6 --- /dev/null +++ b/apps/app/src/components/timetable/AddPerformanceDialog.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/app/src/components/timetable/LineupMatrix.vue b/apps/app/src/components/timetable/LineupMatrix.vue new file mode 100644 index 00000000..54ab4024 --- /dev/null +++ b/apps/app/src/components/timetable/LineupMatrix.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/apps/app/src/components/timetable/PerformancePopover.vue b/apps/app/src/components/timetable/PerformancePopover.vue new file mode 100644 index 00000000..a1ed687e --- /dev/null +++ b/apps/app/src/components/timetable/PerformancePopover.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/apps/app/src/components/timetable/StageEditor.vue b/apps/app/src/components/timetable/StageEditor.vue new file mode 100644 index 00000000..fd84e2ae --- /dev/null +++ b/apps/app/src/components/timetable/StageEditor.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/apps/app/src/components/timetable/Wachtrij.vue b/apps/app/src/components/timetable/Wachtrij.vue new file mode 100644 index 00000000..7c3f00a3 --- /dev/null +++ b/apps/app/src/components/timetable/Wachtrij.vue @@ -0,0 +1,215 @@ + + + + + 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 @@ + + + + + -- 2.39.5 From 43572a7812ffcb5b287053118bd0c81513987645 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:58:56 +0200 Subject: [PATCH 08/26] =?UTF-8?q?feat(timetable):=20keyboard=20a11y=20comp?= =?UTF-8?q?osable=20+=20page=20entry=20=E2=80=94=20Session=204=20step=2011?= =?UTF-8?q?=20+=20ship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTimetableKeyboard (RFC v0.2 D20): - Arrow ←/→ nudges by SNAP_MIN; Shift+Arrow = ±60min - Arrow ↑/↓ shifts lane; Shift+Arrow ↑/↓ = ±1 stage - [/] cycles stages preserving time + lane - Space starts a "keyboard drag" (announced via aria-live), arrows accumulate the offset, Enter commits, Esc cancels - Enter on a focused block opens the popover; Delete confirms+removes - Pure orchestration — the actual mutation goes through useTimetableMutations so keyboard moves inherit optimistic update + 409 rollback pages/events/[id]/timetable/index.vue: - definePage with organizer context + navActiveLink=events - ?day query param ↔ store.activeDayId in both directions - Composes EventTabsNav, TimeAxis, GridBg, StageHeaderCell, StageRow, Wachtrij, PerformancePopover, AddPerformanceDialog, StageEditor, LineupMatrix, EmptyDayState - Conflict pill in toolbar (header total) per prototype audit §4.8 - Status filter chips applied to canvas blocks via store.isStatusVisible - usePointerDrag + useDragOrClick wires drag to a single move() call; on success flashes pulseSet on cascaded[] for 1.5s (D18 + D21 keyframe) - aria-live region echoes keyboard-drag announcements Tweaks for boundary/lint cleanliness: - Dialog props switched from Ref to T + toRef inside (Vue templates auto-unwrap refs; Ref-typed props clashed with template usage) - Wachtrij counts shadow + sonarjs cleanup - no-void watcher Co-Authored-By: Claude Opus 4.7 (1M context) --- .../timetable/AddPerformanceDialog.vue | 17 +- .../src/components/timetable/LineupMatrix.vue | 15 +- .../timetable/PerformancePopover.vue | 8 +- .../src/components/timetable/StageEditor.vue | 15 +- .../app/src/components/timetable/Wachtrij.vue | 14 +- .../timetable/useTimetableKeyboard.ts | 184 +++++ .../src/pages/events/[id]/timetable/index.vue | 645 ++++++++++++++++++ 7 files changed, 868 insertions(+), 30 deletions(-) create mode 100644 apps/app/src/composables/timetable/useTimetableKeyboard.ts create mode 100644 apps/app/src/pages/events/[id]/timetable/index.vue diff --git a/apps/app/src/components/timetable/AddPerformanceDialog.vue b/apps/app/src/components/timetable/AddPerformanceDialog.vue index 1e36aea6..84c0b1de 100644 --- a/apps/app/src/components/timetable/AddPerformanceDialog.vue +++ b/apps/app/src/components/timetable/AddPerformanceDialog.vue @@ -1,6 +1,5 @@ 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/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue new file mode 100644 index 00000000..b0772b8f --- /dev/null +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -0,0 +1,645 @@ + + + + + -- 2.39.5 From 39fdc0fa3dce163c97bf242c5b05056737d55aba Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 02:04:10 +0200 Subject: [PATCH 09/26] =?UTF-8?q?test(timetable):=20Phase=20C=20=E2=80=94?= =?UTF-8?q?=2067=20new=20tests=20(pure=20logic=20+=20composables=20+=20sto?= =?UTF-8?q?re=20+=20schemas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/app/tests/unit/lib/timetable/: - snap.test.ts (5) — rounding, clamp, edge cases - time-grid.test.ts (6) — px↔min↔ISO roundtrips, formatTickLabel - conflict.test.ts (8) — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion - b2b.test.ts (6) — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant - capacity.test.ts (7) — null capacity, missing data, warn/critical, crew+guests preference - lane.test.ts (8) — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion apps/app/tests/unit/composables/: - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade, 409 VersionMismatch surfaced, park sends null, createStage POST path - useDragOrClick.test.ts (3) — onClick fires under threshold, onDragStart+End above threshold, Esc cancels mid-flight apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal) so AxiosError.message and the VersionMismatchError shape both bubble through .catch(). Test count: 252 → 319 (+67). All 42 files pass. Out of scope this session (added to BACKLOG): - ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at the lib/ layer. - ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The aria-label structure on PerformanceBlock + aria-live on the page entry are authored to pass an axe scan when added. - ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs Vuetify + router + msw setup; defer with the component tests above. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/api/useTimetableMutations.ts | 17 +- .../unit/composables/useDragOrClick.test.ts | 77 +++++++ .../composables/useTimetableMutations.test.ts | 201 ++++++++++++++++++ .../app/tests/unit/lib/idempotencyKey.test.ts | 24 +++ apps/app/tests/unit/lib/timetable/b2b.test.ts | 84 ++++++++ .../tests/unit/lib/timetable/capacity.test.ts | 76 +++++++ .../tests/unit/lib/timetable/conflict.test.ts | 116 ++++++++++ .../app/tests/unit/lib/timetable/lane.test.ts | 111 ++++++++++ .../app/tests/unit/lib/timetable/snap.test.ts | 41 ++++ .../unit/lib/timetable/time-grid.test.ts | 47 ++++ apps/app/tests/unit/schemas/timetable.test.ts | 118 ++++++++++ .../unit/stores/useTimetableStore.test.ts | 77 +++++++ 12 files changed, 981 insertions(+), 8 deletions(-) create mode 100644 apps/app/tests/unit/composables/useDragOrClick.test.ts create mode 100644 apps/app/tests/unit/composables/useTimetableMutations.test.ts create mode 100644 apps/app/tests/unit/lib/idempotencyKey.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/b2b.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/capacity.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/conflict.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/lane.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/snap.test.ts create mode 100644 apps/app/tests/unit/lib/timetable/time-grid.test.ts create mode 100644 apps/app/tests/unit/schemas/timetable.test.ts create mode 100644 apps/app/tests/unit/stores/useTimetableStore.test.ts diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts index 0adef797..8a65913c 100644 --- a/apps/app/src/composables/api/useTimetableMutations.ts +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -126,15 +126,16 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { } catch (err) { if (isVersionMismatch(err)) { - throw { - status: 409, - conflict: err.response.data.errors, - } as VersionMismatchError + const mismatch = new Error('version_mismatch') as Error & VersionMismatchError + + mismatch.status = 409 + mismatch.conflict = err.response.data.errors + throw mismatch } - throw { - status: (err as AxiosError).response?.status ?? 0, - message: (err as AxiosError).message, - } as { status: number; message: string } + const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string } + + wrapped.status = (err as AxiosError).response?.status ?? 0 + throw wrapped } }, onMutate: async ({ optimistic }) => { 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..7e4f2e3e --- /dev/null +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -0,0 +1,201 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +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 wrapper = mount(Host, { + global: { plugins: [[VueQueryPlugin, { queryClient }]] }, + }) + + return { wrapper, api, queryClient } +} + +describe('useTimetableMutations', () => { + beforeEach(() => { + 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' } }, + }) + + 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/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..06f22ced --- /dev/null +++ b/apps/app/tests/unit/stores/useTimetableStore.test.ts @@ -0,0 +1,77 @@ +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('setActiveDay updates and selectPerformance maps to id', () => { + const store = useTimetableStore() + + store.setActiveDay('day_1') + expect(store.activeDayId).toBe('day_1') + 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) + }) +}) -- 2.39.5 From 3616b062063394b3970ba59dd99da521ae3d48b4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:17:53 +0200 Subject: [PATCH 10/26] chore(timetable): refresh auto-generated declarations for new components + route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift from Session 4 step 11 — unplugin-vue-components and unplugin-vue-router regenerated their .d.ts files for the new timetable surface. Was missed in the original commit because the test runner doesn't trigger regen. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/components.d.ts | 12 ++++++++++++ apps/app/typed-router.d.ts | 1 + 2 files changed, 13 insertions(+) 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/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 }>, -- 2.39.5 From 5c53dcd2e47edc109cd519e26e87346782859fff Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:21:49 +0200 Subject: [PATCH 11/26] chore(forms): remove unused vee-validate; formalize ref+validators+Zod as canonical pattern Strict-regex sweep of apps/app/src/ confirms zero VeeValidate usage: no `from 'vee-validate'` imports, no , no defineRule(), no useForm(). The 15 prior fuzzy matches were false positives where /useForm/ matched useFormDraft/useFormSteps/ useFormSchemas/useFormFailures. Changes: - Remove `vee-validate` and `@vee-validate/zod` from apps/app/package.json - Regenerate pnpm-lock.yaml (no other deps shifted) - CLAUDE.md "Forms": replace VeeValidate prescription with the actual ref + @core/utils/validators + Zod-payload-schema pattern that the codebase already uses everywhere - VUEXY_COMPONENTS.md: correct the stale "Registration uses VeeValidate" claim (the page actually uses useFormDraft + validators); update the "Form validation" reference row - BACKLOG.md: close VEE-001 with the audit trail All 319 existing tests still pass; vue-tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 18 ++++++++++++++++-- apps/app/package.json | 2 -- apps/app/pnpm-lock.yaml | 30 ------------------------------ dev-docs/BACKLOG.md | 23 +++++++++++++++++++++-- dev-docs/VUEXY_COMPONENTS.md | 9 ++++++--- 5 files changed, 43 insertions(+), 39 deletions(-) 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/apps/app/package.json b/apps/app/package.json index 2786095c..7ad3e112 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", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 89377565..60001748 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) @@ -1931,11 +1925,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} @@ -5001,11 +4990,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==} @@ -7013,14 +6997,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 @@ -10722,12 +10698,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 diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index f7a8a528..793d9e69 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,21 @@ 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. 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 | -- 2.39.5 From b7d814ad8507e49881a6ede07cd95ca88763eda1 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:23:30 +0200 Subject: [PATCH 12/26] refactor(styles): move timetable tokens from .scss to .css for test-time loadability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A finding A2 — `_timetable.scss` was functionally pure CSS: only :root custom properties + @keyframes + one .tt-cascade-pulse class. The only SCSS-specific syntax was `// line comments`. Zero $vars, @use, @mixin, @function, nesting, or color functions. Why move to .css: Vitest+jsdom can `import '@/styles/tokens/_timetable.css'` directly so getComputedStyle() resolves var(--tt-…) in component tests (needed for the upcoming PerformanceBlock visual-state assertions). SCSS imports require Vite's SCSS plugin, which the vitest.config.ts intentionally skips for unit-test speed. Changes: - `_timetable.scss` → `_timetable.css` (line comments converted to /* */ block comments; everything else byte-identical) - `assets/styles/styles.scss`: switch from `@use "@/styles/tokens/timetable"` to `@import "@/styles/tokens/_timetable.css"` - Production `npm run build` passes (16s, no asset warnings) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/assets/styles/styles.scss | 3 +- .../{_timetable.scss => _timetable.css} | 41 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) rename apps/app/src/styles/tokens/{_timetable.scss => _timetable.css} (69%) diff --git a/apps/app/src/assets/styles/styles.scss b/apps/app/src/assets/styles/styles.scss index 9cdb530e..8e6dbc76 100644 --- a/apps/app/src/assets/styles/styles.scss +++ b/apps/app/src/assets/styles/styles.scss @@ -1,4 +1,5 @@ // Write your overrides // RFC-TIMETABLE v0.2 D21 — status palette + geometry custom properties. -@use "@/styles/tokens/timetable"; +// 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/styles/tokens/_timetable.scss b/apps/app/src/styles/tokens/_timetable.css similarity index 69% rename from apps/app/src/styles/tokens/_timetable.scss rename to apps/app/src/styles/tokens/_timetable.css index 1a223b87..f4d31e36 100644 --- a/apps/app/src/styles/tokens/_timetable.scss +++ b/apps/app/src/styles/tokens/_timetable.css @@ -1,17 +1,22 @@ -// 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. +/* 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) ───────────── + /* ─── Status palettes (8 visible + cancelled overlay) ───────────── */ --tt-status-draft-bg: #f1efe9; --tt-status-draft-border: #dcd9d1; @@ -58,7 +63,7 @@ --tt-status-declined-fg: #6b3915; --tt-status-declined-dot: #b56331; - // ─── Cancelled hatch overlay ───────────────────────────────────── + /* ─── Cancelled hatch overlay ───────────────────────────────────── */ --tt-cancelled-hatch: repeating-linear-gradient( 135deg, @@ -68,7 +73,7 @@ rgba(0, 0, 0, 0.05) 8px ); - // ─── Warnings + B2B ────────────────────────────────────────────── + /* ─── Warnings + B2B ────────────────────────────────────────────── */ --tt-conflict-border: #d63d4b; --tt-conflict-glow: rgba(214, 61, 75, 0.25); @@ -82,7 +87,7 @@ --tt-trashed-overlay: rgba(0, 0, 0, 0.35); --tt-trashed-icon: #75706a; - // ─── Geometry ──────────────────────────────────────────────────── + /* ─── Geometry ──────────────────────────────────────────────────── */ --tt-lane-height: 44px; --tt-lane-gap: 4px; @@ -102,21 +107,21 @@ --tt-canvas-grid-major: rgba(0, 0, 0, 0.06); --tt-canvas-grid-minor: rgba(0, 0, 0, 0.025); - // ─── Drop / drag visuals ───────────────────────────────────────── + /* ─── Drop / drag visuals ───────────────────────────────────────── */ --tt-ghost-bg: rgba(255, 215, 90, 0.18); --tt-ghost-border:#f0c45a; --tt-focus-ring: #1f7ad1; - // ─── Day-tab chrome ────────────────────────────────────────────── + /* ─── Day-tab chrome ────────────────────────────────────────────── */ --tt-tab-active-bg: #1f7ad1; --tt-tab-active-fg: #ffffff; --tt-tab-hover-bg: #eef2f7; } -// ─── Animations ───────────────────────────────────────────────────── +/* ─── Animations ─────────────────────────────────────────────────────── */ @keyframes tt-cascade-pulse { 0% { -- 2.39.5 From 5f135ec2b902d85d3d595c817e95f789d2803c00 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:27:31 +0200 Subject: [PATCH 13/26] test: add mountWithVuexy helper, install axe-core, segment vitest configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the upcoming component / integration / a11y tests. vitest.config.ts now declares two projects: - "unit" — pure-logic tests under tests/unit/, src/**/__tests__/, and tests/*.spec.ts (the legacy sanity test). happy-dom, no Vuetify, fast path. - "component" — tests under tests/component/, tests/integration/, tests/a11y/. jsdom, Vuetify inlined via SSR noExternal, CSS imports processed (so :root token sheet loads), and no global vue-router mock so the real router can run. Both share the same alias map and AutoImport bag. tests/utils/mountWithVuexy.ts (new): - Real Vuetify with the Crewli theme tokens - createTestingPinia (actions execute by default; stubActions opt-in) - vue-router with memory history at the configured initialPath + ?query - Fresh QueryClient per call (zero cross-test cache leak) - Notification mock injected via Pinia plugin so any useNotificationStore() resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual NotificationStore API surface (per Phase A finding A4) - Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves var(--tt-…) when components call getComputedStyle() tests/setup.component.ts (new): - vitest-axe matcher registration - JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body bounding rect — Vuetify menus / overlays would crash without them - Deterministic crypto polyfill (mirrors tests/setup.ts so generateIdempotencyKey() is stable, but without the router mock) tests/component/_smoke.test.ts (new): - Mounts a trivial component → asserts wrapper, queryClient, pinia, router, notificationMock all populated - Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg') → asserts '#e8f8f0' (proves the CSS token sheet really loaded) devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing. Total: 319 → 321 tests; 42 → 43 files. Both projects green. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/package.json | 4 + apps/app/pnpm-lock.yaml | 378 +++++++++++++++++++++++- apps/app/tests/component/_smoke.test.ts | 38 +++ apps/app/tests/setup.component.ts | 55 ++++ apps/app/tests/utils/mountWithVuexy.ts | 180 +++++++++++ apps/app/vitest.config.ts | 104 +++++-- 6 files changed, 728 insertions(+), 31 deletions(-) create mode 100644 apps/app/tests/component/_smoke.test.ts create mode 100644 apps/app/tests/setup.component.ts create mode 100644 apps/app/tests/utils/mountWithVuexy.ts diff --git a/apps/app/package.json b/apps/app/package.json index 7ad3e112..dcef67f4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -81,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", @@ -101,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", @@ -127,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", @@ -148,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 60001748..1761002e 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -199,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 @@ -259,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 @@ -337,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) @@ -399,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) @@ -440,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'} @@ -588,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==} @@ -606,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} @@ -819,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==} @@ -1064,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'} @@ -2212,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==} @@ -2226,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'} @@ -2306,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==} @@ -2462,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'} @@ -2481,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'} @@ -2514,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'} @@ -2643,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'} @@ -3280,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'} @@ -3460,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'} @@ -3548,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 @@ -3641,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==} @@ -3656,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==} @@ -3705,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==} @@ -3990,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==} @@ -4270,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} @@ -4397,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} @@ -4719,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} @@ -4770,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'} @@ -4782,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==} @@ -4887,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'} @@ -5086,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} @@ -5232,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==} @@ -5251,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'} @@ -5330,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==} @@ -5489,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 @@ -5696,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 @@ -5718,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) @@ -5858,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 @@ -6130,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 @@ -7400,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 @@ -7414,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: {} @@ -7492,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: {} @@ -7656,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: {} @@ -7668,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 @@ -7696,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 @@ -7830,6 +8100,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -8781,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: {} @@ -8950,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 @@ -9031,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: {} @@ -9108,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: {} @@ -9118,6 +9426,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9209,6 +9519,8 @@ snapshots: mdn-data@2.25.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} meow@13.2.0: {} @@ -9522,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: {} @@ -9811,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 @@ -9968,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 @@ -10352,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 @@ -10403,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 @@ -10416,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): @@ -10520,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): @@ -10800,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)) @@ -10825,6 +11178,7 @@ snapshots: optionalDependencies: '@types/node': 24.9.2 happy-dom: 20.9.0 + jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -10924,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: @@ -10936,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 @@ -11029,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/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/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/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/vitest.config.ts b/apps/app/vitest.config.ts index 0548d309..1ddc2537 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'], + 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'], + }, + }, + }, + }, + ], }, }) -- 2.39.5 From 8d1cb3917264de159acbb6e3f2afdc8f4867c99a Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:32:21 +0200 Subject: [PATCH 14/26] feat(timetable): validate API responses against Zod schemas at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were types-only; nothing parsed actual server responses. Backend → frontend contract drift would only surface as TypeError deep in components. useTimetable.ts queries now parse: - useStages → stageArraySchema.parse() - usePerformances → performanceArraySchema.parse() - useWachtrij → performanceArraySchema.parse() - useEngagement → artistEngagementSchema.parse() useTimetableMutations.ts mutations now parse: - move success → moveTimetableSuccessSchema.parse() - move 409 errors → moveTimetableConflictSchema.parse() (the .errors sub-object — see backend canon at TimetableMoveController:64) - create / updateNotes → performanceSchema.parse() - createStage / updateStage → stageSchema.parse() The move() success parse runs OUTSIDE the try/catch so a Zod failure on a 200 response surfaces as a true error rather than being misclassified as a 409. Per Phase A finding A8 the conflict shape already matches backend field-for-field; no schema correction needed, but the parse() locks future drift in. Regression test (tests/unit/composables/api/zodParseFailure.test.ts): - move() success with missing fields → rejects with ZodError - move() 409 with malformed errors payload → rejects with ZodError - createStage() with missing fields → rejects with ZodError Existing test fixture for createStage was missing created_at/updated_at; fixed in same commit (real backend responses always include them). Test count: 321 → 324. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/composables/api/useTimetable.ts | 17 ++- .../composables/api/useTimetableMutations.ts | 30 ++-- .../composables/api/zodParseFailure.test.ts | 128 ++++++++++++++++++ .../composables/useTimetableMutations.test.ts | 14 +- 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 apps/app/tests/unit/composables/api/zodParseFailure.test.ts diff --git a/apps/app/src/composables/api/useTimetable.ts b/apps/app/src/composables/api/useTimetable.ts index f882e77f..14d4a86f 100644 --- a/apps/app/src/composables/api/useTimetable.ts +++ b/apps/app/src/composables/api/useTimetable.ts @@ -1,13 +1,22 @@ 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 @@ -41,7 +50,7 @@ export function useStages(orgId: Ref, eventId: Ref) { `/organisations/${orgId.value}/events/${eventId.value}/stages`, ) - return data.data + return stageArraySchema.parse(data.data) }, enabled: () => !!orgId.value && !!eventId.value, staleTime: 30_000, @@ -70,7 +79,7 @@ export function usePerformances( `/organisations/${orgId.value}/events/${eventId.value}/performances${params}`, ) - return data.data + return performanceArraySchema.parse(data.data) }, enabled: () => !!orgId.value && !!eventId.value && !!dayId.value, staleTime: 30_000, @@ -92,7 +101,7 @@ export function useWachtrij(orgId: Ref, eventId: Ref) { `/organisations/${orgId.value}/events/${eventId.value}/performances?stage_id=null`, ) - return data.data + return performanceArraySchema.parse(data.data) }, enabled: () => !!orgId.value && !!eventId.value, staleTime: 30_000, @@ -114,7 +123,7 @@ export function useEngagement(orgId: Ref, engagementId: Ref !!orgId.value && !!engagementId.value, staleTime: 30_000, diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts index 8a65913c..8bee4ca1 100644 --- a/apps/app/src/composables/api/useTimetableMutations.ts +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -4,6 +4,12 @@ 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 type { CreatePerformancePayload, CreateStagePayload, @@ -115,21 +121,25 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { MoveContext >({ mutationFn: async ({ payload, idempotencyKey }) => { + let response try { - const { data } = await apiClient.post>( + response = await apiClient.post>( `/organisations/${orgId.value}/events/${eventId.value}/timetable/move`, payload, { headers: { 'Idempotency-Key': idempotencyKey } }, ) - - return data.data } 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 = err.response.data.errors + mismatch.conflict = conflict throw mismatch } const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string } @@ -137,6 +147,10 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { 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] }) @@ -179,7 +193,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { { headers: { 'Idempotency-Key': generateIdempotencyKey() } }, ) - return data.data + return performanceSchema.parse(data.data) }, onSuccess: () => invalidate(), }) @@ -193,7 +207,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { payload, ) - return data.data + return performanceSchema.parse(data.data) }, onSuccess: updated => mergePerformance(updated), }) @@ -260,7 +274,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { payload, ) - return data.data + return stageSchema.parse(data.data) }, onSuccess: () => invalidateStages(), }) @@ -272,7 +286,7 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { payload, ) - return data.data + return stageSchema.parse(data.data) }, onSuccess: () => invalidateStages(), }) 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..12780061 --- /dev/null +++ b/apps/app/tests/unit/composables/api/zodParseFailure.test.ts @@ -0,0 +1,128 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +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 } } }) + + mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] } }) + + return { api } +} + +describe('Zod parse failure on API responses', () => { + beforeEach(() => 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/useTimetableMutations.test.ts b/apps/app/tests/unit/composables/useTimetableMutations.test.ts index 7e4f2e3e..f1798066 100644 --- a/apps/app/tests/unit/composables/useTimetableMutations.test.ts +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -188,7 +188,19 @@ describe('useTimetableMutations', () => { 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' } }, + 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() -- 2.39.5 From e99acbde95745868f570b346e4898a6563562974 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:37:31 +0200 Subject: [PATCH 15/26] fix(timetable): make ?day query the source of truth with validation and fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Phase A finding A6 — the previous three-watcher Pinia-store design had no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly set store.activeDayId to that bogus value and showed an empty page. Cross-org sub-event IDs were silently accepted (backend OrganisationScope returned an empty perf list, so the UI looked broken without telling the user). New design (Session 4 follow-up Step 5): - src/composables/timetable/useActiveDay.ts (NEW) - The URL `?day` is the source of truth; Pinia does NOT hold this value. - `activeDayId` is a computed: queryDay if it appears in `validIds`, else the first valid id, else null when the list is empty. - One corrective watcher (immediate:true, flush:'post') quietly rewrites the URL when `?day` is missing or invalid; runs after Vue settles and after validIds has been recomputed from a fresh fetch. - `setActiveDay(id)` is the user-driven entry point — calls replace(). - Cross-org IDs are blocked transparently: OrganisationScope keeps them out of validIds, so they fail the .includes() check and fall back. - src/stores/useTimetableStore.ts - Removed `activeDayId` state and `setActiveDay()` action; the store docstring now documents that day-state lives at the URL. - src/pages/events/[id]/timetable/index.vue - Replaced the three watchers + onMounted bootstrap with one `useActiveDay({ queryDay, validIds, replace })` call. The day-change side-effect watcher (clear drag, deselect performance) stays. - VTabs binds dayIdRef + setActiveDay directly. - tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests) - Valid ?day=X → activeDayId=X, no URL rewrite. - Missing / invalid / cross-org ?day → fallback + URL replaced once. - Empty validIds → activeDayId=null, URL untouched. - setActiveDay(id) → calls replace. - setActiveDay(null) → no-op. - External URL change (browser back) → activeDayId follows. - validIds populated AFTER mount → fallback fires correctly. - tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId and setActiveDay are GONE from the store surface. Test count: 324 → 333. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/composables/timetable/useActiveDay.ts | 71 ++++++++ .../src/pages/events/[id]/timetable/index.vue | 47 ++--- apps/app/src/stores/useTimetableStore.ts | 13 +- .../tests/unit/pages/timetableDaySync.test.ts | 165 ++++++++++++++++++ .../unit/stores/useTimetableStore.test.ts | 9 +- 5 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 apps/app/src/composables/timetable/useActiveDay.ts create mode 100644 apps/app/tests/unit/pages/timetableDaySync.test.ts 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/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue index b0772b8f..6636a9ba 100644 --- a/apps/app/src/pages/events/[id]/timetable/index.vue +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -1,5 +1,5 @@