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>
81 lines
2.6 KiB
TypeScript
81 lines
2.6 KiB
TypeScript
import { createPinia, setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { useTimetableStore } from '@/stores/useTimetableStore'
|
|
import { ArtistEngagementStatus, type Performance } from '@/types/timetable'
|
|
|
|
function p(): Performance {
|
|
return {
|
|
id: 'p1',
|
|
engagement_id: 'e1',
|
|
event_id: 'ev1',
|
|
stage_id: 's1',
|
|
lane: 0,
|
|
lane_resolved: 0,
|
|
start_at: '2026-07-10T18:00:00.000Z',
|
|
end_at: '2026-07-10T19:00:00.000Z',
|
|
version: 1,
|
|
notes: null,
|
|
warnings: [],
|
|
created_at: null,
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
}
|
|
}
|
|
|
|
describe('useTimetableStore', () => {
|
|
beforeEach(() => setActivePinia(createPinia()))
|
|
|
|
it('initialises with cancelled OFF in status filter', () => {
|
|
const store = useTimetableStore()
|
|
|
|
expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true)
|
|
expect(store.isStatusVisible(ArtistEngagementStatus.CANCELLED)).toBe(false)
|
|
})
|
|
|
|
it('toggleStatus flips a single status', () => {
|
|
const store = useTimetableStore()
|
|
|
|
store.toggleStatus(ArtistEngagementStatus.CONFIRMED)
|
|
expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(false)
|
|
store.toggleStatus(ArtistEngagementStatus.CONFIRMED)
|
|
expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true)
|
|
})
|
|
|
|
it('selectPerformance maps to id and clears on null', () => {
|
|
// activeDayId / setActiveDay intentionally REMOVED from the store
|
|
// (Step 5: ?day URL is the source of truth, page derives via computed).
|
|
const store = useTimetableStore()
|
|
|
|
expect((store as unknown as { activeDayId?: string }).activeDayId).toBeUndefined()
|
|
expect((store as unknown as { setActiveDay?: unknown }).setActiveDay).toBeUndefined()
|
|
|
|
store.selectPerformance('p1')
|
|
expect(store.selectedPerformanceId).toBe('p1')
|
|
store.selectPerformance(null)
|
|
expect(store.selectedPerformanceId).toBeNull()
|
|
})
|
|
|
|
it('startDrag/endDrag manages snapshot + ghost', () => {
|
|
const store = useTimetableStore()
|
|
|
|
expect(store.isDragging).toBe(false)
|
|
store.startDrag(p())
|
|
expect(store.isDragging).toBe(true)
|
|
expect(store.dragOriginSnapshot?.id).toBe('p1')
|
|
|
|
store.updateDragGhost({ stageId: 's1', startAt: '2026-07-10T18:30:00Z', endAt: '2026-07-10T19:30:00Z', lane: 1 })
|
|
expect(store.dragGhost?.lane).toBe(1)
|
|
|
|
store.endDrag()
|
|
expect(store.isDragging).toBe(false)
|
|
expect(store.dragGhost).toBeNull()
|
|
})
|
|
|
|
it('isStatusVisible handles null gracefully', () => {
|
|
const store = useTimetableStore()
|
|
|
|
expect(store.isStatusVisible(null)).toBe(false)
|
|
expect(store.isStatusVisible(undefined)).toBe(false)
|
|
})
|
|
})
|