From 7e1a2bdf9b0d2d5897191566eafd593befac1bc5 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 15 Jun 2026 21:27:39 +0100 Subject: [PATCH] feat(frontend): redesign schedule page with grouped card layout Replace Card component with custom card design: group bookings by month, add color-coded status bars/badges/dots, fly transitions, loading skeletons, empty state with calendar icon, and improved time/date formatting. Remove dependency on shadcn Card component. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/routes/schedule/+page.svelte | 349 +++++++++++++++++----- 1 file changed, 271 insertions(+), 78 deletions(-) diff --git a/frontend/src/routes/schedule/+page.svelte b/frontend/src/routes/schedule/+page.svelte index 8d9ceb5..10a850a 100644 --- a/frontend/src/routes/schedule/+page.svelte +++ b/frontend/src/routes/schedule/+page.svelte @@ -5,11 +5,10 @@ import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; import { SvelteDate } from 'svelte/reactivity'; + import { fly } from 'svelte/transition'; import UserBookingModal from '$lib/components/account/UserBookingModal.svelte'; import type { Booking, BookingService } from '$lib/types/booking'; - import { Button } from '$lib/components/ui/button'; - import * as Card from '$lib/components/ui/card'; let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading'); let bookings = $state([]); @@ -19,18 +18,15 @@ $effect(() => { if (!browser) return; - if (authStore.isLoading) { pageState = 'loading'; return; } - if (!authStore.isAuthenticated) { pageState = 'unauthorized'; goto('/login', { replaceState: true }); return; } - pageState = 'authorized'; fetchBookings(); }); @@ -45,15 +41,12 @@ Authorization: `Bearer ${authStore.currentToken}` } }); - if (!response.ok) { toast.error('Failed to load bookings'); return; } - const data = await response.json(); const now = new SvelteDate(); - bookings = (data.bookings || []) .filter((b: Booking) => { const startTime = new SvelteDate(b.start_time); @@ -77,90 +70,290 @@ showBookingModal = true; } - const statusColors: Record = { - pending: 'bg-yellow-100 text-yellow-800', - confirmed: 'bg-green-100 text-green-800', - in_progress: 'bg-blue-100 text-blue-800', - completed: 'bg-gray-100 text-gray-800' + type BookingGroup = { + label: string; + key: string; + bookings: Booking[]; }; + + const bookingGroups = $derived.by(() => { + const groups: BookingGroup[] = []; + let currentMonth = ''; + let currentGroup: BookingGroup | null = null; + for (const booking of bookings) { + const monthKey = booking.start_time.slice(0, 7); + if (monthKey !== currentMonth) { + currentMonth = monthKey; + const d = new SvelteDate(booking.start_time); + const label = d.toLocaleDateString('en-GB', { + month: 'long', + year: 'numeric' + }); + currentGroup = { label, key: monthKey, bookings: [] }; + groups.push(currentGroup); + } + currentGroup!.bookings.push(booking); + } + return groups; + }); + + function formatTime(iso: string): string { + return new SvelteDate(iso).toLocaleTimeString('en-GB', { + hour: 'numeric', + minute: '2-digit' + }); + } + + function formatCardDate(iso: string): string { + return new SvelteDate(iso).toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short' + }); + } + + function formatEndTime(iso: string, durationMinutes: number): string { + const start = new SvelteDate(iso); + const end = new SvelteDate(start.getTime() + durationMinutes * 60000); + return end.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' }); + } + + const totalAppointments = $derived(bookings.length); + + type StatusStyle = { + bar: string; + badge: string; + dot: string; + label: string; + }; + const statusConfig: Record = { + pending: { + bar: 'bg-amber-500', + badge: 'bg-amber-50 text-amber-700 border-amber-200', + dot: 'bg-amber-500', + label: 'Pending' + }, + confirmed: { + bar: 'bg-emerald-500', + badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', + dot: 'bg-emerald-500', + label: 'Confirmed' + }, + in_progress: { + bar: 'bg-blue-500', + badge: 'bg-blue-50 text-blue-700 border-blue-200', + dot: 'bg-blue-500', + label: 'In Progress' + }, + completed: { + bar: 'bg-gray-400', + badge: 'bg-gray-50 text-gray-600 border-gray-200', + dot: 'bg-gray-400', + label: 'Completed' + }, + client_cancelled: { + bar: 'bg-red-400', + badge: 'bg-red-50 text-red-700 border-red-200', + dot: 'bg-red-500', + label: 'Cancelled' + }, + we_cancelled: { + bar: 'bg-red-400', + badge: 'bg-red-50 text-red-700 border-red-200', + dot: 'bg-red-500', + label: 'Cancelled' + }, + no_show: { + bar: 'bg-gray-400', + badge: 'bg-gray-50 text-gray-600 border-gray-200', + dot: 'bg-gray-400', + label: 'No Show' + } + }; + + function getConfig(status: string): StatusStyle { + return statusConfig[status] || statusConfig.completed; + } {#if pageState === 'loading'} -
-
-
-
+
+
+
+
+
+ {#each Array(3) as _, i} +
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if pageState === 'unauthorized'} -
-

Please log in to view your schedule.

+
+

Please log in to view your schedule.

+ {:else} -
-

My Schedule

+
+
+

My Schedule

+

+ {totalAppointments === 0 + ? 'No upcoming appointments' + : `You have ${totalAppointments} upcoming visit${totalAppointments === 1 ? '' : 's'}`} +

+
{#if loading} -
Loading...
+
+ {#each Array(3) as _, i} +
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if bookings.length === 0} - - - No Upcoming Appointments - You don't have any upcoming appointments. - - - - - +
+
+ + + +
+

No Upcoming Appointments

+

You don't have any upcoming appointments scheduled.

+ +
+ {:else} -
- {#each bookings as booking (booking.id)} - - -
- - {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric' - })} - - - {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { - hour: 'numeric', - minute: '2-digit' - })} - {#if booking.duration_minutes} - · {booking.duration_minutes} min - {/if} - -
- - {booking.status} - -
- -
-
- {#if booking.services && booking.services.length > 0} -

- {booking.services.map((s: BookingService) => s.service_name).join(', ')} -

- {/if} - {#if booking.total_amount} -

£{booking.total_amount.toFixed(2)}

- {/if} +
+ {#each bookingGroups as group (group.key)} +
+
+

+ {group.label} +

+
+
+ +
+ {#each group.bookings as booking (booking.id)} + {@const cfg = getConfig(booking.status)} + {@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''} + +
+
+ +
+
+
+ + + + + {formatCardDate(booking.start_time)} + + + {formatTime(booking.start_time)} + – + {formatEndTime(booking.start_time, booking.duration_minutes || 0)} + + {#if booking.duration_minutes} + · {booking.duration_minutes} min + {/if} +
+ + + {cfg.label} + +
+ +
+ +
+ {#if booking.services && booking.services.length > 0} +

{serviceNames}

+ {/if} + +
+ {#if booking.total_amount} + + £{booking.total_amount.toFixed(2)} + + {:else} + + {/if} + + +
+
+
-
- -
-
- - + {/each} +
+ {/each}
{/if}