578 lines
21 KiB
Vue
578 lines
21 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.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>
|