feat: add caching for backend objects

This commit is contained in:
2026-04-21 19:38:57 -04:00
parent 5b4955f07e
commit 7f1e82acc2
14 changed files with 637 additions and 62 deletions

View File

@@ -1,5 +1,11 @@
<template>
<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">
<IonMenu v-if="authStore.user" content-id="main-content" menu-id="main-menu">
<IonHeader>
@@ -91,11 +97,15 @@ import {
import {
homeOutline, calendarOutline, boatOutline, personOutline,
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
layersOutline, settingsOutline, bookmarkOutline,
layersOutline, settingsOutline, bookmarkOutline, cloudOfflineOutline,
} from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
import { useOfflineStatus } from '~/composables/useOfflineStatus'
import { useAppCache } from '~/composables/useAppCache'
const authStore = useAuthStore()
const { isOnline } = useOfflineStatus()
const cache = useAppCache()
async function closeMenu() {
await menuController.close('main-menu')
@@ -116,4 +126,67 @@ watch(() => authStore.user, (u) => {
if (u) authStore.fetchMember()
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>
<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>

View 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 }
}

View 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 }
}

View 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) }
}

View File

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

View File

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