feat(scheduling,admin,today): week-range queries, file size limits, mobile nav UX
- Expand exceptional application query to Monday of start week - Add 20MB file size limit with visual feedback in ImageUpload - Reorder admin nav links, add burger badge, slide transition + backdrop - Fetch week-range working/available hours, add closing time indicator - Skip lunch protection for days <= 5h via shouldApplyLunchProtection - Extract formatDateISO to shared utils
This commit is contained in:
@@ -14,6 +14,21 @@
|
||||
let uploadStatus = $state<Record<string, string>>({});
|
||||
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
|
||||
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
return (bytes / 1024).toFixed(1) + ' KB';
|
||||
}
|
||||
|
||||
function isFileTooBig(file: File): boolean {
|
||||
return file.size > MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
let hasOversizedFiles = $derived(uploadFiles.some(isFileTooBig));
|
||||
|
||||
function handleFilesDropped(files: File[]) {
|
||||
uploadFiles = files;
|
||||
generatePreviews(files);
|
||||
@@ -505,7 +520,7 @@
|
||||
<div class="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<Card.Title>Drop or Select Files</Card.Title>
|
||||
<Card.Description>Supported: JPEG, PNG, WebP, and other image formats</Card.Description>
|
||||
<Card.Description>HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
@@ -529,7 +544,7 @@
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-gray-700">Drop images here or click to browse</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Supports AVIF, PNG, JPG, WebP and other formats</p>
|
||||
<p class="mt-1 text-xs text-gray-500">HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file</p>
|
||||
</div>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -555,8 +570,11 @@
|
||||
<div class="space-y-2">
|
||||
{#each uploadFiles as f (f.name)}
|
||||
{@const preview = filePreviews.find((p) => p.file === f)?.preview}
|
||||
{@const oversized = isFileTooBig(f)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-3 sm:p-4"
|
||||
class="flex items-center justify-between rounded-lg border bg-white p-3 sm:p-4"
|
||||
class:border-red-300={oversized}
|
||||
class:border-gray-200={!oversized}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -580,9 +598,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-gray-900">{f.name}</p>
|
||||
<p class="truncate text-sm font-medium" class:text-gray-900={!oversized} class:text-red-900={oversized}>
|
||||
{f.name}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-gray-500">{(f.size / 1024).toFixed(1)} KB</p>
|
||||
<p class="text-xs" class:text-gray-500={!oversized} class:text-red-600={oversized}>
|
||||
{formatFileSize(f.size)}
|
||||
{#if oversized}
|
||||
— exceeds 20MB limit
|
||||
{/if}
|
||||
</p>
|
||||
{#if uploading && uploadStatus[f.name]}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs font-medium text-blue-600"
|
||||
@@ -625,10 +650,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Results Section -->
|
||||
<!-- Upload Results Section -->
|
||||
{#if uploadResults.length > 0}
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="mb-4 font-semibold text-gray-900">Upload Results</h3>
|
||||
@@ -780,7 +805,7 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
|
||||
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading || hasOversizedFiles}>
|
||||
{#if uploading}
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { navigating, page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
@@ -9,13 +10,13 @@
|
||||
// Centralized link definition
|
||||
const links = [
|
||||
{ href: '/', label: 'Home', showWhen: 'non-admin', width: 'w-12' },
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/admin/schedule', label: 'Schedule', showWhen: 'admin', width: 'w-20' },
|
||||
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
|
||||
{ href: '/schedule', label: 'My Schedule', showWhen: 'auth-not-admin', width: 'w-24' },
|
||||
{ href: '/book', label: 'Book an appointment', showWhen: 'non-admin', width: 'w-36' },
|
||||
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
|
||||
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/admin/schedule', label: 'Schedule', showWhen: 'admin', width: 'w-20' },
|
||||
{ href: '/contact', label: 'Contact', showWhen: 'non-admin', width: 'w-16' },
|
||||
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }
|
||||
];
|
||||
@@ -178,7 +179,7 @@
|
||||
|
||||
<!-- Mobile: Burger -->
|
||||
<div class="flex items-center md:hidden">
|
||||
<button onclick={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
|
||||
<button onclick={toggleMenu} class="relative focus:outline-none" aria-label="Toggle menu">
|
||||
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -187,14 +188,31 @@
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
{#if unreadCount > 0 && !mobileMenuOpen}
|
||||
<span
|
||||
class="absolute -top-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 top-16 bg-black/30 backdrop-blur-[1px] md:hidden"
|
||||
onclick={toggleMenu}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{#if mobileMenuOpen}
|
||||
<div class="border-b border-gray-200 bg-background md:hidden">
|
||||
<div
|
||||
transition:slide={{ duration: 100 }}
|
||||
class="relative z-50 border-b border-gray-200 bg-background md:hidden"
|
||||
>
|
||||
<div class="space-y-1 px-2 pt-2 pb-3">
|
||||
{#each links as link}
|
||||
{#if canShow(link)}
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
import {
|
||||
calculateMiddleWindow,
|
||||
extractBookedSlots,
|
||||
findAllLunchGaps
|
||||
findAllLunchGaps,
|
||||
shouldApplyLunchProtection,
|
||||
timeToMinutes
|
||||
} from '$lib/lunchProtection';
|
||||
import { formatDuration } from '$lib/utils/format';
|
||||
import { formatDuration, formatDateISO } from '$lib/utils/format';
|
||||
|
||||
interface Props {
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
@@ -98,9 +100,20 @@
|
||||
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
|
||||
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
|
||||
|
||||
const today = $derived.by(() => {
|
||||
const today = $derived(formatDateISO(new SvelteDate()));
|
||||
|
||||
const weekStartStr = $derived.by(() => {
|
||||
const d = new SvelteDate();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
d.setDate(d.getDate() - diff);
|
||||
return formatDateISO(d);
|
||||
});
|
||||
|
||||
const weekEndStr = $derived.by(() => {
|
||||
const d = new SvelteDate(weekStartStr);
|
||||
d.setDate(d.getDate() + 6);
|
||||
return formatDateISO(d);
|
||||
});
|
||||
|
||||
function to24h(hour: string, minute: string, period: 'AM' | 'PM'): string {
|
||||
@@ -110,11 +123,6 @@
|
||||
return `${String(h).padStart(2, '0')}:${minute}`;
|
||||
}
|
||||
|
||||
function timeToMinutes(time: string): number {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function minutesTo12h(totalMin: number): { hour: string; minute: string; period: 'AM' | 'PM' } {
|
||||
let h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
@@ -179,6 +187,9 @@
|
||||
if (!workingHours || !availableHours || !workingHours.isOpen) return null;
|
||||
const wh = workingHours;
|
||||
const ah = availableHours;
|
||||
|
||||
if (!shouldApplyLunchProtection(wh.startTime, wh.endTime)) return null;
|
||||
|
||||
const { windowStart, windowEnd } = calculateMiddleWindow(wh.startTime, wh.endTime);
|
||||
const existingBookings = extractBookedSlots(wh.startTime, wh.endTime, ah.slots);
|
||||
const gaps = findAllLunchGaps(windowStart, windowEnd, existingBookings);
|
||||
@@ -246,7 +257,14 @@
|
||||
data: { duration: number; startLabel: string; endLabel: string };
|
||||
};
|
||||
|
||||
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem;
|
||||
type ClosingTimeTimelineItem = {
|
||||
id: string;
|
||||
startMinutes: number;
|
||||
type: 'closing';
|
||||
data: { label: string };
|
||||
};
|
||||
|
||||
type TimelineItem = AppointmentTimelineItem | BlockerTimelineItem | LunchTimelineItem | ClosingTimeTimelineItem;
|
||||
|
||||
let timeline = $derived.by(() => {
|
||||
const items: TimelineItem[] = [];
|
||||
@@ -289,6 +307,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (workingHours?.isOpen && workingHours.endTime) {
|
||||
const closeMin = timeToMinutes(workingHours.endTime);
|
||||
const t = minutesTo12h(closeMin);
|
||||
items.push({
|
||||
id: 'closing',
|
||||
startMinutes: closeMin,
|
||||
type: 'closing',
|
||||
data: { label: `${t.hour}:${t.minute} ${t.period}` }
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => a.startMinutes - b.startMinutes);
|
||||
});
|
||||
|
||||
@@ -346,7 +375,7 @@
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`/api/scheduling/working-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||
`/api/scheduling/working-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -355,7 +384,7 @@
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`/api/scheduling/available-hours?start=${encodeURIComponent(dateParam)}&end=${encodeURIComponent(dateParam)}`,
|
||||
`/api/scheduling/available-hours?start=${encodeURIComponent(weekStartStr)}&end=${encodeURIComponent(weekEndStr)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -834,6 +863,16 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if item.type === 'closing'}
|
||||
<div class="flex items-center gap-3 border-t border-gray-100 px-1 pt-3">
|
||||
<div
|
||||
class="text-xs font-medium text-gray-400"
|
||||
>
|
||||
{item.data.label}
|
||||
</div>
|
||||
<div class="h-px flex-1 bg-gray-100"></div>
|
||||
<span class="text-[11px] text-gray-400">Close</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,15 @@ export function findLargestLunchGap(
|
||||
export const LUNCH_MINIMUM_USER = 60;
|
||||
export const LUNCH_MINIMUM_ADMIN = 30;
|
||||
export const LUNCH_WARNING_THRESHOLD = 60;
|
||||
export const LUNCH_MIN_DAY_DURATION = 5 * 60; // 300 minutes — skip lunch for days ≤ 5h
|
||||
|
||||
/**
|
||||
* Check lunch protection should apply at all for a day of this duration
|
||||
*/
|
||||
export function shouldApplyLunchProtection(dayStartTime: string, dayEndTime: string): boolean {
|
||||
const dayDuration = timeToMinutes(dayEndTime) - timeToMinutes(dayStartTime);
|
||||
return dayDuration > LUNCH_MIN_DAY_DURATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proposed booking slot violates lunch protection
|
||||
@@ -145,6 +154,9 @@ export function checkLunchProtection(
|
||||
proposedSlotEnd: string,
|
||||
isAdmin: boolean
|
||||
): LunchProtectionResult {
|
||||
if (!shouldApplyLunchProtection(dayStartTime, dayEndTime)) {
|
||||
return { isBlocked: false, showWarning: false };
|
||||
}
|
||||
const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTime, dayEndTime);
|
||||
|
||||
const windowDuration = windowEnd - windowStart;
|
||||
@@ -288,6 +300,10 @@ export function getLunchProtectionForSlots(
|
||||
return results;
|
||||
}
|
||||
|
||||
if (!shouldApplyLunchProtection(dayStartTime, dayEndTime)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const dayStartMinutes = timeToMinutes(dayStartTime);
|
||||
const dayEndMinutes = timeToMinutes(dayEndTime);
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ export function formatDateTime(date: Date | string): string {
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date to ISO date string "YYYY-MM-DD".
|
||||
*
|
||||
* Example: new Date(2026, 4, 29) → "2026-05-29"
|
||||
*/
|
||||
export function formatDateISO(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date value to "Weekday, Month Day" only (no time).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user