From e99acbde95745868f570b346e4898a6563562974 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 03:37:31 +0200 Subject: [PATCH] fix(timetable): make ?day query the source of truth with validation and fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/composables/timetable/useActiveDay.ts | 71 ++++++++ .../src/pages/events/[id]/timetable/index.vue | 47 ++--- apps/app/src/stores/useTimetableStore.ts | 13 +- .../tests/unit/pages/timetableDaySync.test.ts | 165 ++++++++++++++++++ .../unit/stores/useTimetableStore.test.ts | 9 +- 5 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 apps/app/src/composables/timetable/useActiveDay.ts create mode 100644 apps/app/tests/unit/pages/timetableDaySync.test.ts diff --git a/apps/app/src/composables/timetable/useActiveDay.ts b/apps/app/src/composables/timetable/useActiveDay.ts new file mode 100644 index 00000000..d5995fd4 --- /dev/null +++ b/apps/app/src/composables/timetable/useActiveDay.ts @@ -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 + + /** Reactive list of sub-event IDs the user is allowed to see for this event. */ + validIds: Ref + + /** `router.replace` wrapper that updates ONLY the `day` query param. */ + replace: (dayId: string) => void +} + +export interface UseActiveDayReturn { + activeDayId: Ref + setActiveDay: (id: string | null) => void +} + +export function useActiveDay(deps: UseActiveDayDeps): UseActiveDayReturn { + const activeDayId = computed(() => { + 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 } +} diff --git a/apps/app/src/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue index b0772b8f..6636a9ba 100644 --- a/apps/app/src/pages/events/[id]/timetable/index.vue +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -1,5 +1,5 @@