From 6eb8ae7aa40c7fabcfc9daef480271e5d8f6aba5 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:42:18 +0200 Subject: [PATCH] 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; +}