diff --git a/app/app.vue b/app/app.vue index 8e40b60..177149d 100644 --- a/app/app.vue +++ b/app/app.vue @@ -44,6 +44,10 @@ Templates + + + Manage Bookings + @@ -87,7 +91,7 @@ import { import { homeOutline, calendarOutline, boatOutline, personOutline, bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline, - layersOutline, settingsOutline, + layersOutline, settingsOutline, bookmarkOutline, } from 'ionicons/icons' import { useAuthStore } from '~/stores/auth' diff --git a/app/composables/useBoatImage.ts b/app/composables/useBoatImage.ts new file mode 100644 index 0000000..1ba7099 --- /dev/null +++ b/app/composables/useBoatImage.ts @@ -0,0 +1,23 @@ +const BUCKET = 'boat-images' + +export function useBoatImage() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = useSupabaseClient() as any + + function url(path: string | null | undefined, width?: number, height?: number): string { + if (!path) return '' + const opts = width && height + ? { transform: { width, height, resize: 'cover' as const } } + : undefined + const { data } = supabase.storage.from(BUCKET).getPublicUrl(path, opts) + return data.publicUrl as string + } + + return { + thumbnail: (path: string | null | undefined) => url(path, 150, 150), + small: (path: string | null | undefined) => url(path, 400, 300), + medium: (path: string | null | undefined) => url(path, 800, 600), + large: (path: string | null | undefined) => url(path, 1200, 900), + original: (path: string | null | undefined) => url(path), + } +} diff --git a/app/pages/admin/boat.vue b/app/pages/admin/boat.vue new file mode 100644 index 0000000..5b51340 --- /dev/null +++ b/app/pages/admin/boat.vue @@ -0,0 +1,499 @@ + + + + + diff --git a/app/pages/admin/intervals.vue b/app/pages/admin/intervals.vue index d6f9427..80b28dc 100644 --- a/app/pages/admin/intervals.vue +++ b/app/pages/admin/intervals.vue @@ -7,6 +7,9 @@ Manage Slots + + + @@ -127,6 +130,52 @@ + + + + + Apply Template to Week + + Cancel + + + + + + + Template + + {{ t.name }} + + + + Boat + + {{ b.display_name || b.name }} + + + + +

Days to apply

