feat: add caching for backend objects
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -55,6 +55,27 @@ You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat app (oysq
|
|||||||
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
- Ionicons only (`ionicons/icons`) — no PrimeIcons
|
||||||
- Always import individual icon names from `ionicons/icons` (tree-shakeable)
|
- Always import individual icon names from `ionicons/icons` (tree-shakeable)
|
||||||
|
|
||||||
|
### Offline Cache
|
||||||
|
- **Rule**: Every table/view read from Supabase must be written to `useAppCache` on success and read from it when offline.
|
||||||
|
- **Composables**: `useAppCache` (localStorage, 24h TTL), `useOfflineStatus` (reactive `isOnline`)
|
||||||
|
- **Pattern** for any data fetch:
|
||||||
|
```typescript
|
||||||
|
const cache = useAppCache()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
|
||||||
|
if (!isOnline.value) {
|
||||||
|
const cached = cache.peek<T>('my-key')
|
||||||
|
if (cached) data.value = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { data: fresh } = await supabase.from('my_table').select('*')
|
||||||
|
data.value = fresh ?? []
|
||||||
|
if (fresh) cache.set('my-key', fresh)
|
||||||
|
```
|
||||||
|
- **Schedule data** is keyed by ISO week Monday: `cache.weekKey(utcIso)` → use keys `intervals:{monday}` and `slots:{monday}`.
|
||||||
|
- **Realtime**: `app/app.vue` subscribes to `reservations`, `intervals`, and `boats` changes and patches the cache in real time. When adding a new table subscription, add it to the `app-cache-sync` channel in `app.vue`.
|
||||||
|
- **Cross-page navigation state** (not persistence): use `useBookingDraft` as a pattern — module-level `ref` set by the source page, consumed (`take()`) by the destination page. Do not use query params for structured objects.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. Do not mix unrelated project contexts in one session.
|
1. Do not mix unrelated project contexts in one session.
|
||||||
|
|||||||
75
app/app.vue
75
app/app.vue
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<IonApp>
|
<IonApp>
|
||||||
|
<!-- Offline indicator — floats above all pages -->
|
||||||
|
<div v-if="!isOnline" class="offline-chip">
|
||||||
|
<IonIcon :icon="cloudOfflineOutline" />
|
||||||
|
<span>Offline</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<IonSplitPane content-id="main-content">
|
<IonSplitPane content-id="main-content">
|
||||||
<IonMenu v-if="authStore.user" content-id="main-content" menu-id="main-menu">
|
<IonMenu v-if="authStore.user" content-id="main-content" menu-id="main-menu">
|
||||||
<IonHeader>
|
<IonHeader>
|
||||||
@@ -91,11 +97,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
homeOutline, calendarOutline, boatOutline, personOutline,
|
homeOutline, calendarOutline, boatOutline, personOutline,
|
||||||
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
|
||||||
layersOutline, settingsOutline, bookmarkOutline,
|
layersOutline, settingsOutline, bookmarkOutline, cloudOfflineOutline,
|
||||||
} from 'ionicons/icons'
|
} from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||||
|
import { useAppCache } from '~/composables/useAppCache'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
const cache = useAppCache()
|
||||||
|
|
||||||
async function closeMenu() {
|
async function closeMenu() {
|
||||||
await menuController.close('main-menu')
|
await menuController.close('main-menu')
|
||||||
@@ -116,4 +126,67 @@ watch(() => authStore.user, (u) => {
|
|||||||
if (u) authStore.fetchMember()
|
if (u) authStore.fetchMember()
|
||||||
else authStore.member = null
|
else authStore.member = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Realtime: keep cache fresh while online ──────────────────────────────────
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supabase = useSupabaseClient() as any
|
||||||
|
|
||||||
|
type ReservationPayload = {
|
||||||
|
eventType: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||||
|
new: { id: string; boat_id: string; start_time: string; end_time: string; status: string } | null
|
||||||
|
old: { id: string; boat_id: string; start_time: string; end_time: string; status: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
supabase
|
||||||
|
.channel('app-cache-sync')
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'reservations' },
|
||||||
|
(payload: ReservationPayload) => {
|
||||||
|
const row = payload.new ?? payload.old
|
||||||
|
if (!row?.start_time) return
|
||||||
|
const wk = cache.weekKey(row.start_time)
|
||||||
|
const slotsKey = `slots:${wk}`
|
||||||
|
type SlotRow = { id: string; boat_id: string; start_time: string; end_time: string; status: string }
|
||||||
|
const cached = cache.peek<SlotRow[]>(slotsKey) ?? []
|
||||||
|
let updated: SlotRow[]
|
||||||
|
if (payload.eventType === 'INSERT' && payload.new) {
|
||||||
|
const { id, boat_id, start_time, end_time, status } = payload.new
|
||||||
|
updated = [...cached, { id, boat_id, start_time, end_time, status }]
|
||||||
|
} else if (payload.eventType === 'UPDATE' && payload.new) {
|
||||||
|
updated = cached.map(s => s.id === payload.new!.id ? { ...s, status: payload.new!.status } : s)
|
||||||
|
} else {
|
||||||
|
updated = cached.filter(s => s.id !== (payload.old?.id))
|
||||||
|
}
|
||||||
|
cache.set(slotsKey, updated)
|
||||||
|
})
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'intervals' },
|
||||||
|
() => cache.invalidatePrefix('intervals:'))
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'boats' },
|
||||||
|
async () => {
|
||||||
|
cache.invalidate('boats')
|
||||||
|
const { data } = await supabase.from('boats').select('*').order('name')
|
||||||
|
if (data) cache.set('boats', data)
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.offline-chip {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||||
|
right: 12px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--ion-color-warning);
|
||||||
|
color: var(--ion-color-warning-contrast);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
74
app/composables/useAppCache.ts
Normal file
74
app/composables/useAppCache.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { weekMonday } from '~/utils/toronto'
|
||||||
|
|
||||||
|
const TTL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(key: string) {
|
||||||
|
return `cache:${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppCache() {
|
||||||
|
function set<T>(key: string, data: T): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey(key), JSON.stringify({ data, ts: Date.now() } satisfies CacheEntry<T>))
|
||||||
|
} catch { /* quota exceeded or unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns data only if fresh (< 24 h). Returns null if stale or absent. */
|
||||||
|
function get<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(key))
|
||||||
|
if (!raw) return null
|
||||||
|
const entry = JSON.parse(raw) as CacheEntry<T>
|
||||||
|
if (Date.now() - entry.ts > TTL_MS) return null
|
||||||
|
return entry.data
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns data regardless of age — for offline fallback when cache is stale. */
|
||||||
|
function peek<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(key))
|
||||||
|
if (!raw) return null
|
||||||
|
return (JSON.parse(raw) as CacheEntry<T>).data
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns age in ms, or null if absent. */
|
||||||
|
function age(key: string): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(key))
|
||||||
|
if (!raw) return null
|
||||||
|
return Date.now() - (JSON.parse(raw) as CacheEntry<unknown>).ts
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate(key: string): void {
|
||||||
|
localStorage.removeItem(storageKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes all cache entries whose key starts with prefix. */
|
||||||
|
function invalidatePrefix(prefix: string): void {
|
||||||
|
const full = storageKey(prefix)
|
||||||
|
const toRemove: string[] = []
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i)
|
||||||
|
if (k?.startsWith(full)) toRemove.push(k)
|
||||||
|
}
|
||||||
|
toRemove.forEach(k => localStorage.removeItem(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key for schedule data (intervals or slots) for the ISO week containing
|
||||||
|
* the given UTC ISO timestamp. Desktop and mobile both key to week-Monday.
|
||||||
|
*/
|
||||||
|
function weekKey(utcIso: string): string {
|
||||||
|
return weekMonday(utcIso.slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { set, get, peek, age, invalidate, invalidatePrefix, weekKey }
|
||||||
|
}
|
||||||
26
app/composables/useBookingDraft.ts
Normal file
26
app/composables/useBookingDraft.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Database } from '~/types/supabase'
|
||||||
|
|
||||||
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
|
|
||||||
|
export interface BookingDraft {
|
||||||
|
boat: Boat
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const _draft = ref<BookingDraft | null>(null)
|
||||||
|
|
||||||
|
export function useBookingDraft() {
|
||||||
|
function set(boat: Boat, startTime: string, endTime: string): void {
|
||||||
|
_draft.value = { boat, startTime, endTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and clears the draft — call once in the destination page. */
|
||||||
|
function take(): BookingDraft | null {
|
||||||
|
const val = _draft.value
|
||||||
|
_draft.value = null
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return { set, take }
|
||||||
|
}
|
||||||
10
app/composables/useOfflineStatus.ts
Normal file
10
app/composables/useOfflineStatus.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const _isOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
window.addEventListener('online', () => { _isOnline.value = true })
|
||||||
|
window.addEventListener('offline', () => { _isOnline.value = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOfflineStatus() {
|
||||||
|
return { isOnline: readonly(_isOnline) }
|
||||||
|
}
|
||||||
@@ -129,11 +129,15 @@ import {
|
|||||||
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||||
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
|
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
|
||||||
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
|
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
|
||||||
|
onIonViewWillEnter,
|
||||||
} from '@ionic/vue'
|
} from '@ionic/vue'
|
||||||
import { timeOutline, warningOutline } from 'ionicons/icons'
|
import { timeOutline, warningOutline } from 'ionicons/icons'
|
||||||
import { useAuthStore } from '~/stores/auth'
|
import { useAuthStore } from '~/stores/auth'
|
||||||
import { toDateToronto } from '~/utils/toronto'
|
import { toDateToronto } from '~/utils/toronto'
|
||||||
import type { Database } from '~/types/supabase'
|
import type { Database } from '~/types/supabase'
|
||||||
|
import { useBookingDraft } from '~/composables/useBookingDraft'
|
||||||
|
import { useAppCache } from '~/composables/useAppCache'
|
||||||
|
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||||
|
|
||||||
type Boat = Database['public']['Tables']['boats']['Row']
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
type Interval = Database['public']['Tables']['intervals']['Row']
|
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||||
@@ -144,7 +148,9 @@ definePageMeta({ layout: false })
|
|||||||
const supabase = useSupabaseClient() as any
|
const supabase = useSupabaseClient() as any
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const { take: takeDraft } = useBookingDraft()
|
||||||
|
const cache = useAppCache()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
|
||||||
const step = ref<1 | 2>(1)
|
const step = ref<1 | 2>(1)
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
@@ -182,28 +188,28 @@ interface BoatSlotEntry {
|
|||||||
|
|
||||||
const availableByBoat = ref<BoatSlotEntry[]>([])
|
const availableByBoat = ref<BoatSlotEntry[]>([])
|
||||||
|
|
||||||
// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime=
|
// onIonViewWillEnter fires every visit, including re-entry to a cached Ionic page
|
||||||
onMounted(async () => {
|
onIonViewWillEnter(async () => {
|
||||||
const { boatId, startTime, endTime } = route.query as Record<string, string>
|
const draft = takeDraft()
|
||||||
if (boatId && startTime && endTime) {
|
if (draft) {
|
||||||
const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single()
|
selectedBoat.value = draft.boat
|
||||||
if (boat) {
|
selectedSlot.value = {
|
||||||
selectedBoat.value = boat as Boat
|
id: '',
|
||||||
selectedSlot.value = {
|
boat_id: draft.boat.id,
|
||||||
id: '',
|
start_time: draft.startTime,
|
||||||
boat_id: boatId,
|
end_time: draft.endTime,
|
||||||
start_time: startTime,
|
user_id: null,
|
||||||
end_time: endTime,
|
created_at: '',
|
||||||
user_id: null,
|
|
||||||
created_at: '',
|
|
||||||
}
|
|
||||||
// Set the date strip to the slot's date so Back returns to correct day
|
|
||||||
selectedDate.value = toDateToronto(new Date(startTime))
|
|
||||||
step.value = 2
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
selectedDate.value = toDateToronto(new Date(draft.startTime))
|
||||||
|
step.value = 2
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Normal flow: load slots for today
|
// Normal flow: reset to step 1 and load slots for today
|
||||||
|
step.value = 1
|
||||||
|
selectedBoat.value = null
|
||||||
|
selectedSlot.value = null
|
||||||
|
selectedDate.value = todayIso()
|
||||||
await loadSlots()
|
await loadSlots()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,37 +221,50 @@ async function loadSlots() {
|
|||||||
|
|
||||||
const dayStart = selectedDate.value + 'T00:00:00Z'
|
const dayStart = selectedDate.value + 'T00:00:00Z'
|
||||||
const dayEnd = selectedDate.value + 'T23:59:59Z'
|
const dayEnd = selectedDate.value + 'T23:59:59Z'
|
||||||
|
const wk = cache.weekKey(selectedDate.value + 'T12:00:00Z')
|
||||||
|
|
||||||
// Load available intervals + boats (booking_available = true) for selected date
|
type BookedSlot = { boat_id: string; start_time: string; end_time: string }
|
||||||
const { data: intervals, error } = await supabase
|
let intervals: unknown[] | null = null
|
||||||
.from('intervals')
|
let bookedSlots: BookedSlot[] | null = null
|
||||||
.select('*, boats!inner(id, name, display_name, class, img_src, booking_available, required_certs, max_passengers, defects, year, icon_src, created_at)')
|
|
||||||
.gte('start_time', dayStart)
|
|
||||||
.lte('start_time', dayEnd)
|
|
||||||
.eq('boats.booking_available', true)
|
|
||||||
.order('start_time', { ascending: true })
|
|
||||||
|
|
||||||
if (error) { loadingSlots.value = false; return }
|
if (!isOnline.value) {
|
||||||
|
const cachedIntervals = cache.peek<unknown[]>(`intervals:${wk}`)
|
||||||
|
const cachedSlots = cache.peek<BookedSlot[]>(`slots:${wk}`)
|
||||||
|
intervals = cachedIntervals?.filter(r => {
|
||||||
|
const row = r as { start_time: string }
|
||||||
|
return row.start_time >= dayStart && row.start_time <= dayEnd
|
||||||
|
}) ?? []
|
||||||
|
bookedSlots = cachedSlots?.filter(s => s.start_time >= dayStart && s.start_time <= dayEnd) ?? []
|
||||||
|
} else {
|
||||||
|
const { data: intData, error } = await supabase
|
||||||
|
.from('intervals')
|
||||||
|
.select('*, boats!inner(id, name, display_name, class, img_src, booking_available, required_certs, max_passengers, defects, year, icon_src, created_at)')
|
||||||
|
.gte('start_time', dayStart)
|
||||||
|
.lte('start_time', dayEnd)
|
||||||
|
.eq('boats.booking_available', true)
|
||||||
|
.order('start_time', { ascending: true })
|
||||||
|
if (error) { loadingSlots.value = false; return }
|
||||||
|
intervals = intData
|
||||||
|
|
||||||
// Load booked slots via reservation_slots view (hides personal details)
|
const { data: slotData } = await supabase
|
||||||
const { data: bookedSlots } = await supabase
|
.from('reservation_slots')
|
||||||
.from('reservation_slots')
|
.select('boat_id, start_time, end_time')
|
||||||
.select('boat_id, start_time, end_time')
|
.gte('start_time', dayStart)
|
||||||
.gte('start_time', dayStart)
|
.lte('start_time', dayEnd)
|
||||||
.lte('start_time', dayEnd)
|
bookedSlots = slotData
|
||||||
|
}
|
||||||
|
|
||||||
const booked = new Set<string>()
|
const booked = new Set<string>()
|
||||||
for (const r of (bookedSlots ?? []) as { boat_id: string; start_time: string; end_time: string }[]) {
|
for (const r of (bookedSlots ?? []) as BookedSlot[]) {
|
||||||
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
|
booked.add(`${r.boat_id}|${r.start_time}|${r.end_time}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberCerts: string[] = auth.member?.certifications ?? []
|
const memberCerts: string[] = auth.member?.certifications ?? []
|
||||||
|
|
||||||
// Group by boat, filtering out booked slots
|
|
||||||
const byBoat = new Map<string, BoatSlotEntry>()
|
const byBoat = new Map<string, BoatSlotEntry>()
|
||||||
for (const row of intervals ?? []) {
|
for (const row of intervals ?? []) {
|
||||||
const boat = (row as unknown as { boats: Boat }).boats
|
const boat = (row as unknown as { boats: Boat }).boats
|
||||||
const key = `${boat.id}|${row.start_time}|${row.end_time}`
|
const key = `${boat.id}|${(row as { start_time: string }).start_time}|${(row as { end_time: string }).end_time}`
|
||||||
if (booked.has(key)) continue
|
if (booked.has(key)) continue
|
||||||
|
|
||||||
if (!byBoat.has(boat.id)) {
|
if (!byBoat.has(boat.id)) {
|
||||||
@@ -291,14 +310,25 @@ async function submitReservation() {
|
|||||||
|
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
|
||||||
if (response.error || (response.data as { error?: { code: string; message: string } })?.error) {
|
type ApiErrorBody = { error?: { code: string; message: string } }
|
||||||
const apiError = (response.data as { error?: { code: string; message: string } })?.error
|
// functions-js v2 sets data=null on non-2xx and stores the raw Response on error.context
|
||||||
|
let apiError: { code: string; message: string } | undefined =
|
||||||
|
(response.data as ApiErrorBody)?.error
|
||||||
|
if (!apiError && response.error) {
|
||||||
|
try {
|
||||||
|
const body: ApiErrorBody = await (response.error as { context?: Response }).context?.json()
|
||||||
|
apiError = body?.error
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError || response.error) {
|
||||||
const codeMessages: Record<string, string> = {
|
const codeMessages: Record<string, string> = {
|
||||||
cert_required: 'You are not certified for this boat.',
|
cert_required: 'You are not certified for this boat.',
|
||||||
slot_taken: 'This slot was just booked by someone else.',
|
slot_taken: 'This slot was just booked by someone else.',
|
||||||
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
|
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
|
||||||
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
|
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
|
||||||
boat_unavailable: 'This boat is currently out of service.',
|
boat_unavailable: 'This boat is currently out of service.',
|
||||||
|
historical_booking_not_allowed: apiError?.message ?? 'Can not book a reservation in the past.',
|
||||||
}
|
}
|
||||||
toast.message = (apiError?.code && codeMessages[apiError.code]) || apiError?.message || 'Failed to create reservation.'
|
toast.message = (apiError?.code && codeMessages[apiError.code]) || apiError?.message || 'Failed to create reservation.'
|
||||||
toast.color = 'danger'
|
toast.color = 'danger'
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ import {
|
|||||||
todayToronto, toDateToronto, addDays,
|
todayToronto, toDateToronto, addDays,
|
||||||
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
||||||
} from '~/utils/toronto'
|
} from '~/utils/toronto'
|
||||||
|
import { useAppCache } from '~/composables/useAppCache'
|
||||||
|
import { useOfflineStatus } from '~/composables/useOfflineStatus'
|
||||||
|
import { useBookingDraft } from '~/composables/useBookingDraft'
|
||||||
|
|
||||||
type Boat = Database['public']['Tables']['boats']['Row']
|
type Boat = Database['public']['Tables']['boats']['Row']
|
||||||
type Interval = Database['public']['Tables']['intervals']['Row']
|
type Interval = Database['public']['Tables']['intervals']['Row']
|
||||||
@@ -167,6 +170,9 @@ definePageMeta({ layout: false, middleware: ['auth'] })
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const supabase = useSupabaseClient() as any
|
const supabase = useSupabaseClient() as any
|
||||||
|
const cache = useAppCache()
|
||||||
|
const { isOnline } = useOfflineStatus()
|
||||||
|
const { set: setDraft } = useBookingDraft()
|
||||||
|
|
||||||
// ── Responsive ──────────────────────────────────────────────
|
// ── Responsive ──────────────────────────────────────────────
|
||||||
const isMobile = ref(true)
|
const isMobile = ref(true)
|
||||||
@@ -200,23 +206,42 @@ const intervals = ref<Interval[]>([])
|
|||||||
const slotViews = ref<SlotView[]>([])
|
const slotViews = ref<SlotView[]>([])
|
||||||
|
|
||||||
async function fetchBoats() {
|
async function fetchBoats() {
|
||||||
|
if (!isOnline.value) {
|
||||||
|
const cached = cache.peek<Boat[]>('boats')
|
||||||
|
if (cached) boats.value = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
const { data } = await supabase.from('boats').select('*').order('name')
|
const { data } = await supabase.from('boats').select('*').order('name')
|
||||||
boats.value = data ?? []
|
boats.value = data ?? []
|
||||||
|
if (data) cache.set('boats', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSchedule() {
|
async function fetchSchedule() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const dates = visibleDates.value
|
const dates = visibleDates.value
|
||||||
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
const wk = cache.weekKey(dates[0]!)
|
||||||
|
|
||||||
|
if (!isOnline.value) {
|
||||||
|
const cachedIntervals = cache.peek<Interval[]>(`intervals:${wk}`)
|
||||||
|
const cachedSlots = cache.peek<SlotView[]>(`slots:${wk}`)
|
||||||
|
if (cachedIntervals) intervals.value = cachedIntervals
|
||||||
|
if (cachedSlots) slotViews.value = cachedSlots
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = utcRange(dates[0]!, dates[dates.length - 1]!)
|
||||||
const [intRes, slotRes] = await Promise.all([
|
const [intRes, slotRes] = await Promise.all([
|
||||||
supabase.from('intervals').select('*').gte('start_time', from).lte('start_time', to),
|
supabase.from('intervals').select('*').gte('start_time', from).lte('start_time', to),
|
||||||
supabase.from('reservation_slots').select('*').gte('start_time', from).lte('start_time', to),
|
supabase.from('reservation_slots').select('*').gte('start_time', from).lte('start_time', to),
|
||||||
])
|
])
|
||||||
|
|
||||||
intervals.value = intRes.data ?? []
|
intervals.value = intRes.data ?? []
|
||||||
slotViews.value = slotRes.data ?? []
|
slotViews.value = slotRes.data ?? []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
||||||
|
if (intRes.data) cache.set(`intervals:${wk}`, intRes.data)
|
||||||
|
if (slotRes.data) cache.set(`slots:${wk}`, slotRes.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -284,14 +309,9 @@ function slotLabelShort(slot: SlotBlock): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bookSlot(slot: SlotBlock) {
|
function bookSlot(slot: SlotBlock) {
|
||||||
router.push({
|
const boat = boats.value.find(b => b.id === slot.boatId)
|
||||||
path: '/reservations/create',
|
if (boat) setDraft(boat, slot.startTime, slot.endTime)
|
||||||
query: {
|
router.push('/reservations/create')
|
||||||
boatId: slot.boatId,
|
|
||||||
startTime: slot.startTime,
|
|
||||||
endTime: slot.endTime,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotTitle(slot: SlotBlock): string {
|
function slotTitle(slot: SlotBlock): string {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Session Handoff: Project Brief + Web Awesome Spike
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Session Duration:** ~1.5 hours
|
||||||
|
**Session Focus:** Created project brief, evaluated and spiked Web Awesome as a PrimeVue replacement, abandoned after discovering no calendar component
|
||||||
|
**Context Usage at Handoff:** ~50%
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
1. Created project brief template → `docs/summaries/00-project-brief.md` (user filled in club name, deadline, boat/member counts, booking rules)
|
||||||
|
2. Evaluated Web Awesome as PrimeVue replacement — determined feasible given PrimeVue was only used for `<DatePicker>` in one file
|
||||||
|
3. Spiked Web Awesome Pro installation: configured `@web.awesome.me` registry on Cloudsmith, fixed stray backslash in auth token, installed `@web.awesome.me/webawesome-pro@3.5.0`
|
||||||
|
4. Discovered Web Awesome Pro 3.5.0 has no calendar component → abandoned spike, reverted to main
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
- Web Awesome spike: fully reverted — main branch is clean, `node_modules` restored from lockfile
|
||||||
|
- `docs/summaries/00-project-brief.md`: created and partially filled; `[FILL: Current Phase]` and booking rule detail `[FILL: any other rules]` remain open
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
- **Abandon Web Awesome**: no calendar/date-picker component in v3.5.0 — STATUS: confirmed
|
||||||
|
- **Keep PrimeVue**: only one component in use (`<DatePicker inline>` on home page); not worth replacing until a suitable alternative exists — STATUS: confirmed
|
||||||
|
- **`WEBAWESOME_NPM_TOKEN` in `.env`**: registry token stored in `.env` (gitignored), referenced via `${WEBAWESOME_NPM_TOKEN}` in `.npmrc`/`.yarnrc` — STATUS: confirmed pattern for future private registries
|
||||||
|
- **`webawesome` branch deleted (implicitly)**: all work was uncommitted; restored via `git restore` + `yarn install` — no branch to clean up
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
- PrimeVue usage in app: 1 component (`<DatePicker>` in `app/pages/index.vue:59`)
|
||||||
|
- Web Awesome Pro version spiked: 3.5.0
|
||||||
|
- Cloudsmith registry: `https://npm.cloudsmith.io/fortawesome/webawesome-pro/`
|
||||||
|
- Deadline: April 30 (9 days away at time of handoff)
|
||||||
|
- Boats in program: 4
|
||||||
|
- Members: 20–30
|
||||||
|
- Weekly pre-booking limit: 2
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `docs/summaries/00-project-brief.md` | Created | Project brief — club name, personas, stack, CI/CD, constraints, booking rules; partially filled |
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
1. **First**: Decide what replaces the `<DatePicker>` on the home page — options: remove the Calendar card entirely (simplest, given deadline), keep PrimeVue just for that one component, or use a native `<input type="date">` unstyled
|
||||||
|
2. **Then**: Build out reservations UI — `app/pages/reservations/create.vue` exists (scaffolded with Ionic components); wire it to the `create-reservation` Edge Function
|
||||||
|
3. **Then**: Build `app/pages/admin/reservations.vue` (exists as untracked file per git status) — admin view of all bookings
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
- [ ] What replaces `<DatePicker inline>` on the home page? Remove card, keep PrimeVue, or native input? — impacts whether PrimeVue stays in the stack
|
||||||
|
- [ ] What is the full set of admin pages needed beyond `reservations` and `boat`? — impacts session planning before April 30
|
||||||
|
- [ ] Are cancel-reservation and other Edge Functions planned? — impacts backend scope
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
- ASSUMED: `app/pages/admin/reservations.vue` is scaffolded but incomplete — verify by reading file
|
||||||
|
- ASSUMED: `app/pages/reservations/create.vue` is scaffolded but not wired to Edge Function — verify by reading file
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
- `docs/summaries/handoff-2026-04-21-project-brief-webawesome-spike.md` — this file
|
||||||
|
- `docs/summaries/00-project-brief.md` — for project context
|
||||||
|
- `app/pages/reservations/create.vue` — if working on reservations UI
|
||||||
|
- `app/pages/admin/reservations.vue` — if working on admin bookings view
|
||||||
90
docs/summaries/00-project-brief.md
Normal file
90
docs/summaries/00-project-brief.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Project Brief: OYS Borrow a Boat (oysqn.app)
|
||||||
|
**Created:** 2026-04-21
|
||||||
|
**Owner:** Patrick Toal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- **App name:** OYS Borrow a Boat (oysqn.app)
|
||||||
|
- **Previous app:** bab-app (Appwrite backend — retired)
|
||||||
|
- **Purpose:** Manage a Borrow a Boat program for a Yacht Club (OYS = Oakville Yacht Squadron)
|
||||||
|
- **Club:** Oakville Yacht Club, Oakville, ON Canada
|
||||||
|
- **Target users:** Club members enrolled in the Borrow a Boat program
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Personas
|
||||||
|
|
||||||
|
| Persona | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| BAB Member | Enrolled club member; can browse boats and make reservations |
|
||||||
|
| Certified Skipper | Credentialed to take boats without supervision |
|
||||||
|
| Program Administrator | Manages boats, intervals, rules, and member access |
|
||||||
|
| Boatswain | Responsible for boat maintenance and readiness |
|
||||||
|
| Volunteer | Assists with program logistics |
|
||||||
|
| Instructor | Delivers sailing instruction; may certify members |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Framework | Nuxt 4 (SSR=false, SPA mode) |
|
||||||
|
| UI | Ionic Vue (@ionic/vue) + PrimeVue 4 |
|
||||||
|
| Language | TypeScript |
|
||||||
|
| Backend | Supabase (Auth, DB, Edge Functions, Storage) |
|
||||||
|
| Testing | Vitest (unit), Vitest (integration, hits real Supabase), Playwright (E2E) |
|
||||||
|
| Package manager | Yarn |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Highlights
|
||||||
|
|
||||||
|
- `app/app.vue` — IonApp + IonMenu + IonRouterOutlet (no NuxtLayout/NuxtPage)
|
||||||
|
- Auth: magic link + OTP only; no password auth; no self-service signup; admin-only invite
|
||||||
|
- Edge Functions: Bearer JWT → `adminClient.auth.getUser(token)` pattern; all DB ops via service role
|
||||||
|
- Icons: Ionicons only (`ionicons/icons`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
- **Source control:** Gitea
|
||||||
|
- **CI:** Gitea Actions (unit tests + build on PR)
|
||||||
|
- **Deploy (dev):** EDA (Event-Driven Ansible) → AAP → nginx artifact swap on bab1
|
||||||
|
- **Deploy (prod):** EDA → AAP (manual approval gate) → S3 sync
|
||||||
|
- **E2E:** Playwright, run post-deploy via AAP
|
||||||
|
- **Supabase environments:** Two projects — dev and prod (project IDs in Vault)
|
||||||
|
- **Secrets:** HashiCorp Vault at `http://nas.lan.toal.ca:8200`, path `kv/oys/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Phase
|
||||||
|
|
||||||
|
[FILL: e.g., "Active development — reservations UI", "Beta testing", "Production"]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
|
||||||
|
- Deadline: April 30th (Launch Day)
|
||||||
|
- Four boats in the program
|
||||||
|
- Between 20 - 30 Members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Booking Rules (known so far)
|
||||||
|
|
||||||
|
- Weekly pre-booking limit enforced (exact number: 2)
|
||||||
|
- Overlap constraint: same boat cannot be double-booked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Items
|
||||||
|
|
||||||
|
- [ ] Are cancel-reservation and admin Edge Functions planned?
|
||||||
|
- [ ] What is the full set of admin pages needed?
|
||||||
|
- [ ] What is the club name for display in the app?
|
||||||
|
- [X] What is the season start date / go-live target?
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Session Handoff: Historical Booking Constraint + Offline Cache + Booking Draft
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Session Duration:** ~2 hours
|
||||||
|
**Session Focus:** Enforced historical booking rule, fixed Edge Function error surfacing, implemented offline cache system with Realtime updates, and fixed slot-click navigation from schedule to create page using a booking draft composable.
|
||||||
|
**Context Usage at Handoff:** ~75%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Accomplished
|
||||||
|
|
||||||
|
1. **Historical booking constraint** — tests written and Edge Function enforced → `tests/integration/booking-constraints.test.ts`, `supabase/functions/create-reservation/index.ts`
|
||||||
|
2. **Error message fix** — "Can not book a reservation in the past." is the user-facing message for `historical_booking_not_allowed` (422)
|
||||||
|
3. **Fixed `functions-js` v2 error body parsing** — `response.data` is `null` on non-2xx; body must be read from `response.error.context.json()` → `app/pages/reservations/create.vue`
|
||||||
|
4. **Historical booking uses `end_time`** — a booking is historical only when its `end_time < now()` (not `start_time`)
|
||||||
|
5. **Offline cache system** — localStorage, 24h TTL, Realtime-updated → `app/composables/useAppCache.ts`, `app/composables/useOfflineStatus.ts`
|
||||||
|
6. **Booking draft composable** — module-level ref for cross-page state (replaces broken query-param deep-link) → `app/composables/useBookingDraft.ts`
|
||||||
|
7. **Offline indicator** — fixed chip in top-right corner in `app/app.vue`; Realtime channel `app-cache-sync` patches `slots:`, `intervals:`, and `boats` cache on DB changes
|
||||||
|
8. **Schedule page cache integration** → `app/pages/schedule.vue`: `fetchBoats` and `fetchSchedule` write to cache on success, read from cache when offline
|
||||||
|
9. **Create page booking draft** → `app/pages/reservations/create.vue`: `onIonViewWillEnter` reads draft (no async fetch needed), jumps to step 2 with pre-filled boat + slot; also cache-aware `loadSlots` for offline step 1
|
||||||
|
10. **CLAUDE.md caching rule documented** — "Every table/view read from Supabase must be written to `useAppCache` on success and read from it when offline" — includes pattern, key conventions, Realtime guidance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exact State of Work in Progress
|
||||||
|
|
||||||
|
- **Booking draft navigation**: complete — clicking a slot in schedule sets draft and routes to `/reservations/create`; create page reads draft in `onIonViewWillEnter` and goes directly to step 2
|
||||||
|
- **Offline cache**: complete for `boats`, `intervals`, `slots` (schedule + create pages); NOT yet applied to other pages (`/boat`, `/reference`, `/profile`, admin pages)
|
||||||
|
- **Realtime**: subscribed to `reservations`, `intervals`, `boats` in `app.vue`; if new tables are cached in future, add subscriptions to `app-cache-sync` channel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Made This Session
|
||||||
|
|
||||||
|
- **Historical = `end_time < now()`** BECAUSE a session that started in the past but hasn't ended yet is still bookable — STATUS: confirmed
|
||||||
|
- **Query params abandoned for slot navigation** BECAUSE `+` in ISO timestamps (`+00:00`) URL-encodes to `%2B`, which can be decoded as a space by some parsers; async boat fetch adds an auth-timing race; `onIonViewWillEnter` route-timing is unreliable — CHOSE module-level reactive `ref` (booking draft) instead — STATUS: confirmed
|
||||||
|
- **`functions-js` v2 error body is in `error.context`, not `data`** — `data` is `null` on non-2xx; `error.context` is the raw `Response` — applies to all error code handling in `create.vue` — STATUS: confirmed
|
||||||
|
- **Cache key for schedule data = ISO week Monday** (`cache.weekKey(utcIso)` → `weekMonday(date)`) so desktop (week view) and mobile (day view) share the same cache entries — STATUS: confirmed
|
||||||
|
- **Offline = read-only** — booking submission is not attempted offline (Edge Function call fails naturally); no explicit offline guard on submit — ASSUMED acceptable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Numbers Generated or Discovered This Session
|
||||||
|
|
||||||
|
- `functions-js` version in use: `2.100.0` (confirmed from `node_modules`)
|
||||||
|
- Cache TTL: 24 hours (ms: `86_400_000`)
|
||||||
|
- Realtime channel name: `app-cache-sync`
|
||||||
|
- Error code for historical booking: `historical_booking_not_allowed` (HTTP 422)
|
||||||
|
- localStorage key prefix: `cache:` (e.g., `cache:boats`, `cache:slots:2026-04-20`, `cache:intervals:2026-04-20`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created or Modified
|
||||||
|
|
||||||
|
| File Path | Action | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `app/composables/useOfflineStatus.ts` | Created | Module-level `isOnline` ref; wires `online`/`offline` browser events |
|
||||||
|
| `app/composables/useAppCache.ts` | Created | localStorage cache; `get` (TTL-enforced), `peek` (stale-ok), `set`, `invalidate`, `invalidatePrefix`, `weekKey` |
|
||||||
|
| `app/composables/useBookingDraft.ts` | Created | Module-level `BookingDraft` ref; `set(boat, startTime, endTime)` / `take()` |
|
||||||
|
| `app/app.vue` | Modified | Offline chip (fixed top-right, warning colour); Realtime `app-cache-sync` channel for `reservations`, `intervals`, `boats` |
|
||||||
|
| `app/pages/schedule.vue` | Modified | `fetchBoats`/`fetchSchedule` cache-aware; `bookSlot` sets draft instead of query params |
|
||||||
|
| `app/pages/reservations/create.vue` | Modified | `onIonViewWillEnter` consumes draft (jumps to step 2); `loadSlots` cache-aware; `functions-js` v2 error body fix; `historical_booking_not_allowed` in `codeMessages` |
|
||||||
|
| `supabase/functions/create-reservation/index.ts` | Modified | Historical guard: `endDate < now()` → admin-only (422 `historical_booking_not_allowed`); admin skips cert check + booking-limit checks |
|
||||||
|
| `tests/integration/booking-constraints.test.ts` | Modified | Added `describe('historical booking constraint')`: member rejected (422), admin allowed (201), skipper rejected (422) |
|
||||||
|
| `CLAUDE.md` | Modified | Added "Offline Cache" section under Architecture with pattern, key conventions, Realtime guidance, and booking-draft pattern |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the NEXT Session Should Do
|
||||||
|
|
||||||
|
1. **Verify slot-click flow end to end**: open schedule page, click an available slot, confirm create page opens at step 2 with the correct boat name and time pre-filled, submit a reservation
|
||||||
|
2. **Apply cache pattern to remaining pages**: `/boat` (boats list), `/reference` (reference_docs), `/profile` (member record), admin pages as needed — follow the pattern documented in `CLAUDE.md`
|
||||||
|
3. **Decide on the DatePicker replacement** (OPEN from previous session): `<DatePicker inline>` on `app/pages/index.vue:59` is the only PrimeVue usage — options are remove the calendar card, keep PrimeVue, or use native `<input type="date">`
|
||||||
|
4. **Build `app/pages/admin/reservations.vue`** — admin view of all bookings (file exists, likely scaffolded)
|
||||||
|
5. **Wire cancel-reservation** — OPEN: is a cancel Edge Function planned?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions Requiring User Input
|
||||||
|
|
||||||
|
- [ ] `app/pages/index.vue:59` — what replaces `<DatePicker inline>`? Remove card, keep PrimeVue, or native input? Impacts whether PrimeVue stays in the stack
|
||||||
|
- [ ] Is a cancel-reservation Edge Function planned? Impacts backend scope before April 30
|
||||||
|
- [ ] Should offline submission attempts show an explicit "You are offline — cannot book" message rather than a generic network error?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions That Need Validation
|
||||||
|
|
||||||
|
- ASSUMED: `app/pages/admin/reservations.vue` is scaffolded but incomplete — verify by reading the file
|
||||||
|
- ASSUMED: offline booking submission failing silently (network error toast) is acceptable — validate with Patrick
|
||||||
|
- ASSUMED: `onIonViewWillEnter` fires before the user can interact with the page — if there is a visible flash of step 1 before the draft is consumed, add `step.value = 2` synchronously before the `await` in the handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Load Next Session
|
||||||
|
|
||||||
|
- `docs/summaries/handoff-2026-04-21-historical-booking-offline-cache.md` — this file
|
||||||
|
- `docs/summaries/00-project-brief.md` — project context
|
||||||
|
- `app/pages/reservations/create.vue` — if continuing booking flow work
|
||||||
|
- `app/pages/admin/reservations.vue` — if building admin bookings view
|
||||||
|
- `app/pages/index.vue` — if resolving the DatePicker question
|
||||||
@@ -87,4 +87,4 @@ Error generating stack: `+l.message+`
|
|||||||
<div id='root'></div>
|
<div id='root'></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<template id="playwrightReportBase64">data:application/zip;base64,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</template>
|
<template id="playwrightReportBase64">data:application/zip;base64,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</template>
|
||||||
@@ -110,6 +110,20 @@ Deno.serve(async (req: Request) => {
|
|||||||
return errorResponse('invalid_dates', 'end_time must be after start_time', 400)
|
return errorResponse('invalid_dates', 'end_time must be after start_time', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Historical booking guard ────────────────────────────────────────────────
|
||||||
|
const isPast = endDate.getTime() < Date.now()
|
||||||
|
|
||||||
|
if (isPast) {
|
||||||
|
const { data: callerMember } = await adminClient
|
||||||
|
.from('members')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single()
|
||||||
|
if (callerMember?.role !== 'admin') {
|
||||||
|
return errorResponse('historical_booking_not_allowed', 'Can not book a reservation in the past.', 422)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1. Load booking config ──────────────────────────────────────────────────
|
// ── 1. Load booking config ──────────────────────────────────────────────────
|
||||||
const { data: configRows, error: configErr } = await adminClient
|
const { data: configRows, error: configErr } = await adminClient
|
||||||
.from('booking_config')
|
.from('booking_config')
|
||||||
@@ -142,7 +156,7 @@ Deno.serve(async (req: Request) => {
|
|||||||
if (boatErr || !boat) return errorResponse('not_found', 'Boat not found', 404)
|
if (boatErr || !boat) return errorResponse('not_found', 'Boat not found', 404)
|
||||||
if (!boat.booking_available) return errorResponse('boat_unavailable', 'This boat is currently out of service', 422)
|
if (!boat.booking_available) return errorResponse('boat_unavailable', 'This boat is currently out of service', 422)
|
||||||
|
|
||||||
if (boat.required_certs.length > 0) {
|
if (!isPast && boat.required_certs.length > 0) {
|
||||||
const { data: member } = await adminClient
|
const { data: member } = await adminClient
|
||||||
.from('members')
|
.from('members')
|
||||||
.select('certifications')
|
.select('certifications')
|
||||||
@@ -164,7 +178,7 @@ Deno.serve(async (req: Request) => {
|
|||||||
const advanceMs = config.open_session_advance_hours * 60 * 60 * 1000
|
const advanceMs = config.open_session_advance_hours * 60 * 60 * 1000
|
||||||
const isOpenSession = startDate.getTime() - Date.now() <= advanceMs
|
const isOpenSession = startDate.getTime() - Date.now() <= advanceMs
|
||||||
|
|
||||||
if (!isOpenSession) {
|
if (!isPast && !isOpenSession) {
|
||||||
// ── 5. Weekly pre-booking limit ─────────────────────────────────────────
|
// ── 5. Weekly pre-booking limit ─────────────────────────────────────────
|
||||||
const weekStart = isoWeekStart(startDate)
|
const weekStart = isoWeekStart(startDate)
|
||||||
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)
|
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
|||||||
@@ -393,6 +393,68 @@ describe('open-session window bypasses pre-booking limits', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Historical booking constraint ────────────────────────────────────────────
|
||||||
|
describe('historical booking constraint', () => {
|
||||||
|
const emailMember = `test-hist-member-${Date.now()}@oysqn.test`
|
||||||
|
const emailAdmin = `test-hist-admin-${Date.now()}@oysqn.test`
|
||||||
|
let memberUserId: string
|
||||||
|
let adminUserId: string
|
||||||
|
let boatId: string
|
||||||
|
let memberToken: string
|
||||||
|
let adminToken: string
|
||||||
|
|
||||||
|
function pastSlot(): { start_time: string; end_time: string } {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCDate(d.getUTCDate() - 7)
|
||||||
|
d.setUTCHours(9, 0, 0, 0)
|
||||||
|
const e = new Date(d)
|
||||||
|
e.setUTCHours(12, 30, 0, 0)
|
||||||
|
return { start_time: d.toISOString(), end_time: e.toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
boatId = await createTestBoat()
|
||||||
|
memberUserId = await createTestUser(emailMember, [], 'member')
|
||||||
|
adminUserId = await createTestUser(emailAdmin, [], 'admin')
|
||||||
|
memberToken = await getSessionToken(emailMember)
|
||||||
|
adminToken = await getSessionToken(emailAdmin)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await adminClient.from('reservations').delete().eq('boat_id', boatId)
|
||||||
|
await adminClient.from('boats').delete().eq('id', boatId)
|
||||||
|
await adminClient.auth.admin.deleteUser(memberUserId)
|
||||||
|
await adminClient.auth.admin.deleteUser(adminUserId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a historical booking by a regular member', async () => {
|
||||||
|
const slot = pastSlot()
|
||||||
|
const { status, body } = await callCreateReservation(memberToken, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('historical_booking_not_allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a historical booking by an admin', async () => {
|
||||||
|
const slot = pastSlot()
|
||||||
|
const { status } = await callCreateReservation(adminToken, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a historical booking by a skipper (non-admin role)', async () => {
|
||||||
|
const emailSkipper = `test-hist-skipper-${Date.now()}@oysqn.test`
|
||||||
|
const skipperId = await createTestUser(emailSkipper, [], 'skipper')
|
||||||
|
const skipperToken = await getSessionToken(emailSkipper)
|
||||||
|
try {
|
||||||
|
const slot = pastSlot()
|
||||||
|
const { status, body } = await callCreateReservation(skipperToken, { boat_id: boatId, ...slot })
|
||||||
|
expect(status).toBe(422)
|
||||||
|
expect(body.error.code).toBe('historical_booking_not_allowed')
|
||||||
|
} finally {
|
||||||
|
await adminClient.auth.admin.deleteUser(skipperId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ── RBAC visibility: reservation_slots view ───────────────────────────────────
|
// ── RBAC visibility: reservation_slots view ───────────────────────────────────
|
||||||
describe('RBAC: reservation_slots view', () => {
|
describe('RBAC: reservation_slots view', () => {
|
||||||
const emailOwner = `test-rbac-owner-${Date.now()}@oysqn.test`
|
const emailOwner = `test-rbac-owner-${Date.now()}@oysqn.test`
|
||||||
|
|||||||
Reference in New Issue
Block a user