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:
2026-05-09 03:37:31 +02:00
parent 8d1cb39172
commit e99acbde95
5 changed files with 274 additions and 31 deletions

View File

@@ -0,0 +1,71 @@
import { computed, watch } from 'vue'
import type { Ref } from 'vue'
/**
* `?day` query param ↔ active sub-event id binding for the timetable page.
*
* The URL is the source of truth. The store does NOT hold this value.
*
* Behaviour (RFC v0.2 §6.2 / Session 4 follow-up Step 5):
* - If `?day=X` is in `validIds` → activeDayId = X.
* - If `?day=X` is missing or invalid → activeDayId = first valid id,
* and the URL is silently rewritten
* via `replace({day: firstValidId})`.
* - If `validIds` is empty (event has → activeDayId = null.
* no sub-events / data still loading)
*
* Cross-org sub-event IDs are blocked transparently: `OrganisationScope`
* on the backend never returns them, so they fail `validIds.includes(...)`
* and fall back to the first valid id from the user's own organisation.
*/
export interface UseActiveDayDeps {
/** Reactive read of `route.query.day`. Must coerce array → string outside. */
queryDay: Ref<string | null>
/** Reactive list of sub-event IDs the user is allowed to see for this event. */
validIds: Ref<string[]>
/** `router.replace` wrapper that updates ONLY the `day` query param. */
replace: (dayId: string) => void
}
export interface UseActiveDayReturn {
activeDayId: Ref<string | null>
setActiveDay: (id: string | null) => void
}
export function useActiveDay(deps: UseActiveDayDeps): UseActiveDayReturn {
const activeDayId = computed<string | null>(() => {
const ids = deps.validIds.value
if (ids.length === 0)
return null
const q = deps.queryDay.value
if (q && ids.includes(q))
return q
return ids[0]
})
// Single corrective watcher — quietly rewrites the URL when the query param
// is missing or invalid. immediate:true so a mount with `?day=null` (or
// an invalid value) corrects the URL on first paint instead of waiting
// for the next user interaction. flush:'post' so it runs after Vue settles
// and after validIds has been recomputed from a fresh fetch.
watch([() => deps.queryDay.value, () => deps.validIds.value], ([q, ids]) => {
if (ids.length === 0)
return
if (q === null || !ids.includes(q))
deps.replace(ids[0])
}, { flush: 'post', immediate: true })
function setActiveDay(id: string | null): void {
if (id === null)
return
if (id === deps.queryDay.value)
return
deps.replace(id)
}
return { activeDayId, setActiveDay }
}