From 169d7dc6e3138b4d041b213e5249396566db8d60 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Wed, 3 Jun 2026 10:22:16 +0100 Subject: [PATCH] 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 --- backend/handlers/scheduling/default-hours.go | 20 +++++- .../lib/components/admin/ImageUpload.svelte | 43 ++++++++++--- .../src/lib/components/layout/NavBar.svelte | 26 ++++++-- .../lib/components/today/TodayCalendar.svelte | 63 +++++++++++++++---- frontend/src/lib/lunchProtection.ts | 16 +++++ frontend/src/lib/utils/format.ts | 9 +++ 6 files changed, 150 insertions(+), 27 deletions(-) diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 2434295..a4645f5 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -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 diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte index 54f671c..6e0d2d7 100644 --- a/frontend/src/lib/components/admin/ImageUpload.svelte +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -14,6 +14,21 @@ let uploadStatus = $state>({}); 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 @@
Drop or Select Files - Supported: JPEG, PNG, WebP, and other image formats + HEIC, AVIF, WebP, PNG, JPEG and more. Maximum 20MB per file
@@ -529,7 +544,7 @@

Drop images here or click to browse

-

Supports AVIF, PNG, JPG, WebP and other formats

+

HEIC, AVIF, WebP, PNG, JPEG, GIF & more — max 20MB per file

@@ -555,8 +570,11 @@
{#each uploadFiles as f (f.name)} {@const preview = filePreviews.find((p) => p.file === f)?.preview} + {@const oversized = isFileTooBig(f)}
@@ -580,9 +598,16 @@ {/if}
-

{f.name}

+

+ {f.name} +

-

{(f.size / 1024).toFixed(1)} KB

+

+ {formatFileSize(f.size)} + {#if oversized} + — exceeds 20MB limit + {/if} +

{#if uploading && uploadStatus[f.name]} {/each}
-
- {/if} +
+ {/if} - + {#if uploadResults.length > 0}

Upload Results

@@ -780,7 +805,7 @@
-
+ {#if mobileMenuOpen} +
+ {/if} + {#if mobileMenuOpen} -
+
{#each links as link} {#if canShow(link)} diff --git a/frontend/src/lib/components/today/TodayCalendar.svelte b/frontend/src/lib/components/today/TodayCalendar.svelte index 9a6afd8..4a12506 100644 --- a/frontend/src/lib/components/today/TodayCalendar.svelte +++ b/frontend/src/lib/components/today/TodayCalendar.svelte @@ -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 @@

+ {:else if item.type === 'closing'} +
+
+ {item.data.label} +
+
+ Close +
{/if} {/each}
diff --git a/frontend/src/lib/lunchProtection.ts b/frontend/src/lib/lunchProtection.ts index 30ca7b6..3b1fc63 100644 --- a/frontend/src/lib/lunchProtection.ts +++ b/frontend/src/lib/lunchProtection.ts @@ -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); diff --git a/frontend/src/lib/utils/format.ts b/frontend/src/lib/utils/format.ts index 3b0720d..8444468 100644 --- a/frontend/src/lib/utils/format.ts +++ b/frontend/src/lib/utils/format.ts @@ -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). *