feat: Schedule bookings

This commit is contained in:
2026-04-21 16:12:04 -04:00
parent 108c042921
commit 5b4955f07e
9 changed files with 1636 additions and 4 deletions

478
app/pages/schedule.vue Normal file
View File

@@ -0,0 +1,478 @@
<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-tappable' : '']"
:title="slotTitle(slot)"
:role="slot.type === 'available' ? 'button' : undefined"
:tabindex="slot.type === 'available' ? 0 : undefined"
@click="slot.type === 'available' && bookSlot(slot)"
@keydown.enter="slot.type === 'available' && bookSlot(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>
</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)"
:title="slotTitle(slot)"
>
<span class="slot-time-sm">{{ fmtTime(slot.startTime) }}</span>
<span class="slot-sep"></span>
<span class="slot-time-sm">{{ fmtTime(slot.endTime) }}</span>
<span class="slot-status-sm">{{ slotLabelShort(slot) }}</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,
} from '@ionic/vue'
import {
chevronBackOutline, chevronForwardOutline, todayOutline,
} from 'ionicons/icons'
const router = useRouter()
import type { Database, ReservationStatus } from '~/types/supabase'
import {
todayToronto, toDateToronto, addDays,
fmtTime, fmtDateLong, fmtDayHeader, weekDates, utcRange,
} from '~/utils/toronto'
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
}
definePageMeta({ layout: false, middleware: ['auth'] })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
// ── 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[]>([])
async function fetchBoats() {
const { data } = await supabase.from('boats').select('*').order('name')
boats.value = data ?? []
}
async function fetchSchedule() {
loading.value = true
const dates = visibleDates.value
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
}
onMounted(async () => {
await fetchBoats()
await fetchSchedule()
})
// Re-fetch when the visible date range changes
watch(visibleDates, 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',
status: res.status,
}
}
return {
id: interval.id,
boatId,
startTime: interval.start_time,
endTime: interval.end_time,
type: 'available',
status: 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) {
router.push({
path: '/reservations/create',
query: {
boatId: slot.boatId,
startTime: slot.startTime,
endTime: slot.endTime,
},
})
}
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-chevron { font-size: 1rem; opacity: 0.8; flex-shrink: 0; }
.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.2rem 0.4rem;
font-size: 0.72rem;
flex-direction: column;
align-items: flex-start;
gap: 0.05rem;
border-radius: 0.3rem;
}
.slot-time-sm { font-variant-numeric: tabular-nums; font-size: 0.72rem; line-height: 1.2; }
.slot-sep { display: none; }
.slot-status-sm { font-size: 0.65rem; opacity: 0.85; }
/* ── 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>