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:
162
apps/app/src/composables/api/useTimetable.ts
Normal file
162
apps/app/src/composables/api/useTimetable.ts
Normal 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 }
|
||||
352
apps/app/src/composables/api/useTimetableMutations.ts
Normal file
352
apps/app/src/composables/api/useTimetableMutations.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user