feat(frontend): add done-for-day summary card to CurrentAppointment, fix schedule z-index

Replace current/next card with done-for-day summary state when all appointments are finished. Shows daily stats (payments, tips, duration, customers) and week summary when tomorrow is closed. Fix schedule grid z-index to remove stacking context that caused overlay issues.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-15 20:27:47 +01:00
co-authored by Sisyphus
parent 459adf7001
commit bf90b0fa42
2 changed files with 374 additions and 72 deletions
@@ -8,20 +8,28 @@
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
import type { Booking as BookingType } from '$lib/types/booking';
interface Props {
openBookingModal: (bookingId: string) => void;
_openBookingModal: (bookingId: string) => void;
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
openUserModal: (userId: string) => void;
}
let { openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
let { _openBookingModal, openEditBookingModal, openUserModal }: Props = $props();
type Booking = {
id: string;
start_time: string;
status: string;
notes?: string;
user_id?: string;
created_at?: string;
updated_at?: string;
deposit_required?: boolean;
deposit_paid?: boolean;
amount_paid?: number;
amount_due?: number;
user?: {
id: string;
full_name: string;
@@ -40,17 +48,60 @@
total_amount: number;
};
type DailySummary = {
total_payments_today: number;
total_tips_today: number;
amount_due_today: number;
total_duration_spent: number;
customers_served: number;
total_bookings: number;
new_customers: number;
returning_customers: number;
guest_customers: number;
gift_cards_sold: number;
last_customer_name?: string;
last_customer_visits?: number;
summary_scope: 'day' | 'week';
summary_start_date: string;
summary_end_date: string;
new_booking_services?: Array<{
service_name: string;
count: number;
}>;
};
let currentAppointment = $state<Booking | null>(null);
let nextAppointment = $state<Booking | null>(null);
let closingTime = $state<string | null>(null); // "HH:MM" format
let freeTimeAfter = $state(0); // minutes of free time after current/next appointment
let freeTimeCapped = $state(false); // true if free time extends to/past closing
let closingTime = $state<string | null>(null);
let freeTimeAfter = $state(0);
let freeTimeCapped = $state(false);
let loading = $state(true);
let timeRemaining = $state(0); // minutes remaining in current appointment
let timeRemaining = $state(0);
let isInProgress = $state(false);
let showPaymentModal = $state(false);
// Calculate time remaining and free time
// Done-for-the-day state
let doneForDay = $state(false);
let summary = $state<DailySummary | null>(null);
let weekSummary = $state<DailySummary | null>(null);
function formatRemainingTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins} minutes`;
if (mins === 0) return `${hours} hour`;
if (hours === 1) return `1 hour ${mins} minutes`;
return `${hours} hours ${mins} minutes`;
}
const minutesUntilClosing = $derived.by(() => {
if (!closingTime) return 0;
const [ch, cm] = closingTime.split(':').map(Number);
const now = new SvelteDate();
const closing = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate(), ch, cm);
return Math.max(0, Math.floor((closing.getTime() - now.getTime()) / 60000));
});
let interval: ReturnType<typeof setInterval> | null = null;
function calculateTimes() {
@@ -77,7 +128,6 @@
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
}
// Calculate free time after this appointment
let rawFreeMinutes = 0;
if (nextAppointment) {
const nextStart = new SvelteDate(nextAppointment.start_time);
@@ -85,7 +135,6 @@
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
}
// Cap at closing time
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new SvelteDate();
@@ -103,11 +152,9 @@
);
if (!nextAppointment) {
// No next appointment — show time until closing
freeTimeAfter = minutesToClosing;
freeTimeCapped = true;
} else if (rawFreeMinutes > minutesToClosing) {
// Next appointment is after closing — cap at closing
freeTimeAfter = minutesToClosing;
freeTimeCapped = true;
} else {
@@ -137,6 +184,17 @@
currentAppointment = data.current || null;
nextAppointment = data.next || null;
closingTime = data.closing_time || null;
if (data.done_for_day) {
doneForDay = true;
summary = data.summary || null;
weekSummary = data.week_summary || null;
} else {
doneForDay = false;
summary = null;
weekSummary = null;
}
calculateTimes();
} else {
toast.error('Failed to load current appointment');
@@ -149,20 +207,18 @@
}
}
// Start interval for real-time countdown
$effect(() => {
fetchCurrentAndNext();
interval = setInterval(() => {
calculateTimes();
}, 15000); // Update every 15 seconds
}, 15000);
return () => {
if (interval) clearInterval(interval);
};
});
// WIP Demo actions
function handleBegin() {
toast.info('Begin appointment - Coming soon');
}
@@ -181,7 +237,7 @@
showPaymentModal = true;
}
function handlePaymentComplete(payment: unknown) {
function handlePaymentComplete(_payment: unknown) {
toast.success('Payment completed');
showPaymentModal = false;
}
@@ -190,68 +246,104 @@
toast.info('Cancel appointment - Coming soon');
}
function isMilestone(visits: number): boolean {
if (visits < 5) return false;
const early = [5, 10, 15, 20, 25, 50, 75, 100, 150];
if (visits <= 150) return early.includes(visits);
return visits % 50 === 0;
}
const activeAppointment = $derived(currentAppointment || nextAppointment);
</script>
<Card.Root class="border-2 border-blue-200 bg-blue-50">
<Card.Root
class="border-2 {doneForDay ? 'border-teal-200 bg-teal-50' : 'border-blue-200 bg-blue-50'}"
>
<Card.Header>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<Card.Title class="text-xl md:text-2xl">
{isInProgress ? 'Current Appointment' : 'Next Appointment'}
{#if doneForDay}
<div class="flex flex-col gap-1">
<Card.Title class="flex items-center gap-2 text-xl md:text-2xl">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7 text-teal-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{summary?.summary_scope === 'week' ? 'Closed today' : 'All done for today!'}
</Card.Title>
<Card.Description class="text-base">
{summary?.summary_scope === 'week'
? 'Showing summary from the last working period'
: 'No more customers booked for today'}
</Card.Description>
</div>
{:else}
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<Card.Title class="text-xl md:text-2xl">
{isInProgress ? 'Current Appointment' : 'Next Appointment'}
</Card.Title>
{#if activeAppointment}
<Card.Description class="text-base">
{new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
-
{new SvelteDate(
new SvelteDate(activeAppointment.start_time).getTime() +
activeAppointment.duration_minutes * 60 * 1000
).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</Card.Description>
{/if}
</div>
{#if activeAppointment}
<Card.Description class="text-base">
{new SvelteDate(activeAppointment.start_time).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
-
{new SvelteDate(
new SvelteDate(activeAppointment.start_time).getTime() +
activeAppointment.duration_minutes * 60 * 1000
).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</Card.Description>
{#if isInProgress}
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
</Badge>
{#if timeRemaining > 29}
{#if freeTimeCapped && freeTimeAfter === 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
closing after
</Badge>
{:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
{freeTimeAfter} min free afterwards{#if freeTimeCapped}
(closing after){/if}
</Badge>
{/if}
{/if}
</div>
{:else}
<Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
Starts in {timeRemaining} min
</Badge>
{/if}
{/if}
</div>
{#if activeAppointment}
{#if isInProgress}
<div class="flex flex-row flex-wrap gap-2 sm:flex-col sm:items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
<span class="relative mr-2 flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-blue-600"></span>
</span>
In Progress • {timeRemaining} min remaining
</Badge>
{#if timeRemaining > 29}
{#if freeTimeCapped && freeTimeAfter === 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
closing after
</Badge>
{:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm whitespace-nowrap text-blue-800">
{freeTimeAfter} min free afterwards{#if freeTimeCapped}
(closing after){/if}
</Badge>
{/if}
{/if}
</div>
{:else}
<Badge class="bg-amber-100 px-3 py-1 text-sm whitespace-nowrap text-amber-800">
Starts in {timeRemaining} min
</Badge>
{/if}
{/if}
</div>
{/if}
</Card.Header>
{#if loading}
@@ -266,6 +358,216 @@
</div>
<Skeleton class="h-32 w-full" />
</Card.Content>
{:else if doneForDay && summary}
<Card.Content>
<div class="space-y-5">
<!-- Conditional messages -->
{#if minutesUntilClosing >= 30}
<div class="rounded-md border border-teal-200 bg-white p-3 text-sm text-teal-800">
There is {formatRemainingTime(minutesUntilClosing)} left to accept walk-in bookings
</div>
{/if}
{#if minutesUntilClosing >= 75}
<div class="rounded-md border border-blue-200 bg-white p-3 text-sm text-blue-800">
Users booking online still have time to book in
</div>
{/if}
{#if summary.summary_scope === 'week'}
<div class="text-xs text-gray-500">
Summary: {new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
&ndash;
{new SvelteDate(summary.summary_end_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
</div>
{/if}
<!-- Stats grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)}
</div>
</div>
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_tips_today.toFixed(2)}
</div>
</div>
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Time in Appointments</div>
<div class="mt-1 text-lg font-bold text-gray-900">
{formatDuration(summary.total_duration_spent)}
</div>
</div>
{#if summary.amount_due_today > 0}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="text-xs font-medium text-amber-700">Still Due</div>
<div class="mt-1 text-lg font-bold text-amber-900">
£{summary.amount_due_today.toFixed(2)}
</div>
</div>
{/if}
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Customers Served</div>
<div class="mt-1 text-lg font-bold text-gray-900">
{summary.customers_served}
{#if summary.total_bookings !== summary.customers_served}
<span class="text-sm font-normal text-gray-500">
across {summary.total_bookings} booking{summary.total_bookings !== 1 ? 's' : ''}
</span>
{/if}
</div>
</div>
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Customers</div>
<div class="mt-1 space-y-0.5">
{#if summary.new_customers > 0}
<div class="flex items-baseline justify-between gap-2">
<span class="text-lg font-bold text-gray-900">{summary.new_customers}</span>
<span class="text-xs text-gray-500">new</span>
</div>
{/if}
{#if summary.returning_customers > 0}
<div class="flex items-baseline justify-between gap-2">
<span class="text-lg font-bold text-gray-900">{summary.returning_customers}</span>
<span class="text-xs text-gray-500">returning</span>
</div>
{/if}
{#if summary.guest_customers > 0}
<div class="flex items-baseline justify-between gap-2">
<span class="text-lg font-bold text-gray-900">{summary.guest_customers}</span>
<span class="text-xs text-gray-500">guest</span>
</div>
{/if}
</div>
</div>
{#if summary.gift_cards_sold > 0}
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Gift Cards Sold</div>
<div class="mt-1 text-lg font-bold text-gray-900">{summary.gift_cards_sold}</div>
</div>
{/if}
</div>
{#if summary.last_customer_name && isMilestone(summary.last_customer_visits ?? 0)}
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="flex items-center gap-2">
<div>
<span class="font-medium text-gray-900">{summary.last_customer_name}</span>
<span class="text-gray-700">
has visited {summary.last_customer_visits} times now!
</span>
</div>
</div>
</div>
{/if}
{#if summary.new_booking_services && summary.new_booking_services.length > 0}
<div>
<h4 class="mb-2 text-sm font-semibold text-gray-700">
New bookings made since{summary.summary_scope === 'week'
? ` opening ${new SvelteDate(summary.summary_start_date).toLocaleDateString('en-US', { weekday: 'long' })}`
: ' closing yesterday'}
</h4>
<div class="flex flex-wrap gap-2">
{#each summary.new_booking_services as svc (svc.service_name)}
<div class="rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-sm">
<span class="font-semibold text-gray-900">{svc.count}</span>
<span class="ml-1 text-gray-600">{svc.service_name}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Week summary (shown alongside daily summary when tomorrow is closed) -->
{#if weekSummary}
<hr class="border-gray-200" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-semibold text-gray-700">This week so far</h4>
<div class="text-xs text-gray-500">
{new SvelteDate(weekSummary.summary_start_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
&ndash;
{new SvelteDate(weekSummary.summary_end_date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})}
</div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)}
</div>
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_tips_today.toFixed(2)}
</div>
</div>
{#if weekSummary.amount_due_today > 0}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="text-xs font-medium text-amber-700">Still Due</div>
<div class="mt-1 text-lg font-bold text-amber-900">
£{weekSummary.amount_due_today.toFixed(2)}
</div>
</div>
{/if}
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Time in Appointments</div>
<div class="mt-1 text-lg font-bold text-gray-900">
{formatDuration(weekSummary.total_duration_spent)}
</div>
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Customers Served</div>
<div class="mt-1 text-lg font-bold text-gray-900">
{weekSummary.customers_served}
</div>
</div>
{#if weekSummary.gift_cards_sold > 0}
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Gift Cards Sold</div>
<div class="mt-1 text-lg font-bold text-gray-900">
{weekSummary.gift_cards_sold}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</Card.Content>
{:else if !activeAppointment}
<Card.Content>
<div class="py-12 text-center">
@@ -461,7 +763,7 @@
{/if}
</Card.Root>
{#if showPaymentModal}
{#if showPaymentModal && activeAppointment}
<PaymentModal
booking={activeAppointment}
onClose={() => (showPaymentModal = false)}
@@ -487,7 +487,7 @@
<!-- 2. GRID CONTAINER (Day Columns) -->
<div class="relative flex flex-1 flex-col">
<!-- Grid Rows & Cells -->
<div class="relative z-0">
<div class="relative">
{#each timeRange.hours as h (h)}
<div class="flex border-b border-gray-100">
{#each getWeekDays(weekStart) as day (formatDate(day))}
@@ -594,7 +594,7 @@
</div>
<!-- 3. OVERLAY LAYER (Lines & Closed Stripes) -->
<!-- z-5 places it above grid backgrounds (z-0) but below bookings (z-10) -->
<!-- z-5: below bookings (z-10) since grid no longer creates a stacking context -->
<div class="pointer-events-none absolute inset-0 z-5">
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
{@const dateStr = formatDate(day)}