refactor: today page components with calendar grid and stats

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-29 16:08:44 +01:00
co-authored by Sisyphus
parent 18d8822c47
commit fc69fec361
4 changed files with 63 additions and 56 deletions
@@ -2,6 +2,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -184,8 +185,8 @@
<Card.Root class="border-2 border-blue-200 bg-blue-50">
<Card.Header>
<div class="flex items-start justify-between">
<div>
<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>
@@ -211,8 +212,8 @@
{#if activeAppointment}
{#if isInProgress}
<div class="flex flex-col gap-2 items-end">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<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 text-blue-800 whitespace-nowrap">
<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"
@@ -223,18 +224,18 @@
</Badge>
{#if timeRemaining > 29}
{#if freeTimeCapped && freeTimeAfter === 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
closing after
</Badge>
{:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800 whitespace-nowrap">
{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 text-amber-800">
<Badge class="bg-amber-100 px-3 py-1 text-sm text-amber-800 whitespace-nowrap">
Starts in {timeRemaining} min
</Badge>
{/if}
@@ -279,24 +280,24 @@
<Card.Content>
<div class="grid gap-6 md:grid-cols-3">
<!-- Customer Info -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-3 sm:gap-4 min-w-0">
{#if activeAppointment.user?.profile_pic_url}
<img
src={activeAppointment.user.profile_pic_url}
alt={activeAppointment.user.full_name}
class="h-16 w-16 md:h-20 md:w-20 rounded-full object-cover ring-4 ring-blue-200"
class="h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 rounded-full object-cover ring-2 sm:ring-4 ring-blue-200"
/>
{:else}
{@const initials = activeAppointment.user?.full_name?.split(' ').map(n => n[0]).join('') || '?'}
<div
class="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-full bg-gray-200 text-2xl font-bold text-gray-600 ring-4 ring-blue-200"
class="flex h-14 w-14 sm:h-16 sm:w-16 md:h-20 md:w-20 shrink-0 items-center justify-center rounded-full bg-gray-200 text-xl sm:text-2xl font-bold text-gray-600 ring-2 sm:ring-4 ring-blue-200"
>
{initials}
</div>
{/if}
<div>
<div class="text-lg font-semibold">{activeAppointment.user?.full_name || 'Guest'}</div>
<div class="text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
<div class="min-w-0">
<div class="truncate text-lg font-semibold">{activeAppointment.user?.full_name || 'Guest'}</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
{#if activeAppointment.user}
<button
type="button"
@@ -320,7 +321,7 @@
<div class="text-xs text-gray-600">{service.service_description}</div>
{/if}
<div class="mt-1 flex items-center justify-between text-xs text-gray-500">
<span>{service.override_duration_minutes ?? service.duration_minutes} mins</span>
<span>{formatDuration(service.override_duration_minutes ?? service.duration_minutes ?? 0)}</span>
</div>
</div>
{/each}
@@ -112,8 +112,8 @@
}
function formatRelativeTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const d = new SvelteDate(iso);
const now = new SvelteDate();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
@@ -130,7 +130,7 @@
const servicesChanged = areEditServicesChanged(er);
if (timeChanged) {
const d = new Date(er.proposed.start_time!);
const d = new SvelteDate(er.proposed.start_time!);
const dateStr = d.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
@@ -183,7 +183,7 @@
const data = await response.json();
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
new SvelteDate(a.created_at).getTime() - new SvelteDate(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
@@ -218,7 +218,7 @@
const data = await response.json();
const newEditRequests = (data.edit_requests || []).sort(
(a: EditRequest, b: EditRequest) =>
new Date(a.requested_at).getTime() - new Date(b.requested_at).getTime()
new SvelteDate(a.requested_at).getTime() - new SvelteDate(b.requested_at).getTime()
);
const newJson = JSON.stringify(newEditRequests);
if (newJson !== prevEditRequestsJson) {
@@ -11,6 +11,7 @@
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { calculateMiddleWindow, extractBookedSlots, findAllLunchGaps } from '$lib/lunchProtection';
import { formatDuration } from '$lib/utils/format';
interface Props {
openBookingModal: (bookingId: string) => void;
@@ -62,8 +63,8 @@
};
function isPastAppointment(startTime: string, durationMinutes: number): boolean {
const start = new Date(startTime);
const end = new Date(start.getTime() + durationMinutes * 60_000);
const start = new SvelteDate(startTime);
const end = new SvelteDate(start.getTime() + durationMinutes * 60_000);
return end.getTime() < Date.now();
}
@@ -94,7 +95,7 @@
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
const today = $derived.by(() => {
const d = new Date();
const d = new SvelteDate();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
@@ -124,12 +125,7 @@
return { hour, minute, period };
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes}m`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function isAutoGenerated(blocker: TimeBlocker): boolean {
return blocker.description?.startsWith('RESERVATION:') ?? false;
@@ -232,7 +228,7 @@
const items: TimelineItem[] = [];
for (const apt of appointments) {
const start = new Date(apt.start_time);
const start = new SvelteDate(apt.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `apt-${apt.id}`,
@@ -244,7 +240,7 @@
}
for (const b of blockers) {
const start = new Date(b.start_time);
const start = new SvelteDate(b.start_time);
const startM = start.getHours() * 60 + start.getMinutes();
items.push({
id: `blk-${b.id}`,
@@ -362,8 +358,8 @@
const startIso = buildDateTime(today, startHour, startMinute, startPeriod);
const endIso = buildDateTime(today, endHour, endMinute, endPeriod);
if (!startIso || !endIso) { overlappingBookings = []; hasOverlap = false; return; }
const blockerStart = new Date(startIso);
const blockerEnd = new Date(endIso);
const blockerStart = new SvelteDate(startIso);
const blockerEnd = new SvelteDate(endIso);
if (blockerEnd.getTime() <= blockerStart.getTime()) { overlappingBookings = []; hasOverlap = false; return; }
checkingOverlap = true;
try {
@@ -374,8 +370,8 @@
const data = await response.json();
const allBookings: OverlappingBooking[] = data.bookings || [];
const filtered = allBookings.filter((b) => {
const bStart = new Date(b.start_time);
const bEnd = new Date(bStart.getTime() + b.duration_minutes * 60000);
const bStart = new SvelteDate(b.start_time);
const bEnd = new SvelteDate(bStart.getTime() + b.duration_minutes * 60000);
return bStart < blockerEnd && bEnd > blockerStart;
});
overlappingBookings = filtered;
@@ -567,15 +563,15 @@
{#if loading}
<div class="space-y-3">
{#each Array(5) as _, i (i)}
<div class="flex items-center gap-4 rounded-lg border p-3">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-10 w-1" />
<div class="flex-1 space-y-2">
<div class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3">
<Skeleton class="h-4 w-[45px] sm:w-[60px] lg:w-[80px]" />
<Skeleton class="h-10 w-1 shrink-0" />
<div class="flex-1 space-y-2 min-w-0">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-3 w-48" />
</div>
<Skeleton class="h-6 w-20" />
<Skeleton class="h-8 w-16" />
<Skeleton class="h-6 w-20 hidden sm:block shrink-0" />
<Skeleton class="h-8 w-9 sm:w-16 shrink-0" />
</div>
{/each}
</div>
@@ -603,33 +599,42 @@
{#each timeline as item (item.id)}
{#if item.type === 'appointment'}
<div
class="flex items-center gap-4 rounded-lg border p-3 transition-all hover:shadow-md
class="flex items-center gap-3 sm:gap-4 rounded-lg border p-3 transition-all hover:shadow-md
{isPastAppointment(item.data.start_time, item.data.duration_minutes) ? 'line-through opacity-50' : ''}"
>
<div class="min-w-[60px] sm:min-w-[80px] text-sm font-semibold text-gray-700">
<div class="min-w-[45px] sm:min-w-[60px] lg:min-w-[80px] text-xs sm:text-sm font-semibold text-gray-700 shrink-0">
{formatTime(item.data.start_time)}
</div>
<div class="h-10 w-1 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="flex-1">
<div class="h-10 w-1 shrink-0 rounded {getStatusBarColor(item.data.status)}"></div>
<div class="min-w-0 flex-1">
<button
type="button"
class="font-medium hover:text-blue-600 hover:underline"
class="truncate font-medium hover:text-blue-600 hover:underline max-w-full block"
onclick={() => openUserModal(item.data.user_id)}
>
{item.data.user_name}
</button>
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span>{item.data.services.join(', ')}</span>
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium">
<span class="truncate">{item.data.services.join(', ')}</span>
<span class="shrink-0 rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium">
{formatDuration(item.data.duration_minutes)}
</span>
</div>
</div>
<Badge class={getStatusColor(item.data.status)}>
<Badge class="{getStatusColor(item.data.status)} hidden sm:inline-flex shrink-0">
{formatStatus(item.data.status)}
</Badge>
<Button size="sm" variant="outline" onclick={() => openBookingModal(item.data.id)}>
View
<Button
size="sm"
variant="outline"
class="shrink-0 sm:px-3 h-9 w-9 sm:w-auto"
onclick={() => openBookingModal(item.data.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 sm:mr-1.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span class="hidden sm:inline">View</span>
</Button>
</div>
{:else if item.type === 'blocker'}
@@ -1,4 +1,5 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
@@ -38,7 +39,7 @@
let initialized = $state(false);
const today = $derived.by(() => {
const d = new Date();
const d = new SvelteDate();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
@@ -48,11 +49,11 @@
}
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
const now = new Date();
const now = new SvelteDate();
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new Date(now);
const d = new SvelteDate(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -66,7 +67,7 @@
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d);
const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: closeDate.toISOString(), spansClosed };
} else {
@@ -75,7 +76,7 @@
}
}
const fallback = new Date(now);
const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: fallback.toISOString(), spansClosed };
@@ -124,7 +125,7 @@
}
const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = new Date().toISOString();
const nowISO = new SvelteDate().toISOString();
let bookingsMade = 0;
try {
const bmRes = await fetch(`/api/admin/bookings/by-created-range?start=${encodeURIComponent(cutoff)}&end=${encodeURIComponent(nowISO)}`, {