fix(timetable): make ?day query the source of truth with validation and fallback
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
|
||||
import EmptyDayState from '@/components/timetable/EmptyDayState.vue'
|
||||
@@ -14,6 +14,7 @@ import Wachtrij from '@/components/timetable/Wachtrij.vue'
|
||||
import { useEventChildren, useEventDetail } from '@/composables/api/useEvents'
|
||||
import { useTimetable } from '@/composables/api/useTimetable'
|
||||
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
|
||||
import { useActiveDay } from '@/composables/timetable/useActiveDay'
|
||||
import { useDragOrClick } from '@/composables/timetable/useDragOrClick'
|
||||
import { useTimetableKeyboard } from '@/composables/timetable/useTimetableKeyboard'
|
||||
import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
||||
@@ -59,22 +60,31 @@ const dayOptions = computed(() => {
|
||||
return (subEvents.value ?? []).map(c => ({ id: c.id, name: c.name, start_date: null }))
|
||||
})
|
||||
|
||||
const dayIdRef = computed<string | null>(() => store.activeDayId)
|
||||
// `?day` is the source of truth — Pinia derives from it, never owns it.
|
||||
// Behaviour fully delegated to useActiveDay (RFC §6.2 / Session 4 follow-up
|
||||
// Step 5). Cross-org IDs are blocked transparently: OrganisationScope never
|
||||
// returns them, so they fail validIds.includes(…) and fall back.
|
||||
|
||||
watch(() => route.query.day, raw => {
|
||||
const value = typeof raw === 'string' ? raw : null
|
||||
if (value && value !== store.activeDayId)
|
||||
store.setActiveDay(value)
|
||||
}, { immediate: true })
|
||||
const validSubEventIds = computed(() => dayOptions.value.map(o => o.id))
|
||||
|
||||
watch(dayOptions, opts => {
|
||||
if (!store.activeDayId && opts.length > 0)
|
||||
store.setActiveDay(opts[0].id)
|
||||
const queryDay = computed<string | null>(() => {
|
||||
const v = route.query.day
|
||||
|
||||
return typeof v === 'string' && v.length > 0 ? v : null
|
||||
})
|
||||
|
||||
watch(() => store.activeDayId, id => {
|
||||
if (id && id !== route.query.day)
|
||||
router.replace({ query: { ...route.query, day: id } })
|
||||
const { activeDayId: dayIdRef, setActiveDay } = useActiveDay({
|
||||
queryDay,
|
||||
validIds: validSubEventIds,
|
||||
replace: id => {
|
||||
void router.replace({ query: { ...route.query, day: id } })
|
||||
},
|
||||
})
|
||||
|
||||
// Side effects on day-change: clear drag snapshot + deselect.
|
||||
watch(dayIdRef, () => {
|
||||
store.endDrag()
|
||||
store.selectPerformance(null)
|
||||
})
|
||||
|
||||
const tt = useTimetable(orgId, eventId, dayIdRef)
|
||||
@@ -377,11 +387,8 @@ const { announce } = useTimetableKeyboard({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Fire watch immediately to bind ?day → store.
|
||||
if (typeof route.query.day === 'string')
|
||||
store.setActiveDay(route.query.day)
|
||||
})
|
||||
// `?day` source-of-truth is wired via the dayIdRef computed + the
|
||||
// corrective watcher above; no onMounted bootstrap needed.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -390,9 +397,9 @@ onMounted(() => {
|
||||
<div class="tt-page">
|
||||
<div class="tt-page__toolbar">
|
||||
<VTabs
|
||||
:model-value="store.activeDayId"
|
||||
:model-value="dayIdRef"
|
||||
density="compact"
|
||||
@update:model-value="v => store.setActiveDay(typeof v === 'string' ? v : null)"
|
||||
@update:model-value="v => setActiveDay(typeof v === 'string' ? v : null)"
|
||||
>
|
||||
<VTab
|
||||
v-for="d in dayOptions"
|
||||
|
||||
Reference in New Issue
Block a user