Files
Crussell/frontend/src/routes/admin/schedule/+page.svelte
T
popertots eb15a399ef
Lint & Vuln Scan / Go vulnerabilities (push) Successful in 24s
Lint & Vuln Scan / Frontend lint & types (push) Failing after 40s
fix: resolve all agent-induced errors — svelte-check, eslint, prettier pass
- Fix <!-- svelte-ignore HTML comments in script sections (invalid JS)
- Fix catch err -> _err references across all files after renames
- Fix .writable (not in Svelte 5 stable) back to +
- Fix NavBar dynamic href links with proper eslint-disable in template
- Fix SvelteMap type params missing after Map->SvelteMap conversion
- Fix required->_required and onclose->_onclose prop mismatches
- Fix HolidayHours inline type mismatch, BookingCreateModal suppression
- Fix remaining pre-existing no-unused-vars with eslint-disable-next-line
- Revert fonts commit, run prettier format

svelte-check: 0 errors, eslint: 0 errors, prettier: clean
2026-06-25 17:10:49 +01:00

807 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
// ============================================================
// ADMIN SCHEDULE PAGE — Google Calendar-style week view
// ============================================================
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate } from 'svelte/reactivity';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
// -- Types --
type BookingService = { service_name?: string };
type ScheduleBooking = {
id: string;
start_time: string;
status: string;
duration_minutes: number;
user?: {
full_name: string;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
services: BookingService[];
};
type TimeBlocker = {
id: string;
start_time: string;
duration_minutes: number;
description: string;
};
type WorkingDay = {
date: string;
isOpen: boolean;
startTime: string;
endTime: string;
};
// -- State --
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
let bookings = $state<ScheduleBooking[]>([]);
let blockers = $state<TimeBlocker[]>([]);
let workingHours = $state<WorkingDay[]>([]);
let loading = $state(true);
let prevBookingsJson = $state('');
let prevBlockersJson = $state('');
let prevHoursJson = $state('');
let initialized = $state(false);
let showBookingModal = $state(false);
let selectedBookingId = $state<string | null>(null);
let weekStart = $state<Date | undefined>(undefined);
// -- Drag-scroll state --
let scrollContainer = $state<HTMLElement | null>(null);
let isDragging = $state(false);
let dragStartX = $state(0);
let dragStartY = $state(0);
let dragScrollLeft = $state(0);
let dragScrollTop = $state(0);
// -- Auth & Init --
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated || authStore.currentUser?.role !== 'admin') {
pageState = 'unauthorized';
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(authStore.isAuthenticated ? '/' : '/login', { replaceState: true });
return;
}
pageState = 'authorized';
});
$effect(() => {
if (pageState === 'authorized' && !weekStart) {
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
}
});
// -- Helpers --
function getWeekDays(start: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new SvelteDate(start);
d.setDate(d.getDate() + i);
return d;
});
}
function formatDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** Return the current London date as a Date set to midnight in the local timezone.
* Uses Intl.DateTimeFormat with Europe/London to handle BST/GMT correctly. */
function getLondonToday(): Date {
const dateStr = new SvelteDate().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
return new SvelteDate(dateStr + 'T00:00:00');
}
/** Return the current time-of-day in London as minutes since midnight, using
* Intl.DateTimeFormat with Europe/London so the current-time blue line is
* positioned correctly regardless of the browser's system timezone. */
function getLondonNowMinutes(): number {
const timeStr = new SvelteDate().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const [h, m] = timeStr.split(':').map(Number);
return h * 60 + m;
}
/** Convert a UTC ISO timestamp to a London-date YYYY-MM-DD key.
* Uses Intl.DateTimeFormat with Europe/London timezone so that bookings
* at 23:30 UTC (00:30 BST next day) are grouped under the correct
* London date column rather than the UTC date. */
function getLondonDateKey(iso: string): string {
return new SvelteDate(iso).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
}
function formatWeekLabel(start: Date): string {
const end = new SvelteDate(start);
end.setDate(end.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
return `${start.toLocaleDateString('en-US', opts)} ${end.toLocaleDateString('en-US', endOpts)}`;
}
function formatHour(h: number): string {
const period = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
return `${hour} ${period}`;
}
function formatTimeShort(iso: string): string {
return new SvelteDate(iso).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
// Calculates pixel offset from the top of the grid.
// Each hour row is SLOT_HEIGHT (64px content + 1px border-b),
// so fractional hours are multiplied by SLOT_HEIGHT.
function gridPxFromTime(time: string, minStart: number): number {
const fractionalHours = timeToMinutes(time) / 60 - minStart;
return fractionalHours * SLOT_HEIGHT;
}
function getServiceNames(b: ScheduleBooking): string {
return (b.services ?? [])
.map((s) => s.service_name)
.filter(Boolean)
.join(', ');
}
function bookingStyle(status: string): string {
switch (status) {
case 'completed':
return 'background:#d1fae5;color:#065f46;border-color:#6ee7b7;text-decoration:line-through';
case 'confirmed':
return 'background:#d1fae5;color:#065f46;border-color:#6ee7b7;';
case 'in_progress':
return 'background:#dbeafe;color:#1e40af;border-color:#93c5fd';
case 'pending':
return 'background:#fef9c3;color:#854d0e;border-color:#fde047';
case 'client_cancelled':
return 'background:#fecaca;color:#7f1d1d;border-color:#f87171;text-decoration:line-through';
case 'we_cancelled':
return 'background:#fecaca;color:#7f1d1d;border-color:#f87171;text-decoration:line-through';
case 'no_show':
return 'background:#e5e7eb;color:#1f2937;border-color:#9ca3af;text-decoration:line-through';
default:
return 'background:#f3f4f6;color:#1f2937;border-color:#d1d5db';
}
}
function dotColor(status: string): string {
switch (status) {
case 'completed':
case 'confirmed':
return '#10b981';
case 'in_progress':
return '#3b82f6';
case 'pending':
return '#eab308';
case 'client_cancelled':
case 'we_cancelled':
return '#dc2626';
default:
return '#6b7280';
}
}
function bookingsOverlap(a: ScheduleBooking, b: ScheduleBooking): boolean {
const aStart = new SvelteDate(a.start_time).getTime();
const aEnd = aStart + a.duration_minutes * 60000;
const bStart = new SvelteDate(b.start_time).getTime();
const bEnd = bStart + b.duration_minutes * 60000;
return aStart < bEnd && bStart < aEnd;
}
// -- Data Fetching --
async function fetchWeekData() {
if (!weekStart) return;
if (!initialized) loading = true;
try {
const startStr = formatDate(weekStart);
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
const endStr = formatDate(end);
const [bookingsRes, whRes, blockersRes] = await Promise.all([
fetch(`/api/admin/bookings?start_date=${startStr}&end_date=${endStr}&per_page=500`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
}),
fetch(`/api/admin/time-blockers?start=${startStr}&end=${endStr}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
})
]);
if (bookingsRes.ok) {
const data = await bookingsRes.json();
const newBookings = data.bookings || [];
const newJson = JSON.stringify(newBookings);
if (newJson !== prevBookingsJson) {
bookings = newBookings;
prevBookingsJson = newJson;
}
}
if (blockersRes.ok) {
const data: TimeBlocker[] = await blockersRes.json();
const newBlockers = (data || []).filter((b) => !b.description?.startsWith('RESERVATION:'));
const newJson = JSON.stringify(newBlockers);
if (newJson !== prevBlockersJson) {
blockers = newBlockers;
prevBlockersJson = newJson;
}
}
if (whRes.ok) {
const data: WorkingDay[] = await whRes.json();
const newJson = JSON.stringify(data);
if (newJson !== prevHoursJson) {
workingHours = data;
prevHoursJson = newJson;
}
}
} catch (err) {
console.error('Error fetching schedule data:', err);
} finally {
if (!initialized) {
loading = false;
initialized = true;
}
}
}
$effect(() => {
if (weekStart) fetchWeekData();
});
$effect(() => {
if (pageState !== 'authorized') return;
const intervalId = setInterval(fetchWeekData, 600_000);
const handleRefresh = () => fetchWeekData();
window.addEventListener('bookingApproved', handleRefresh);
return () => {
clearInterval(intervalId);
window.removeEventListener('bookingApproved', handleRefresh);
};
});
// -- Interaction Handlers --
function onMouseDown(e: MouseEvent) {
if (!scrollContainer || e.button !== 0) return;
isDragging = true;
dragStartX = e.pageX;
dragStartY = e.pageY;
dragScrollLeft = scrollContainer.scrollLeft;
dragScrollTop = scrollContainer.scrollTop;
}
function onMouseMove(e: MouseEvent) {
if (!isDragging || !scrollContainer) return;
e.preventDefault();
scrollContainer.scrollLeft = dragScrollLeft - (e.pageX - dragStartX);
scrollContainer.scrollTop = dragScrollTop - (e.pageY - dragStartY);
}
function onMouseUp() {
isDragging = false;
}
function navigateWeek(delta: number) {
if (!weekStart) return;
const d = new SvelteDate(weekStart);
d.setDate(d.getDate() + delta * 7);
weekStart = d;
}
function goToday() {
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
}
function openBooking(id: string) {
selectedBookingId = id;
showBookingModal = true;
}
// -- Layout Constants --
const HOUR_HEIGHT = 64;
const SLOT_HEIGHT = HOUR_HEIGHT + 1; // each hour row has 1px border-b
const TIME_LABEL_WIDTH = 56;
const HEADER_HEIGHT = 48;
const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const today = getLondonToday();
const todayStr = formatDate(today);
// -- Derived State --
// Group bookings by London-date key for O(1) lookup in the loop.
// Using London date (not UTC date) ensures that bookings crossing midnight
// during BST (e.g. 23:30 UTC → 00:30 BST next day) appear in the correct column.
const bookingsByDate = $derived(
bookings.reduce((acc, b) => {
const dateKey = getLondonDateKey(b.start_time);
if (!acc.has(dateKey)) acc.set(dateKey, []);
acc.get(dateKey)!.push(b);
return acc;
}, new Map<string, ScheduleBooking[]>())
);
const blockersByDate = $derived(
blockers.reduce((acc, b) => {
const dateKey = getLondonDateKey(b.start_time);
if (!acc.has(dateKey)) acc.set(dateKey, []);
acc.get(dateKey)!.push(b);
return acc;
}, new Map<string, TimeBlocker[]>())
);
const timeRange = $derived.by(() => {
if (!weekStart)
return { minStart: 8, maxEnd: 18, hours: Array.from({ length: 11 }, (_, i) => i + 8) };
// Find earliest start and latest end across ALL data (hours, bookings, blockers)
let earliest = 24,
latest = 0;
for (const day of getWeekDays(weekStart)) {
const dateStr = formatDate(day);
const wh = workingHours.find((w) => w.date === dateStr);
if (wh?.isOpen) {
const [sh, sm] = wh.startTime.split(':').map(Number);
const sf = sh + (sm || 0) / 60;
if (sf < earliest) earliest = sf;
const [eh, em] = wh.endTime.split(':').map(Number);
const ef = eh + (em || 0) / 60;
if (ef > latest) latest = ef;
}
for (const b of bookingsByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
const ef = sf + b.duration_minutes / 60;
if (ef > latest) latest = ef;
}
for (const b of blockersByDate.get(dateStr) || []) {
const d = new SvelteDate(b.start_time);
const sf = d.getHours() + d.getMinutes() / 60;
if (sf < earliest) earliest = sf;
const ef = sf + b.duration_minutes / 60;
if (ef > latest) latest = ef;
}
}
// No data: fall back to 8-18
if (earliest >= 24 || latest <= 0)
return { minStart: 8, maxEnd: 18, hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] };
// Start: 30min buffer before earliest (to show the pre-booking gap)
// End: floor of latest (the row that CONTAINS the latest item, not the next row)
let minStart = Math.floor(earliest - 0.5);
let maxEnd = Math.floor(latest);
minStart = Math.max(0, minStart);
maxEnd = Math.min(24, maxEnd);
const hours: number[] = [];
for (let h = minStart; h <= maxEnd; h++) hours.push(h);
return { minStart, maxEnd, hours };
});
const isCurrentWeek = $derived(
weekStart !== undefined &&
(() => {
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return today >= weekStart && today <= end;
})()
);
let nowMinutes = $state(getLondonNowMinutes());
$effect(() => {
if (pageState !== 'authorized') return;
const tickId = setInterval(() => {
nowMinutes = getLondonNowMinutes();
}, 30_000);
return () => clearInterval(tickId);
});
</script>
<svelte:head>
<script>
(function () {
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
{#if pageState === 'loading'}
<div class="mx-auto max-w-7xl space-y-6 p-4 md:p-6">
<Skeleton class="h-8 w-64" />
<Skeleton class="h-[calc(100vh-12rem)] w-full" />
</div>
{:else if pageState === 'authorized' && weekStart}
<div class="mx-auto flex h-[calc(100vh-4rem)] max-w-7xl flex-col overflow-hidden p-4 md:p-6">
<!-- NAVIGATION BAR -->
<div class="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-3">
<h1 class="font-['Playfair_Display'] text-3xl font-bold">Schedule</h1>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => navigateWeek(-1)}>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<span class="hidden sm:inline">Prev</span>
</Button>
<Button variant="outline" size="sm" onclick={goToday}>Today</Button>
<Button variant="outline" size="sm" onclick={() => navigateWeek(1)}>
<span class="hidden sm:inline">Next</span>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</Button>
<span class="ml-2 text-sm font-medium text-gray-600">{formatWeekLabel(weekStart)}</span>
</div>
</div>
<!-- GRID CONTAINER -->
{#if loading}
<div class="flex-1 overflow-hidden rounded-lg border bg-white">
<Skeleton class="h-full w-full" />
</div>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={scrollContainer}
role="application"
aria-label="Week schedule"
class="schedule-wrapper flex-1 overflow-auto rounded-lg border bg-white select-none"
class:cursor-grab={!isDragging}
class:cursor-grabbing={isDragging}
onmousedown={onMouseDown}
onmousemove={onMouseMove}
onmouseup={onMouseUp}
onmouseleave={onMouseUp}
>
<div class="relative isolate flex min-w-fit flex-col">
<!-- STICKY HEADER ROW -->
<div
class="sticky top-0 z-30 flex border-b bg-gray-50"
style="height: {HEADER_HEIGHT}px;"
>
<!-- Time Column Spacer -->
<div class="shrink-0 border-r bg-gray-50" style="width: {TIME_LABEL_WIDTH}px;"></div>
{#each getWeekDays(weekStart) as day, dayIdx (formatDate(day))}
{@const isToday = formatDate(day) === todayStr}
<div
class="flex min-w-35 flex-1 flex-col items-center justify-center border-l text-center
{isToday ? 'bg-blue-200' : ''}"
>
<div class="text-xs font-medium text-gray-500">{dayHeaders[dayIdx]}</div>
<div class="text-sm font-bold {isToday ? 'text-blue-600' : ''}">
{day.getDate()}
</div>
</div>
{/each}
</div>
<!-- BODY: TIME COLUMN + GRID -->
<div class="relative flex">
<!-- 1. SOLID TIME PANE (Left Column) -->
<div
class="sticky left-0 z-20 shrink-0 border-r border-gray-100 bg-white"
style="width: {TIME_LABEL_WIDTH}px;"
>
{#each timeRange.hours as h (h)}
<div
class="flex items-start justify-end border-b border-gray-100 pt-0.5 pr-1.5 text-right text-xs text-gray-400"
style="height: {SLOT_HEIGHT}px;"
>
{formatHour(h)}
</div>
{/each}
</div>
<!-- 2. GRID CONTAINER (Day Columns) -->
<div class="relative flex flex-1 flex-col">
<!-- Grid Rows & Cells -->
<div class="relative">
{#each timeRange.hours as h (h)}
<div class="flex border-b border-gray-100">
{#each getWeekDays(weekStart) as day (formatDate(day))}
{@const dateStr = formatDate(day)}
{@const isToday = dateStr === todayStr}
{@const dayBookings = bookingsByDate.get(dateStr) || []}
{@const dayBlockers = blockersByDate.get(dateStr) || []}
<div
class="relative min-w-35 flex-1 border-r border-gray-100 {isToday
? 'bg-blue-50'
: ''}"
style="height: {HOUR_HEIGHT}px;"
>
<!-- Half-hour dashed line -->
<div class="half-hour-line absolute inset-x-0 h-px" style="top: 50%;"></div>
{#each dayBlockers.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
<div
class="absolute inset-x-0.5 z-[9] overflow-hidden rounded border border-amber-200 bg-amber-50/90 px-1.5 py-0.5 text-left text-xs text-amber-900 shadow-sm backdrop-blur-sm"
style="top: {topOffset}px; height: {heightPx}px;"
>
<div class="flex items-center gap-1">
<div class="h-2 w-2 shrink-0 rounded-full bg-amber-500"></div>
<span class="truncate font-medium">{b.description || 'Blocker'}</span>
</div>
{#if heightPx > 36}
<div class="mt-1 truncate text-[10px] opacity-75">
{formatTimeShort(b.start_time)} · {formatDuration(
b.duration_minutes
)}
</div>
{/if}
</div>
{/each}
<!-- BOOKING BLOCKS -->
{#each dayBookings.filter((b) => new SvelteDate(b.start_time).getHours() === h) as b (b.id)}
{@const startMinutes = new SvelteDate(b.start_time).getMinutes()}
{@const topOffset = (startMinutes / 60) * HOUR_HEIGHT}
{@const heightPx = Math.max((b.duration_minutes / 60) * HOUR_HEIGHT, 20)}
{@const isCancelled =
b.status === 'client_cancelled' ||
b.status === 'we_cancelled' ||
b.status === 'no_show'}
{@const hasOverlap =
isCancelled &&
dayBookings.some(
(other) =>
other.id !== b.id &&
!(
other.status === 'client_cancelled' ||
other.status === 'we_cancelled' ||
other.status === 'no_show'
) &&
bookingsOverlap(b, other)
)}
{#if !hasOverlap}
<button
type="button"
aria-label="View booking for {formatUserName(
b.user?.full_name || 'Guest',
b.user?.previous_first_name,
b.user?.previous_last_name
)}"
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
b.status
)}"
onclick={(e) => {
e.stopPropagation();
openBooking(b.id);
}}
>
<div class="flex items-center gap-1">
<div
class="h-2 w-2 shrink-0 rounded-full"
style="background:{dotColor(b.status)}"
></div>
<span class="truncate font-medium"
>{formatUserName(
b.user?.full_name || 'Guest',
b.user?.previous_first_name,
b.user?.previous_last_name
)}</span
>
</div>
{#if heightPx > 36}
<div class="line-clamp-3 text-[10px] opacity-75">
{getServiceNames(b)}
</div>
{/if}
{#if heightPx > 52}
<div class="truncate text-[10px] opacity-75">
{formatTimeShort(b.start_time)} · {formatDuration(
b.duration_minutes
)}
</div>
{/if}
</button>
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
<!-- 3. OVERLAY LAYER (Lines & Closed Stripes) -->
<!-- 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)}
{@const wh = workingHours.find((w) => w.date === dateStr)}
{@const colLeft = `calc(${dayIdx} * (100% / 7))`}
{@const colWidth = `calc(100% / 7)`}
{#if !wh?.isOpen}
<!-- Closed Day Overlay -->
<div
class="stripe-bg absolute flex items-center justify-center"
style="left:{colLeft}; top:0; width:{colWidth}; height:100%;"
>
<span
class="pointer-events-auto rounded bg-black/40 px-3 py-1 text-sm font-semibold text-white"
>Closed</span
>
</div>
{:else}
{@const openPx = gridPxFromTime(wh.startTime, timeRange.minStart)}
{@const closePx = gridPxFromTime(wh.endTime, timeRange.minStart)}
<!-- Pre-opening gray stripe -->
{#if openPx > 0}
<div
class="stripe-bg absolute"
style="left:{colLeft}; top:0; width:{colWidth}; height:{openPx}px;"
></div>
{/if}
<!-- Post-closing gray stripe -->
{#if closePx < timeRange.hours.length * SLOT_HEIGHT}
<div
class="stripe-bg absolute"
style="left:{colLeft}; top:{closePx}px; width:{colWidth}; bottom:0;"
></div>
{/if}
<!-- Opening/Closing Red Lines -->
<div
class="absolute border-t-2 border-red-500"
style="left:{colLeft}; top:{openPx}px; width:{colWidth};"
></div>
<div
class="absolute border-t-2 border-red-500"
style="left:{colLeft}; top:{closePx}px; width:{colWidth};"
></div>
{/if}
{/each}
<!-- CURRENT TIME INDICATOR -->
{#if isCurrentWeek}
{@const nowTime = `${String(Math.floor(nowMinutes / 60)).padStart(2, '0')}:${String(nowMinutes % 60).padStart(2, '0')}`}
{@const nowPx = gridPxFromTime(nowTime, timeRange.minStart)}
{@const todayIdx = getWeekDays(weekStart!).findIndex(
(d) => formatDate(d) === todayStr
)}
{@const colLeft = `calc(${todayIdx} * (100% / 7))`}
{@const colWidth = `calc(100% / 7)`}
{#if nowPx >= 0 && nowPx <= timeRange.hours.length * SLOT_HEIGHT && todayIdx !== -1}
<!-- Dot -->
<div
class="absolute h-3 w-3 rounded-full bg-blue-500"
style="left: calc({colLeft} - 6px); top: {nowPx - 6}px;"
></div>
<!-- Line -->
<!-- FIXED: Restricted width to current column using colLeft/colWidth -->
<div
class="time-indicator-line absolute h-0.5"
style="left: {colLeft}; width: {colWidth}; top: {nowPx}px;"
></div>
{/if}
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
{/if}
<style>
/* Diagonal stripe pattern for closed hours/days */
.stripe-bg {
background: repeating-linear-gradient(
45deg,
rgba(156, 163, 175, 0.4),
rgba(156, 163, 175, 0.4) 10px,
rgba(209, 213, 219, 0.15) 10px,
rgba(209, 213, 219, 0.15) 20px
);
}
/* Half-hour dashed line */
.half-hour-line {
background: repeating-linear-gradient(
to right,
#e5e7eb 0,
#e5e7eb 4px,
transparent 4px,
transparent 16px
);
}
/* Current time indicator line */
.time-indicator-line {
background: repeating-linear-gradient(
to right,
#3b82f6 0,
#3b82f6 8px,
transparent 8px,
transparent 16px
);
}
</style>