Files
oysqn.app/app/pages/schedule.vue

578 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Schedule</IonTitle>
<IonButtons slot="end">
<IonButton @click="goToday" :disabled="focusDate === todayToronto()">
<IonIcon slot="icon-only" :icon="todayOutline" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent>
<!-- Date navigation bar -->
<div class="date-nav">
<IonButton fill="clear" @click="navPrev">
<IonIcon slot="icon-only" :icon="chevronBackOutline" />
</IonButton>
<span class="date-nav-label" v-if="isMobile">{{ fmtDateLong(focusDate) }}</span>
<span class="date-nav-label" v-else>
{{ fmtDayHeader(visibleDates[0]!).weekday }} {{ fmtDayHeader(visibleDates[0]!).short }}
&nbsp;&nbsp;
{{ fmtDayHeader(visibleDates[6]!).weekday }} {{ fmtDayHeader(visibleDates[6]!).short }}
</span>
<IonButton fill="clear" @click="navNext">
<IonIcon slot="icon-only" :icon="chevronForwardOutline" />
</IonButton>
</div>
<div v-if="loading" class="ion-text-center ion-padding-top" style="padding: 3rem 0">
<IonSpinner name="crescent" />
</div>
<!-- Mobile: single-day stacked view -->
<div v-else-if="isMobile" class="mobile-schedule">
<div v-for="boat in boats" :key="boat.id" class="mobile-boat-card">
<div class="mobile-boat-header">
<div class="mobile-boat-info">
<span class="mobile-boat-name">{{ boat.display_name || boat.name }}</span>
<span v-if="boat.class" class="mobile-boat-class">{{ boat.class }}</span>
</div>
<IonBadge v-if="!boat.booking_available" color="medium">Out of service</IonBadge>
</div>
<div v-if="!boat.booking_available" class="slots-empty">Not available for booking.</div>
<div v-else-if="daySlots(boat.id, focusDate).length === 0" class="slots-empty">No slots today.</div>
<div v-else class="mobile-slots">
<div
v-for="slot in daySlots(boat.id, focusDate)"
:key="slot.id"
class="slot-block"
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
:title="slotTitle(slot)"
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
@click="handleSlotClick(slot)"
@keydown.enter="handleSlotClick(slot)"
>
<div class="slot-main-row">
<span class="slot-time">{{ fmtTime(slot.startTime) }}{{ fmtTime(slot.endTime) }}</span>
<IonIcon v-if="slot.type === 'available'" :icon="chevronForwardOutline" class="slot-chevron" />
</div>
<span class="slot-status">{{ slotLabel(slot) }}</span>
<span class="slot-member">{{ slot.memberName ?? '' }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop: week grid -->
<div v-else class="desktop-schedule-wrap">
<div class="desktop-schedule">
<!-- Header row -->
<div class="grid-header-row">
<div class="grid-boat-col grid-header-cell"></div>
<div
v-for="date in visibleDates"
:key="date"
class="grid-day-col grid-header-cell"
:class="{ 'is-today': date === todayToronto() }"
>
<span class="grid-day-weekday">{{ fmtDayHeader(date).weekday }}</span>
<span class="grid-day-short">{{ fmtDayHeader(date).short }}</span>
</div>
</div>
<!-- One row per boat -->
<div v-for="boat in boats" :key="boat.id" class="grid-data-row">
<div class="grid-boat-col grid-boat-label">
<div class="grid-boat-name">{{ boat.display_name || boat.name }}</div>
<div v-if="boat.class" class="grid-boat-class">{{ boat.class }}</div>
<IonBadge v-if="!boat.booking_available" color="medium" class="oos-badge">OOS</IonBadge>
</div>
<div
v-for="date in visibleDates"
:key="date"
class="grid-day-col grid-cell"
:class="{ 'is-today': date === todayToronto(), 'cell-oos': !boat.booking_available }"
>
<template v-if="boat.booking_available">
<div v-if="daySlots(boat.id, date).length === 0" class="cell-empty"></div>
<div v-else class="cell-slots">
<div
v-for="slot in daySlots(boat.id, date)"
:key="slot.id"
class="slot-block slot-sm"
:class="[slotClass(slot), (slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'slot-tappable' : '']"
:title="slotTitle(slot)"
:role="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 'button' : undefined"
:tabindex="(slot.type === 'available' || slot.isOwn || authStore.isAdmin) ? 0 : undefined"
@click="handleSlotClick(slot)"
@keydown.enter="handleSlotClick(slot)"
>
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}-{{ fmtTime(slot.endTime) }}</span>
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</span>
<span class="slot-member-sm">{{ slot.memberName ?? '' }}</span>
</div>
</div>
</template>
<div v-else class="cell-empty"></div>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="legend">
<span class="legend-item slot-block slot-available">Available</span>
<span class="legend-item slot-block slot-confirmed">Confirmed</span>
<span class="legend-item slot-block slot-tentative">Tentative</span>
<span class="legend-item slot-block slot-pending">Pending</span>
</div>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
IonButtons, IonMenuButton, IonButton, IonIcon, IonBadge, IonSpinner,
actionSheetController, alertController, onIonViewWillEnter,
} from '@ionic/vue'
import {
chevronBackOutline, chevronForwardOutline, todayOutline,
createOutline, trashOutline,
} from 'ionicons/icons'
import { useRouter } from 'vue-router'
const router = useRouter()
import type { Database, ReservationStatus } from '~/types/supabase'
import { useAuthStore } from '~/stores/auth'
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']
type SlotView = Database['public']['Views']['reservation_slots']['Row']
interface SlotBlock {
id: string
boatId: string
startTime: string
endTime: string
type: 'available' | 'booked'
status: ReservationStatus | null
isOwn: boolean
memberName: string | null
}
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const user = useSupabaseUser()
const cache = useAppCache()
const { isOnline } = useOfflineStatus()
const { set: setDraft } = useBookingDraft()
const authStore = useAuthStore()
// ── Responsive ──────────────────────────────────────────────
const isMobile = ref(true)
function checkBreakpoint() { isMobile.value = window.innerWidth < 768 }
onMounted(() => { checkBreakpoint(); window.addEventListener('resize', checkBreakpoint) })
onUnmounted(() => window.removeEventListener('resize', checkBreakpoint))
// ── Date state ──────────────────────────────────────────────
const focusDate = ref(todayToronto())
const visibleDates = computed(() =>
isMobile.value ? [focusDate.value] : weekDates(focusDate.value)
)
function navPrev() {
focusDate.value = isMobile.value
? addDays(focusDate.value, -1)
: addDays(focusDate.value, -7)
}
function navNext() {
focusDate.value = isMobile.value
? addDays(focusDate.value, +1)
: addDays(focusDate.value, +7)
}
function goToday() { focusDate.value = todayToronto() }
// ── Data ────────────────────────────────────────────────────
const loading = ref(true)
const boats = ref<Boat[]>([])
const intervals = ref<Interval[]>([])
const slotViews = ref<SlotView[]>([])
const myReservationIds = ref(new Set<string>())
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 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, myRes] = 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),
supabase.from('reservations').select('id').eq('user_id', user.value?.id).gte('start_time', from).lte('start_time', to).neq('status', 'cancelled'),
])
intervals.value = intRes.data ?? []
slotViews.value = slotRes.data ?? []
myReservationIds.value = new Set((myRes.data ?? []).map((r: { id: string }) => r.id))
loading.value = false
if (intRes.data) cache.set(`intervals:${wk}`, intRes.data)
if (slotRes.data) cache.set(`slots:${wk}`, slotRes.data)
}
async function loadAll() {
await fetchBoats()
await fetchSchedule()
}
watch(user, (val) => { if (val) loadAll() }, { immediate: true })
onIonViewWillEnter(() => { if (user.value) loadAll() })
// Re-fetch when the visible date range changes (user-driven navigation)
watch(visibleDates, () => { if (user.value) fetchSchedule() })
// ── Slot computation ─────────────────────────────────────────
function daySlots(boatId: string, dateIso: string): SlotBlock[] {
const dayIntervals = intervals.value
.filter(i =>
i.boat_id === boatId &&
toDateToronto(new Date(i.start_time)) === dateIso
)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
return dayIntervals.map(interval => {
const res = slotViews.value.find(r =>
r.boat_id === boatId &&
r.start_time >= interval.start_time &&
r.start_time < interval.end_time
)
if (res) {
return {
id: res.id,
boatId,
startTime: interval.start_time,
endTime: interval.end_time,
type: 'booked' as const,
status: res.status,
isOwn: myReservationIds.value.has(res.id),
memberName: res.member_name ?? null,
}
}
return {
id: interval.id,
boatId,
startTime: interval.start_time,
endTime: interval.end_time,
type: 'available' as const,
status: null,
isOwn: false,
memberName: null,
}
})
}
// ── Display helpers ──────────────────────────────────────────
function slotClass(slot: SlotBlock) {
return slot.type === 'available' ? 'slot-available' : `slot-${slot.status}`
}
function slotLabel(slot: SlotBlock): string {
if (slot.type === 'available') return 'Available'
const labels: Record<string, string> = {
confirmed: 'Confirmed',
tentative: 'Tentative',
pending: 'Pending',
}
return labels[slot.status ?? ''] ?? 'Booked'
}
function slotLabelShort(slot: SlotBlock): string {
if (slot.type === 'available') return 'Free'
return slotLabel(slot)
}
function bookSlot(slot: SlotBlock) {
const boat = boats.value.find(b => b.id === slot.boatId)
if (boat) setDraft(boat, slot.startTime, slot.endTime)
router.push('/reservations/create')
}
function handleSlotClick(slot: SlotBlock) {
if (slot.type === 'available') {
bookSlot(slot)
} else if (slot.isOwn || authStore.isAdmin) {
openReservationSheet(slot)
}
}
async function openReservationSheet(slot: SlotBlock) {
const isFuture = new Date(slot.startTime) > new Date()
const memberName = slot.memberName ?? ''
const header = memberName
? `${memberName} · ${fmtTime(slot.startTime)}${fmtTime(slot.endTime)}`
: `${fmtTime(slot.startTime)}${fmtTime(slot.endTime)}`
const sheet = await actionSheetController.create({
header,
buttons: [
...(isFuture ? [
{
text: 'Edit',
icon: createOutline,
handler: () => void router.push(`/reservations/edit/${slot.id}`),
},
{
text: 'Cancel Reservation',
role: 'destructive',
icon: trashOutline,
handler: () => void confirmCancelSlot(slot.id),
},
] : []),
{ text: 'Dismiss', role: 'cancel' },
],
})
await sheet.present()
}
async function confirmCancelSlot(id: string) {
const alert = await alertController.create({
header: 'Cancel Reservation',
message: 'Are you sure you want to cancel this reservation?',
buttons: [
{ text: 'Keep it', role: 'cancel' },
{ text: 'Cancel Reservation', role: 'destructive', handler: () => void cancelSlot(id) },
],
})
await alert.present()
}
async function cancelSlot(id: string) {
await supabase.from('reservations').update({ status: 'cancelled' }).eq('id', id)
slotViews.value = slotViews.value.filter(s => s.id !== id)
myReservationIds.value.delete(id)
}
function slotTitle(slot: SlotBlock): string {
return `${fmtTime(slot.startTime)}${fmtTime(slot.endTime)} · ${slotLabel(slot)}`
}
</script>
<style scoped>
/* ── Date navigation bar ───────────────────────────────────── */
.date-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.25rem;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.date-nav-label {
font-weight: 600;
font-size: 0.95rem;
flex: 1;
text-align: center;
}
/* ── Slot block base ────────────────────────────────────────── */
.slot-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.65rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
min-height: 56px;
justify-content: center;
user-select: none;
}
.slot-available { background: var(--ion-color-success); color: #fff; }
.slot-confirmed { background: var(--ion-color-medium); color: #fff; }
.slot-tentative { background: var(--ion-color-primary); color: #fff; }
.slot-pending { background: var(--ion-color-warning); color: #333; }
.slot-main-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.slot-time { font-variant-numeric: tabular-nums; font-size: 0.9rem; }
.slot-status { font-size: 0.75rem; opacity: 0.85; }
.slot-member { font-size: 0.72rem; opacity: 0.8; font-style: italic; min-height: 1em; }
.slot-chevron { font-size: 1rem; opacity: 0.8; flex-shrink: 0; }
.slot-member-sm { font-size: 0.62rem; opacity: 0.8; font-style: italic; }
.slot-tappable {
cursor: pointer;
transition: filter 0.12s, transform 0.12s;
}
.slot-tappable:active {
filter: brightness(0.88);
transform: scale(0.98);
}
/* ── Mobile schedule ────────────────────────────────────────── */
.mobile-schedule { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mobile-boat-card {
border: 1px solid var(--ion-color-light-shade);
border-radius: 0.75rem;
overflow: hidden;
}
.mobile-boat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.85rem;
background: var(--ion-color-light);
border-bottom: 1px solid var(--ion-color-light-shade);
}
.mobile-boat-info { display: flex; align-items: baseline; gap: 0.5rem; }
.mobile-boat-name { font-weight: 600; font-size: 0.95rem; }
.mobile-boat-class { font-size: 0.78rem; color: var(--ion-color-medium); }
.mobile-slots {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.slots-empty {
padding: 0.6rem 0.85rem;
font-size: 0.82rem;
color: var(--ion-color-medium);
}
/* ── Desktop schedule ───────────────────────────────────────── */
.desktop-schedule-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.desktop-schedule {
display: grid;
/* boat-label column + 7 day columns */
grid-template-columns: 130px repeat(7, minmax(110px, 1fr));
min-width: 900px;
border-top: 1px solid var(--ion-color-light-shade);
}
.grid-header-row,
.grid-data-row {
display: contents;
}
.grid-header-cell {
padding: 0.5rem 0.5rem;
background: var(--ion-color-light);
border-bottom: 2px solid var(--ion-color-light-shade);
border-right: 1px solid var(--ion-color-light-shade);
position: sticky;
top: 0;
z-index: 1;
}
.grid-day-col.grid-header-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.grid-day-weekday { font-size: 0.72rem; color: var(--ion-color-medium); text-transform: uppercase; font-weight: 600; }
.grid-day-short { font-size: 0.85rem; font-weight: 600; }
.grid-header-cell.is-today,
.grid-cell.is-today {
background: color-mix(in srgb, var(--ion-color-primary) 8%, transparent);
}
.grid-boat-col {
border-right: 1px solid var(--ion-color-light-shade);
}
.grid-boat-label {
padding: 0.6rem 0.65rem;
background: var(--ion-color-light-tint);
border-bottom: 1px solid var(--ion-color-light-shade);
display: flex;
flex-direction: column;
gap: 0.1rem;
position: sticky;
left: 0;
z-index: 1;
}
.grid-boat-name { font-weight: 600; font-size: 0.85rem; }
.grid-boat-class { font-size: 0.72rem; color: var(--ion-color-medium); }
.oos-badge { font-size: 0.65rem; margin-top: 0.2rem; }
.grid-cell {
padding: 0.4rem 0.35rem;
border-bottom: 1px solid var(--ion-color-light-shade);
border-right: 1px solid var(--ion-color-light-shade);
vertical-align: top;
}
.cell-empty { color: var(--ion-color-light-shade); text-align: center; font-size: 1.1rem; padding: 0.5rem 0; }
.cell-oos { background: repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(0,0,0,0.03) 4px, rgba(0,0,0,0.03) 8px); }
.cell-slots { display: flex; flex-direction: column; gap: 0.3rem; }
/* Compact slot variant for desktop cells */
.slot-sm {
padding: 0.25rem 0.4rem;
font-size: 0.72rem;
flex-direction: column;
align-items: flex-start;
gap: 0.05rem;
border-radius: 0.3rem;
min-height: 0;
}
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.3; font-weight: 600; }
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; line-height: 1.3; }
.slot-member-sm { font-size: 0.65rem; opacity: 0.8; font-style: italic; line-height: 1.3; min-height: 0.9em; }
/* ── Legend ─────────────────────────────────────────────────── */
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--ion-color-light-shade);
}
.legend-item { font-size: 0.75rem; padding: 0.2rem 0.6rem; }
</style>