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
@@ -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>