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:
2026-06-03 10:22:16 +01:00
parent b1847b4cfc
commit 169d7dc6e3
6 changed files with 150 additions and 27 deletions
+18 -2
View File
@@ -141,11 +141,19 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
defRows.Close()
// Load exceptional applications for Mondays in range
// Expand start to the Monday of its week so single-day queries still find the correct application
startWeekday := int(start.Weekday())
daysSinceMonday := startWeekday - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.DB.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
`, start, end)
`, queryStart, end)
type appEntry struct {
GroupID int
WeekStart time.Time
@@ -321,11 +329,19 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
defRows.Close()
// Load exceptional applications for Mondays in range
// Expand start to the Monday of its week so single-day queries still find the correct application
startWeekday := int(start.Weekday())
daysSinceMonday := startWeekday - 1
if daysSinceMonday < 0 {
daysSinceMonday = 6 // Sunday
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.DB.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
`, start, end)
`, queryStart, end)
type appEntry struct {
GroupID int
WeekStart time.Time
@@ -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 &amp; more &mdash; 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}
&mdash; 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>
+16
View File
@@ -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);
+9
View File
@@ -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).
*