feat: add caching for backend objects
This commit is contained in:
@@ -129,11 +129,15 @@ import {
|
||||
IonBackButton, IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||
IonCardContent, IonList, IonItem, IonLabel, IonSelect, IonSelectOption,
|
||||
IonTextarea, IonButton, IonSpinner, IonIcon, IonToast,
|
||||
onIonViewWillEnter,
|
||||
} from '@ionic/vue'
|
||||
import { timeOutline, warningOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { toDateToronto } from '~/utils/toronto'
|
||||
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 Interval = Database['public']['Tables']['intervals']['Row']
|
||||
@@ -144,7 +148,9 @@ definePageMeta({ layout: false })
|
||||
const supabase = useSupabaseClient() as any
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { take: takeDraft } = useBookingDraft()
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const loadingSlots = ref(false)
|
||||
@@ -182,28 +188,28 @@ interface BoatSlotEntry {
|
||||
|
||||
const availableByBoat = ref<BoatSlotEntry[]>([])
|
||||
|
||||
// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime=
|
||||
onMounted(async () => {
|
||||
const { boatId, startTime, endTime } = route.query as Record<string, string>
|
||||
if (boatId && startTime && endTime) {
|
||||
const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single()
|
||||
if (boat) {
|
||||
selectedBoat.value = boat as Boat
|
||||
selectedSlot.value = {
|
||||
id: '',
|
||||
boat_id: boatId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
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
|
||||
// onIonViewWillEnter fires every visit, including re-entry to a cached Ionic page
|
||||
onIonViewWillEnter(async () => {
|
||||
const draft = takeDraft()
|
||||
if (draft) {
|
||||
selectedBoat.value = draft.boat
|
||||
selectedSlot.value = {
|
||||
id: '',
|
||||
boat_id: draft.boat.id,
|
||||
start_time: draft.startTime,
|
||||
end_time: draft.endTime,
|
||||
user_id: null,
|
||||
created_at: '',
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -215,37 +221,50 @@ async function loadSlots() {
|
||||
|
||||
const dayStart = selectedDate.value + 'T00:00:00Z'
|
||||
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
|
||||
const { data: intervals, 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 })
|
||||
type BookedSlot = { boat_id: string; start_time: string; end_time: string }
|
||||
let intervals: unknown[] | null = null
|
||||
let bookedSlots: BookedSlot[] | null = null
|
||||
|
||||
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: bookedSlots } = await supabase
|
||||
.from('reservation_slots')
|
||||
.select('boat_id, start_time, end_time')
|
||||
.gte('start_time', dayStart)
|
||||
.lte('start_time', dayEnd)
|
||||
const { data: slotData } = await supabase
|
||||
.from('reservation_slots')
|
||||
.select('boat_id, start_time, end_time')
|
||||
.gte('start_time', dayStart)
|
||||
.lte('start_time', dayEnd)
|
||||
bookedSlots = slotData
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
const memberCerts: string[] = auth.member?.certifications ?? []
|
||||
|
||||
// Group by boat, filtering out booked slots
|
||||
const byBoat = new Map<string, BoatSlotEntry>()
|
||||
for (const row of intervals ?? []) {
|
||||
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 (!byBoat.has(boat.id)) {
|
||||
@@ -291,14 +310,25 @@ async function submitReservation() {
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (response.error || (response.data as { error?: { code: string; message: string } })?.error) {
|
||||
const apiError = (response.data as { error?: { code: string; message: string } })?.error
|
||||
type ApiErrorBody = { error?: { code: string; message: string } }
|
||||
// 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> = {
|
||||
cert_required: 'You are not certified for this boat.',
|
||||
slot_taken: 'This slot was just booked by someone else.',
|
||||
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
|
||||
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
|
||||
boat_unavailable: 'This boat is currently out of service.',
|
||||
cert_required: 'You are not certified for this boat.',
|
||||
slot_taken: 'This slot was just booked by someone else.',
|
||||
booking_limit_weekly: apiError?.message ?? 'Weekly booking limit reached.',
|
||||
booking_limit_weekend: apiError?.message ?? 'Weekend booking limit reached.',
|
||||
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.color = 'danger'
|
||||
|
||||
@@ -149,6 +149,9 @@ import {
|
||||
todayToronto, toDateToronto, addDays,
|
||||
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
|
||||
} 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 Interval = Database['public']['Tables']['intervals']['Row']
|
||||
@@ -167,6 +170,9 @@ definePageMeta({ layout: false, middleware: ['auth'] })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = useSupabaseClient() as any
|
||||
const cache = useAppCache()
|
||||
const { isOnline } = useOfflineStatus()
|
||||
const { set: setDraft } = useBookingDraft()
|
||||
|
||||
// ── Responsive ──────────────────────────────────────────────
|
||||
const isMobile = ref(true)
|
||||
@@ -200,23 +206,42 @@ const intervals = ref<Interval[]>([])
|
||||
const slotViews = ref<SlotView[]>([])
|
||||
|
||||
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')
|
||||
boats.value = data ?? []
|
||||
if (data) cache.set('boats', data)
|
||||
}
|
||||
|
||||
async function fetchSchedule() {
|
||||
loading.value = true
|
||||
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([
|
||||
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),
|
||||
])
|
||||
|
||||
intervals.value = intRes.data ?? []
|
||||
slotViews.value = slotRes.data ?? []
|
||||
loading.value = false
|
||||
intervals.value = intRes.data ?? []
|
||||
slotViews.value = slotRes.data ?? []
|
||||
loading.value = false
|
||||
|
||||
if (intRes.data) cache.set(`intervals:${wk}`, intRes.data)
|
||||
if (slotRes.data) cache.set(`slots:${wk}`, slotRes.data)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -284,14 +309,9 @@ function slotLabelShort(slot: SlotBlock): string {
|
||||
}
|
||||
|
||||
function bookSlot(slot: SlotBlock) {
|
||||
router.push({
|
||||
path: '/reservations/create',
|
||||
query: {
|
||||
boatId: slot.boatId,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
},
|
||||
})
|
||||
const boat = boats.value.find(b => b.id === slot.boatId)
|
||||
if (boat) setDraft(boat, slot.startTime, slot.endTime)
|
||||
router.push('/reservations/create')
|
||||
}
|
||||
|
||||
function slotTitle(slot: SlotBlock): string {
|
||||
|
||||
Reference in New Issue
Block a user