+ + + + {{ day.label }} + + + + + + Apply to Selected Days + +
+
+ { + if (open) weekApply.days = Array(7).fill(true) +}) +const weekDays = computed(() => { + const base = new Date(selectedDate.value + 'T12:00:00') + const dow = base.getDay() // 0=Sun + const monday = new Date(base) + monday.setDate(base.getDate() - ((dow + 6) % 7)) + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(monday) + d.setDate(monday.getDate() + i) + return { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' }) } + }) +}) const selectedDate = ref(todayIso()) const boats = ref([]) const templates = ref([]) @@ -280,6 +346,28 @@ async function applyTemplate(boatId: string, templateId: string) { showToast(`Template "${template.name}" applied`, 'success') } +async function applyTemplateToWeek() { + const template = templates.value.find(t => t.id === weekApply.templateId) + if (!template) return + weekApply.applying = true + + const selectedDays = weekDays.value.filter((_, i) => weekApply.days[i]) + const inserts = selectedDays.flatMap(day => + (template.time_tuples as TimeTuple[]).map(([start, end]) => ({ + boat_id: weekApply.boatId, + start_time: new Date(`${day.iso}T${start}:00`).toISOString(), + end_time: new Date(`${day.iso}T${end}:00`).toISOString(), + })) + ) + + const { error } = await supabase.from('intervals').insert(inserts) + weekApply.applying = false + if (error) { showToast('Failed to apply: ' + error.message, 'danger'); return } + showWeekApply.value = false + await fetchSlots() + showToast(`Template applied to ${selectedDays.length} day(s)`, 'success') +} + function showToast(message: string, color: string) { toast.message = message toast.color = color diff --git a/app/pages/admin/reservations.vue b/app/pages/admin/reservations.vue new file mode 100644 index 0000000..1a679df --- /dev/null +++ b/app/pages/admin/reservations.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/app/pages/reservations/create.vue b/app/pages/reservations/create.vue index 4eff8ad..3775342 100644 --- a/app/pages/reservations/create.vue +++ b/app/pages/reservations/create.vue @@ -132,6 +132,7 @@ import { } from '@ionic/vue' import { timeOutline, warningOutline } from 'ionicons/icons' import { useAuthStore } from '~/stores/auth' +import { toDateToronto } from '~/utils/toronto' import type { Database } from '~/types/supabase' type Boat = Database['public']['Tables']['boats']['Row'] @@ -143,6 +144,7 @@ definePageMeta({ layout: false }) const supabase = useSupabaseClient() as any const auth = useAuthStore() const router = useRouter() +const route = useRoute() const step = ref<1 | 2>(1) const loadingSlots = ref(false) @@ -180,7 +182,32 @@ interface BoatSlotEntry { const availableByBoat = ref([]) -watch(selectedDate, loadSlots, { immediate: true }) +// Pre-populate from schedule deep-link: ?boatId=&startTime=&endTime= +onMounted(async () => { + const { boatId, startTime, endTime } = route.query as Record + if (boatId && startTime && endTime) { + const { data: boat } = await supabase.from('boats').select('*').eq('id', boatId).single() + if (boat) { + selectedBoat.value = boat as Boat + selectedSlot.value = { + id: '', + boat_id: boatId, + start_time: startTime, + end_time: endTime, + user_id: null, + created_at: '', + } + // Set the date strip to the slot's date so Back returns to correct day + selectedDate.value = toDateToronto(new Date(startTime)) + step.value = 2 + return + } + } + // Normal flow: load slots for today + await loadSlots() +}) + +watch(selectedDate, loadSlots) async function loadSlots() { loadingSlots.value = true diff --git a/app/pages/schedule.vue b/app/pages/schedule.vue new file mode 100644 index 0000000..ba97a72 --- /dev/null +++ b/app/pages/schedule.vue @@ -0,0 +1,478 @@ + + + + + diff --git a/app/utils/toronto.ts b/app/utils/toronto.ts new file mode 100644 index 0000000..1c3fe64 --- /dev/null +++ b/app/utils/toronto.ts @@ -0,0 +1,83 @@ +export const TZ = 'America/Toronto' + +/** YYYY-MM-DD for right now in Toronto */ +export function todayToronto(): string { + return toDateToronto(new Date()) +} + +/** YYYY-MM-DD in Toronto for any Date */ +export function toDateToronto(date: Date): string { + return new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(date) +} + +/** + * Add n calendar days to a YYYY-MM-DD string. + * Uses noon UTC so the shift is always exactly 24 h and never crosses + * a Toronto DST boundary ambiguously. + */ +export function addDays(isoDate: string, n: number): string { + const d = new Date(isoDate + 'T12:00:00Z') + d.setUTCDate(d.getUTCDate() + n) + return d.toISOString().slice(0, 10) +} + +/** HH:MM in Toronto for a UTC ISO string */ +export function fmtTime(utcIso: string): string { + return new Date(utcIso).toLocaleTimeString('en-CA', { + timeZone: TZ, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +/** "Wednesday, April 20" style label for a YYYY-MM-DD string */ +export function fmtDateLong(isoDate: string): string { + return new Date(isoDate + 'T12:00:00Z').toLocaleDateString('en-CA', { + timeZone: TZ, + weekday: 'long', + month: 'long', + day: 'numeric', + }) +} + +/** { weekday: "Wed", short: "Apr 20" } for a YYYY-MM-DD string */ +export function fmtDayHeader(isoDate: string): { weekday: string; short: string } { + const d = new Date(isoDate + 'T12:00:00Z') + return { + weekday: d.toLocaleDateString('en-CA', { timeZone: TZ, weekday: 'short' }), + short: d.toLocaleDateString('en-CA', { timeZone: TZ, month: 'short', day: 'numeric' }), + } +} + +/** + * YYYY-MM-DD of the Monday that starts the ISO week containing isoDate. + * Uses the en-CA weekday name to determine day-of-week in Toronto. + */ +export function weekMonday(isoDate: string): string { + const d = new Date(isoDate + 'T12:00:00Z') + const wd = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, weekday: 'short' }).format(d) + const offsets: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 } + const offset = offsets[wd] ?? 0 + const monday = new Date(d) + monday.setUTCDate(d.getUTCDate() - offset) + return monday.toISOString().slice(0, 10) +} + +/** All 7 YYYY-MM-DD strings for the ISO week containing isoDate (Mon → Sun) */ +export function weekDates(isoDate: string): string[] { + const mon = weekMonday(isoDate) + return Array.from({ length: 7 }, (_, i) => addDays(mon, i)) +} + +/** + * UTC range string pair that safely covers all Toronto-local timestamps + * falling within the isoFrom…isoTo date range. + * Adds a 1-day buffer on each side so client-side date filtering is authoritative. + */ +export function utcRange(isoFrom: string, isoTo: string): { from: string; to: string } { + return { + from: addDays(isoFrom, -1) + 'T00:00:00Z', + to: addDays(isoTo, +1) + 'T23:59:59Z', + } +} diff --git a/supabase/migrations/20260420200000_boat_images_storage.sql b/supabase/migrations/20260420200000_boat_images_storage.sql new file mode 100644 index 0000000..4915ce5 --- /dev/null +++ b/supabase/migrations/20260420200000_boat_images_storage.sql @@ -0,0 +1,44 @@ +-- Create boat-images storage bucket +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'boat-images', + 'boat-images', + true, + 10485760, + array['image/jpeg', 'image/png', 'image/webp'] +) +on conflict (id) do nothing; + +-- Public read (bucket is public, but explicit policy is required for RLS) +create policy "Anyone can read boat images" on storage.objects + for select using (bucket_id = 'boat-images'); + +-- Admins/boatswains can upload +create policy "Admins can upload boat images" on storage.objects + for insert with check ( + bucket_id = 'boat-images' and + exists ( + select 1 from public.members + where user_id = auth.uid() and role in ('admin', 'boatswain') + ) + ); + +-- Admins/boatswains can replace/update +create policy "Admins can update boat images" on storage.objects + for update using ( + bucket_id = 'boat-images' and + exists ( + select 1 from public.members + where user_id = auth.uid() and role in ('admin', 'boatswain') + ) + ); + +-- Admins/boatswains can delete +create policy "Admins can delete boat images" on storage.objects + for delete using ( + bucket_id = 'boat-images' and + exists ( + select 1 from public.members + where user_id = auth.uid() and role in ('admin', 'boatswain') + ) + );