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 @@