479 lines
16 KiB
Vue
479 lines
16 KiB
Vue
<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 }}
|
||
–
|
||
{{ 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>
|