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:
@@ -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)}`, {
|
||||
|
||||
Reference in New Issue
Block a user