From 238cff0795fe2880a005fd74b8234dc9b9b31871 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 22 Jun 2026 20:26:23 +0200 Subject: [PATCH 1/2] Use the local first day of week --- src/lib/components/ui/calendar/calendar.svelte | 8 ++++++++ src/lib/utils/datetime.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/lib/components/ui/calendar/calendar.svelte b/src/lib/components/ui/calendar/calendar.svelte index 0f842b0..d281ac9 100644 --- a/src/lib/components/ui/calendar/calendar.svelte +++ b/src/lib/components/ui/calendar/calendar.svelte @@ -1,3 +1,9 @@ + @@ -95,27 +103,32 @@ {m["nav.tenants"]()} - {#each $tenants?.tenants.slice(0, maxTenantsToShow) as tenant (tenant.id)} + {#each tenantList.slice(0, maxTenantsToShow) as tenant (tenant.id)} tenants.setCurrentTenant(tenant.id)} - class="gap-2 p-2" + class="justify-between gap-2 p-2" > -
- {#if tenant.logo} - {tenant.shortName} - {:else} - - {/if} +
+
+ {#if tenant.logo} + {tenant.shortName} + {:else} + + {/if} +
+ {tenant.shortName}
- {tenant.shortName} + {#if tenant.id === activeTenantId} + + {/if} {/each} - {#if $tenants?.tenants.length > maxTenantsToShow} + {#if tenantList.length > maxTenantsToShow} + import * as Calendar from "$lib/components/ui/calendar"; + import * as Sidebar from "$lib/components/ui/sidebar"; + import { cn } from "$lib/utils"; + import { type DateValue } from "@internationalized/date"; + import { type ComponentProps } from "svelte"; + + let { + day, + outsideMonth, + isDateEmpty, + isDateHighlighted, + }: ComponentProps & { + day: DateValue; + outsideMonth: boolean; + isDateEmpty?: (day: DateValue) => boolean; + isDateHighlighted?: (day: DateValue) => boolean; + } = $props(); + + + + {#if !outsideMonth} +
+
+ {day.day} +
+ {#if isDateHighlighted && isDateHighlighted(day)} + + {/if} +
+ {/if} +
diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c4c7a25..641408d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -21,3 +21,11 @@ export function normalizeEmail(value?: string | null): string | undefined { return value.trim().toLowerCase(); } + +export const debounce = void>(fn: T, ms: number): T => { + let timeout: ReturnType; + return ((...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), ms); + }) as T; +}; diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index a59c862..643fa98 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -185,10 +185,46 @@ export const utcToLocal = (utcDate: Date): Date => { return new Date(utcDate.getTime() - currentOffsetMs); }; +const sundayStartLocales = new Set([ + "en-US", + "en-CA", + "ja", + "ja-JP", + "ko", + "ko-KR", + "zh", + "zh-TW", + "zh-CN", + "he", + "he-IL", + "ar-SA", + "th", + "th-TH", + "pt-BR", +]); + +const saturdayStartLocales = new Set([ + "ar", + "ar-AE", + "ar-EG", + "ar-BH", + "ar-DZ", + "ar-IQ", + "ar-JO", + "ar-KW", + "ar-LY", + "ar-MA", + "ar-OM", + "ar-QA", + "ar-SY", + "ar-YE", + "fa", + "fa-IR", +]); + export const getWeekStartsOn = (): 0 | 1 | 2 | 3 | 4 | 5 | 6 => { - const locale = new Intl.Locale(navigator.language); - // @ts-expect-error weekInfo is available in modern browsers - const weekInfo = locale.weekInfo ?? (locale as Locale).getWeekInfo(); - const firstDay = weekInfo?.firstDay ?? 1; - return (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6; + const locale = navigator.language; + if (saturdayStartLocales.has(locale)) return 6; + if (sundayStartLocales.has(locale)) return 0; + return 1; }; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte index ff9d618..7a71a81 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -4,6 +4,7 @@ import { getLocale } from "$i18n/runtime"; import { Button } from "$lib/components/ui/button"; import * as Calendar from "$lib/components/ui/calendar"; + import Day from "$lib/components/ui/calendar-custom/day.svelte"; import * as Card from "$lib/components/ui/card"; import { ScrollArea } from "$lib/components/ui/scroll-area"; import { Text } from "$lib/components/ui/typography"; @@ -28,6 +29,7 @@ import type { DateMatcher } from "bits-ui"; import type { OnChangeFn } from "vaul-svelte"; import { fetchSchedule } from "./utils"; + import { debounce } from "$lib/utils"; const { channel, @@ -81,6 +83,14 @@ }); }; + const debouncedPlaceholderChange = debounce((placeholder: DateValue | undefined) => { + if (!placeholder) return; + if (placeholder.year === year && placeholder.month === month) return; + year = placeholder.year; + month = placeholder.month; + schedule = undefined; + }, 300); + const setToToday = () => { if (browser) { const todayDate = today(getLocalTimeZone()); @@ -189,21 +199,19 @@ { - if (!placeholder) return; - if (placeholder.year === year && placeholder.month === month) return; - year = placeholder.year; - month = placeholder.month; - schedule = undefined; - }} - /> + onPlaceholderChange={debouncedPlaceholderChange} + > + {#snippet day({ day, outsideMonth })} + + {/snippet} +
{#if slots === null} diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index acde2c7..afb2486 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -7,12 +7,22 @@ import { staffCrypto } from "$lib/stores/staff-crypto"; import { notifications } from "$lib/stores/notifications"; import { staff } from "$lib/stores/staff"; + import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; + import { browser } from "$app/environment"; let { data, children }: LayoutProps = $props(); let intervalSession: ReturnType | null = null; let intervalData: ReturnType | null = null; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + enabled: browser, + }, + }, + }); + onMount(() => { if (data?.user) { auth.setUser(data.user); @@ -76,4 +86,6 @@ }; -{@render children()} + + {@render children()} + diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte index afea0ef..ede8489 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte @@ -14,14 +14,17 @@ import { sidebar as sidebarStore } from "$lib/stores/sidebar"; import type { TAppointmentFilter } from "$lib/types/calendar"; import { cn } from "$lib/utils"; - import { PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte"; + import { CalendarDate } from "@internationalized/date"; + import { FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte"; import { type ComponentProps } from "svelte"; + import CalendarMonth from "./CalendarMonth.svelte"; let { shownAppointments = $bindable(), shownChannels = $bindable(), shownAgents = $bindable(), scale = $bindable(), + selectedDate = $bindable(), ref = $bindable(null), ...restProps }: ComponentProps & { @@ -29,6 +32,7 @@ shownChannels: string[]; shownAgents: string[]; scale: number; + selectedDate: CalendarDate; } = $props(); const appointmentStates: { value: TAppointmentFilter; label: string }[] = [ @@ -49,6 +53,12 @@ const nextIndex = currentIndex + direction; scale = zoomSteps[nextIndex]; }; + + const clearFilters = () => { + shownAppointments = "all"; + shownChannels = []; + shownAgents = []; + }; - {m["calendar.shownAppointments.title"]()} - - {#each appointmentStates as state (state.value)} -
- - +
+
+
+ {m["calendar.shownAppointments.title"]()} +
- {/each} - + + {#each appointmentStates as state (state.value)} +
+ + +
+ {/each} +
+
+ {#if channels.length > 1} +
+ {m["channels.title"]()} + {#each channels as channel (channel.id)} + {@const locale = getLocale()} + {@const name = channel.names[locale] || Object.values(channel.names)[0]} +
+ { + if (v) { + shownChannels = [...shownChannels, channel.id]; + } else { + shownChannels = shownChannels.filter((id) => id !== channel.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} + {#if agents.length > 1} +
+ {m["agents.title"]()} + {#each agents as agent (agent.id)} +
+ { + if (v) { + shownAgents = [...shownAgents, agent.id]; + } else { + shownAgents = shownAgents.filter((id) => id !== agent.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} +
- - - {#if channels.length > 1} -
- {m["channels.title"]()} - {#each channels as channel (channel.id)} - {@const locale = getLocale()} - {@const name = channel.names[locale] || Object.values(channel.names)[0]} -
- { - if (v) { - shownChannels = [...shownChannels, channel.id]; - } else { - shownChannels = shownChannels.filter((id) => id !== channel.id); - } - }} - class="mt-2 mb-1" - /> -
- {/each} -
- {/if} - {#if agents.length > 1} -
- {m["agents.title"]()} - {#each agents as agent (agent.id)} -
- { - if (v) { - shownAgents = [...shownAgents, agent.id]; - } else { - shownAgents = shownAgents.filter((id) => id !== agent.id); - } - }} - class="mt-2 mb-1" - /> -
- {/each} -
- {/if} + + + sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)} + /> diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte index 356f336..eb717d1 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte @@ -1,32 +1,53 @@ @@ -37,15 +58,28 @@ -
- {Intl.DateTimeFormat(getLocale(), { - year: "numeric", - month: "long", - day: "numeric", - weekday: "short", - timeZone: getLocalTimeZone().toString(), - }).format(startDate.toDate(getLocalTimeZone()))} -
+ + + {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + timeZone: getLocalTimeZone().toString(), + }).format(selectedDate.toDate(getLocalTimeZone()))} + + + + + @@ -54,7 +88,7 @@ size="sm" variant="outline" onclick={setToToday} - disabled={startDate.toString() === today(getLocalTimeZone()).toString()} + disabled={selectedDate.toString() === today(getLocalTimeZone()).toString()} class="-order-1 ml-auto min-[500px]:order-0" > {m["calendar.today"]()} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarMonth.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarMonth.svelte new file mode 100644 index 0000000..32b40bf --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarMonth.svelte @@ -0,0 +1,189 @@ + + +
+
+ +
+ + + +
+
+ + {#snippet day({ day, outsideMonth })} + + {/snippet} + +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/queries.ts b/src/routes/(pages)/dashboard/calendar/(components)/queries.ts new file mode 100644 index 0000000..73710da --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/queries.ts @@ -0,0 +1,9 @@ +import type { CalendarDate } from "@internationalized/date"; +import { fetchCalendar } from "./utils"; + +export const calendarMonthQuery = (tenant: string | undefined | null, date: CalendarDate) => ({ + queryKey: ["calendar", `${date.year}-${date.month}`], + queryFn: () => fetchCalendar({ tenant: tenant as string, selectedDate: date }), + enabled: Boolean(tenant), + staleTime: 5000, +}); diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts index 0e7c44d..00b24c6 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -10,14 +10,26 @@ import { localToUTCWithoutDST } from "$lib/utils/datetime"; import { getLocalTimeZone, type CalendarDate } from "@internationalized/date"; import { get } from "svelte/store"; -export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => { +export const fetchCalendar = async (opts: { tenant: string; selectedDate: CalendarDate }) => { if (!browser) return; const localStartDate = new Date( - Date.UTC(opts.startDate.year, opts.startDate.month - 1, opts.startDate.day, 0, 0, 0, 0), + opts.selectedDate.year, + opts.selectedDate.month - 1, + 1, + 0, + 0, + 0, + 0, ); const localEndDate = new Date( - Date.UTC(opts.startDate.year, opts.startDate.month - 1, opts.startDate.day, 23, 59, 59, 999), + opts.selectedDate.year, + opts.selectedDate.month, + 0, + 23, + 59, + 59, + 999, ); const params = new URLSearchParams({ diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 1e540ee..82c00cc 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -12,7 +12,7 @@ import { calendarStore } from "$lib/stores/calendar"; import { channels as channelsStore } from "$lib/stores/channels"; import { sidebar } from "$lib/stores/sidebar"; - import type { TAppointmentFilter, TCalendar, TCalendarItem } from "$lib/types/calendar"; + import type { TAppointmentFilter, TCalendarItem } from "$lib/types/calendar"; import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { getCurrentTranlslation } from "$lib/utils/localizations"; import { @@ -22,31 +22,35 @@ today, type CalendarDate, } from "@internationalized/date"; - import { Funnel } from "@lucide/svelte"; + import { SlidersHorizontal } from "@lucide/svelte"; import { onMount } from "svelte"; import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte"; import Appointment from "./(components)/Appointment.svelte"; import CalendarDay from "./(components)/CalendarDay.svelte"; import CalendarFilters from "./(components)/CalendarFilters.svelte"; import CalendarHeader from "./(components)/CalendarHeader.svelte"; - import { fetchCalendar, openAppointmentById } from "./(components)/utils"; + import { openAppointmentById } from "./(components)/utils"; + import { createQuery, useQueryClient } from "@tanstack/svelte-query"; + import { calendarMonthQuery } from "./(components)/queries"; const convertDate = (dateStr: string) => { const zonedDateTime = parseAbsoluteToLocal(dateStr); return toCalendarDate(zonedDateTime); }; + const queryClient = useQueryClient(); const tenantId = $derived($auth.user?.tenantId); const curEmptySlot = $derived($calendarStore.curEmptySlot); const curItem = $derived($calendarStore.curItem); const channels = $derived($channelsStore.channels); const agents = $derived($agentsStore.agents); - let startDate: CalendarDate = $state( + let selectedDate: CalendarDate = $state( "date" in page.state ? convertDate(history?.state["sveltekit:states"].date) : today(getLocalTimeZone()), ); - let calender: TCalendar | undefined = $state(); + const query = createQuery(() => calendarMonthQuery(tenantId as string, selectedDate)); + const calendar = $derived(query.data); let shownAppointments: TAppointmentFilter = $state("all"); let shownChannels: string[] = $state([]); let shownAgents: string[] = $state([]); @@ -70,7 +74,10 @@ let scale = $state(1); $effect(() => { - updateCalendar(); + const getMonth = (x: string | undefined) => (x ? new Date(x).getMonth() + 1 : undefined); + if (!calendar || getMonth(calendar?.period.startDate) !== getMonth(selectedDate.toString())) { + updateCalendar(); + } }); $effect(() => { @@ -100,8 +107,8 @@ "appointmentId" in page.state ) { const date = convertDate(page.state.date as string); - if (date.toString() !== startDate.toString()) { - startDate = date; + if (date.toString() !== selectedDate.toString()) { + selectedDate = date; replaceState("", { appointmentId: page.state.appointmentId }); } else { if (items) { @@ -115,8 +122,9 @@ const updateCalendar = async () => { if (tenantId) { - calender = undefined; - calender = await fetchCalendar({ startDate, tenant: tenantId }); + queryClient.invalidateQueries({ + queryKey: ["calendar"], + }); } }; @@ -125,8 +133,8 @@ }; let items: TCalendarItem[] | undefined = $derived.by(() => { - if (!calender) return undefined; - const dayEntry = calender.calendar.find((d) => d.date === startDate.toString()); + if (!calendar) return undefined; + const dayEntry = calendar.calendar.find((d) => d.date === selectedDate.toString()); if (!dayEntry) return []; return Object.keys(dayEntry.channels).reduce((allItems, channelId) => { const channelData = dayEntry.channels[channelId]; @@ -203,10 +211,10 @@
- +
sidebar.setCalendarExpanded(!$sidebar.isCalendarExpanded)} class="lg:hidden" > - + {/snippet} {#snippet sidebarRight()} - + {/snippet}