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

479 lines
16 KiB
Vue
Raw 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-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>