From 3536358a59f3584dbdc8345604483ac889fe02eb Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 01:41:04 +0200 Subject: [PATCH] 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, + } +}