feat(timetable): TanStack queries + mutations with optimistic move + cascade pulse (Session 4 steps 3+4)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 01:41:04 +02:00
parent 36525e729a
commit 3536358a59
2 changed files with 514 additions and 0 deletions

View File

@@ -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<T> {
success: boolean
data: T
message?: string
}
interface ResourceCollection<T> {
data: T[]
}
interface ResourceObject<T> {
data: T
}
/**
* Fetch stages for an event (ordered by sort_order, with stage_days).
* Query key: ['timetable', 'stages', eventId].
*/
export function useStages(orgId: Ref<string>, eventId: Ref<string>) {
return useQuery({
queryKey: ['timetable', 'stages', eventId],
queryFn: async (): Promise<Stage[]> => {
const { data } = await apiClient.get<ResourceCollection<Stage>>(
`/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<string>,
eventId: Ref<string>,
dayId: Ref<string | null>,
) {
return useQuery({
queryKey: ['timetable', 'performances', eventId, dayId],
queryFn: async (): Promise<Performance[]> => {
const params = dayId.value ? `?day=${encodeURIComponent(dayId.value)}` : ''
const { data } = await apiClient.get<ResourceCollection<Performance>>(
`/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<string>, eventId: Ref<string>) {
return useQuery({
queryKey: ['timetable', 'wachtrij', eventId],
queryFn: async (): Promise<Performance[]> => {
const { data } = await apiClient.get<ResourceCollection<Performance>>(
`/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<string>, engagementId: Ref<string | null>) {
return useQuery({
queryKey: ['timetable', 'engagement', engagementId],
queryFn: async (): Promise<ArtistEngagement> => {
const { data } = await apiClient.get<ApiResponse<ArtistEngagement>>(
`/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<string>,
eventId: Ref<string>,
dayId: Ref<string | null>,
) {
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 }

View File

@@ -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<string>
eventId: Ref<string>
/** Active sub-event id; `usePerformances` cache invalidation needs it. */
dayId: Ref<string | null>
}
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<Performance[]>(performancesKey() as unknown as readonly unknown[])
const wachtrijCache = queryClient.getQueryData<Performance[]>(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<Performance[]>(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<ApiResponse<MoveTimetableSuccess>>(
`/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<Performance[]>(performancesKey() as unknown as readonly unknown[])
const wachtrijCache = queryClient.getQueryData<Performance[]>(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<Performance> => {
const { data } = await apiClient.post<ApiResponse<Performance>>(
`/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<Performance> => {
const { data } = await apiClient.put<ApiResponse<Performance>>(
`/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<void> => {
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<Stage> => {
const { data } = await apiClient.post<ApiResponse<Stage>>(
`/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<Stage> => {
const { data } = await apiClient.put<ApiResponse<Stage>>(
`/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<Stage[]> => {
const { data } = await apiClient.post<ApiResponse<ResourceCollection<Stage>>>(
`/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<Stage[]>(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<ReplaceStageDaysResponse> => {
const { data } = await apiClient.put<ApiResponse<ReplaceStageDaysResponse>>(
`/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,
}
}