Calendar: Added week view

This commit is contained in:
Karl Ludwig Weise
2026-06-30 20:31:34 +02:00
parent 708eead295
commit d6a527b89f
19 changed files with 739 additions and 244 deletions
+12
View File
@@ -944,6 +944,18 @@
"title": "Kalender",
"selectDate": "Datum auswählen",
"today": "Heute",
"zoomOptions": {
"title": "Kalender Zoom-Optionen",
"zoomIn": "Hinein-zoomen",
"zoomOut": "Heraus-zoomen"
},
"viewOptions": {
"title": "Kalender Ansicht",
"day": "Tagesansicht",
"week": "Wochenansicht",
"week-workdays": "Wochenansicht (ohne Wochenende)"
},
"calendarWeek": "KW {week}, {year}",
"thisMonth": "Aktueller Monat",
"addThreeMonths": "+3",
"addSixMonths": "+6",
+12
View File
@@ -952,6 +952,18 @@
"title": "Calendar",
"selectDate": "Select a date",
"today": "Today",
"zoomOptions": {
"title": "Calendar zoom options",
"zoomIn": "zoom-in",
"zoomOut": "zoom-out"
},
"viewOptions": {
"title": "Calendar view options",
"day": "Day",
"week": "Week",
"week-workdays": "Week (without weekend)"
},
"calendarWeek": "CW {week}, {year}",
"thisMonth": "Current month",
"addThreeMonths": "+3",
"addSixMonths": "+6",
@@ -0,0 +1,27 @@
<script lang="ts">
interface Props {
[key: string]: unknown;
}
let { ...rest }: Props = $props();
</script>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
<g
id="calendar-week-workdays"
stroke="none"
fill="none"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
>
<g id="Group" stroke="currentColor">
<line x1="8" y1="2" x2="8" y2="6" id="Path" stroke-width="2"></line>
<line x1="16" y1="2" x2="16" y2="6" id="Path" stroke-width="2"></line>
<line x1="16" y1="11" x2="16" y2="21" id="Path" stroke-width="2"></line>
<rect id="Rectangle" stroke-width="2" x="3" y="4" width="18" height="18" rx="2"></rect>
<line x1="3" y1="10" x2="21" y2="10" id="Path" stroke-width="2"></line>
</g>
</g>
</svg>
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
[key: string]: unknown;
}
let { ...rest }: Props = $props();
</script>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
<title>calendar-week</title>
<g
id="calendar-week"
stroke="none"
fill="none"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
>
<g id="Group" stroke="currentColor">
<line x1="8" y1="2" x2="8" y2="6" id="Path" stroke-width="2"></line>
<line x1="6" y1="11" x2="6" y2="21" id="Path-2" stroke-width="2"></line>
<line x1="9" y1="11" x2="9" y2="21" id="Path-5" stroke-width="2"></line>
<line x1="12" y1="11" x2="12" y2="21" id="Path-3" stroke-width="2"></line>
<line x1="15" y1="11" x2="15" y2="21" id="Path-6" stroke-width="2"></line>
<line x1="18" y1="11" x2="18" y2="21" id="Path-4" stroke-width="2"></line>
<line x1="16" y1="2" x2="16" y2="6" id="Path" stroke-width="2"></line>
<rect id="Rectangle" stroke-width="2" x="3" y="4" width="18" height="18" rx="2"></rect>
<line x1="3" y1="10" x2="21" y2="10" id="Path" stroke-width="2"></line>
</g>
</g>
</svg>
@@ -13,6 +13,7 @@
import { toDisplayDateTime } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { getLocalTimeZone } from "@internationalized/date";
import { useQueryClient } from "@tanstack/svelte-query";
import { onMount } from "svelte";
let {
@@ -23,6 +24,7 @@
closePopover: () => void;
} = $props();
const queryClient = useQueryClient();
const channels = $derived($channelsStore.channels);
let appointment: TAppointment | undefined = $state();
@@ -57,6 +59,9 @@
onclick={() => {
if (appointment) {
closePopover();
queryClient.invalidateQueries({
queryKey: ["calendar"],
});
if (page.url.pathname === ROUTES.DASHBOARD.CALENDAR) {
goto(resolve(ROUTES.DASHBOARD.CALENDAR), {
state: {
@@ -19,17 +19,20 @@
</script>
<Calendar.Day
class="relative data-outside-month:bg-transparent! data-outside-month:hover:bg-transparent data-today:rounded-sm data-today:border-2 data-today:border-red-500/50!"
class="relative data-today:rounded-sm data-today:border-2 data-today:border-red-500/50!"
>
{#if !outsideMonth}
<div class="py-1">
<div class={cn(isDateEmpty && isDateEmpty(day) && "text-muted-foreground")}>
{day.day}
</div>
{#if isDateHighlighted && isDateHighlighted(day)}
<span class="absolute bottom-0 left-1/2 h-1 w-1 -translate-1/2 rounded-full bg-green-500"
></span>
{/if}
<div class="py-1">
<div
class={cn(
isDateEmpty && isDateEmpty(day) && "text-muted-foreground",
outsideMonth && "text-muted-foreground",
)}
>
{day.day}
</div>
{/if}
{#if isDateHighlighted && isDateHighlighted(day)}
<span class="absolute bottom-0 left-1/2 h-1 w-1 -translate-1/2 rounded-full bg-green-500"
></span>
{/if}
</div>
</Calendar.Day>
+20
View File
@@ -1,6 +1,7 @@
import { getLocale } from "$i18n/runtime";
import type { TCalendarSlot } from "$lib/types/calendar";
import {
CalendarDate,
getLocalTimeZone,
parseAbsoluteToLocal,
toCalendarDateTime,
@@ -228,3 +229,22 @@ export const getWeekStartsOn = (): 0 | 1 | 2 | 3 | 4 | 5 | 6 => {
if (sundayStartLocales.has(locale)) return 0;
return 1;
};
export const getWeekDays = (day: CalendarDate, skipWeekend = false): CalendarDate[] => {
const jsDay = day.toDate("UTC").getDay();
const weekStartsOn = getWeekStartsOn();
let offset = jsDay - weekStartsOn;
if (offset < 0) offset += 7;
const weekStart = day.subtract({ days: offset });
const days = Array.from({ length: 7 }, (_, i) => weekStart.add({ days: i }));
if (skipWeekend) {
return days.filter((d) => {
const dow = d.toDate("UTC").getDay();
return dow !== 0 && dow !== 6;
});
}
return days;
};
@@ -8,11 +8,14 @@
import { calendarStore } from "$lib/stores/calendar";
import { Button } from "$lib/components/ui/button";
import { m } from "$i18n/messages";
import { cn } from "$lib/utils";
let {
item,
scale,
}: {
item: TCalendarItem;
scale: number;
} = $props();
let decrypted = $state<AppointmentData | undefined>();
@@ -101,13 +104,16 @@
{:else if decrypted === undefined}
<div class="flex h-full items-center gap-1 px-1">
<Loader2Icon class="h-3/4 max-h-4 w-auto animate-spin" />
<Text style="xs" class="leading-none text-(--channel-color-contrast)"
>{m["calendar.decrypting"]()}</Text
>
<Text style="xs" class="leading-none text-(--channel-color-contrast)">
{m["calendar.decrypting"]()}
</Text>
</div>
{:else if decrypted}
<Button
class="m-0 h-full w-full cursor-pointer justify-start rounded-none px-1 leading-none text-(--channel-color-contrast) hover:bg-transparent! hover:text-(--channel-color-contrast) focus:ring-1"
class={cn(
"m-0 h-full w-full cursor-pointer items-start justify-start rounded-none px-px py-px text-left leading-none break-all whitespace-pre-line text-(--channel-color-contrast) hover:bg-transparent! hover:text-(--channel-color-contrast) focus:ring-1",
scale === 1 ? "text-xs" : "",
)}
variant="ghost"
onclick={setCalendarItem}
>
@@ -1,49 +1,115 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Separator } from "$lib/components/ui/separator";
import { Text } from "$lib/components/ui/typography";
import { clock } from "$lib/stores/time";
import { type TCalendarItem } from "$lib/types/calendar";
import { type TAppointmentFilter, type TCalendar, type TCalendarItem } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import {
CalendarDate,
getLocalTimeZone,
toCalendarDate,
toCalendarDateTime,
today,
} from "@internationalized/date";
import Loader from "@lucide/svelte/icons/loader-2";
import { tv } from "tailwind-variants";
import { positionItems } from "./utils";
import AppointmentPreview from "./AppointmentPreview.svelte";
import { getContrastColor } from "$lib/utils/color";
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { CalendarDate, getLocalTimeZone, toCalendarDate, today } from "@internationalized/date";
import { tv } from "tailwind-variants";
import AppointmentPreview from "./AppointmentPreview.svelte";
import SlotPreview from "./SlotPreview.svelte";
import { positionItems } from "./utils";
import { Text } from "$lib/components/ui/typography";
import { Separator } from "$lib/components/ui/separator";
let {
day = $bindable(),
items,
selectedDate,
calendar,
shownAppointments,
shownChannels,
shownAgents,
earliestStartHour,
latestEndHour,
scale = $bindable(),
isWeekView,
}: {
day: CalendarDate;
items: TCalendarItem[] | undefined;
selectedDate: CalendarDate;
calendar: TCalendar | undefined;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
earliestStartHour: number;
latestEndHour: number;
scale: number;
isWeekView?: boolean;
} = $props();
let items: TCalendarItem[] | undefined = $derived.by(() => {
if (!calendar) return undefined;
const dayEntry = calendar.calendar.find((d) => d.date === day.toString());
if (!dayEntry) return [];
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
const channelData = dayEntry.channels[channelId];
const channelItems: TCalendarItem[] = [];
// Available slots
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
const isSlotInPast = utcToLocalWithoutDST(new Date(slot.to)) < new Date();
if (!isSlotInPast) {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
) {
channelItems.push({
id: `${channelId}-${slot.from}`,
date: dayEntry.date,
start: slot.from,
duration: slot.duration,
channelId,
color: channelData.channel.color,
column: 0,
status: "available",
availableAgents: slot.availableAgents,
});
}
}
});
}
}
// Appointments
if (["all", "booked", "reserved"].includes(shownAppointments)) {
channelData.appointments.forEach((appointment) => {
const status = appointment.status === "CONFIRMED" ? "booked" : "reserved";
if (shownAppointments === "all" || shownAppointments === status) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
if (shownAgents.length === 0 || shownAgents.includes(appointment.agentId)) {
channelItems.push({
id: appointment.id,
date: dayEntry.date,
start: new Date(appointment.appointmentDate).toISOString(),
duration: appointment.duration,
channelId,
color: channelData.channel.color,
column: 0,
status,
appointment: {
dateTime: new Date(appointment.appointmentDate),
encryptedPayload: appointment.encryptedPayload,
tunnelId: appointment.tunnelId,
agentId: appointment.agentId,
staffKeyShare: appointment.staffKeyShare,
iv: appointment.iv || undefined,
authTag: appointment.authTag || undefined,
},
});
}
}
}
});
}
return [...allItems, ...channelItems];
}, []);
});
// Process items to handle overlaps
let processedItems = $derived(positionItems(items));
const hourSize = $derived(60 * scale);
const hours = Array.from({ length: 25 }, (_, i) => i);
const shownHours = $derived(hours.slice(0, latestEndHour + 1).slice(earliestStartHour));
const focusAdjustment = $derived(30 * scale);
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const slotVariants = tv({
base: "",
@@ -59,41 +125,26 @@
</script>
<div class="relative flex w-full flex-col">
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Hour markers -->
{#each shownHours as hour (`hour-${hour}`)}
<div
class="relative flex w-full items-start justify-between transition-all duration-200 select-none"
style:height={`${hourSize}px`}
{#if isWeekView && items}
{@const isSelected = selectedDate.toString() === items[0]?.date}
{@const isToday = toCalendarDate(day).toString() === today(getLocalTimeZone()).toString()}
<Text
style="xs"
class={cn(
"absolute -top-5 left-1 z-20",
isSelected && "bg-primary text-secondary rounded-sm px-1",
isToday && "rounded-sm border-2 border-red-500/50 px-1",
)}
>
<Text style="xs" class="text-muted-foreground -mt-2 w-16 shrink-0">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))}
</Text>
<Separator class="bg-muted-foreground h-px w-auto! grow" />
<Separator class="bg-secondary absolute top-1/2 right-0 left-16 h-px w-auto!" />
</div>
{/each}
<!-- Loading state -->
{#if items === undefined}
<div class="absolute top-0 left-0 -mt-5">
<Loader class="size-4 animate-spin" strokeWidth={1} />
<span class="sr-only">{m["calendar.loading"]()}</span>
</div>
{toDisplayDateTime(day.toDate(getLocalTimeZone()), { weekday: "short" })}
</Text>
<Separator
orientation="vertical"
class="bg-muted-foreground absolute top-0 bottom-0 left-0 z-10 transition-all duration-200"
style={{ height: `${(latestEndHour * 30 + 30) * scale + 20}px`, marginTop: "-10px" }}
/>
{/if}
<!-- Day content area -->
<div class="absolute top-0 right-0 bottom-0 left-16">
<div class="absolute top-0 right-0 bottom-0 left-0 z-10">
{#each processedItems as item, index (`${item.id}-${index}`)}
{@const top =
(item.startMinutes / 60) * hourSize + focusAdjustment - earliestStartHour * hourSize}
@@ -119,7 +170,7 @@
)}
>
{#if ["booked", "reserved"].includes(item.status)}
<AppointmentPreview {item} />
<AppointmentPreview {item} {scale} />
{:else if item.status === "available"}
<SlotPreview {item} />
{/if}
@@ -127,26 +178,4 @@
</div>
{/each}
</div>
<!-- Current Time Indicator -->
{#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize && earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
(curTimeIndicator.minute / 60) * hourSize -
earliestStartHour * hourSize}
<div
class="pointer-events-none absolute right-0 left-0 z-10 flex h-1 items-center transition-all duration-200 select-none"
style:top={`${top}px`}
>
<Text style="xs" class="z-10 -ml-1 rounded-full bg-red-500 px-1 text-white">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))}
</Text>
<div class="absolute right-0 left-0 h-px bg-red-500"></div>
</div>
{/if}
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import CalendarWeekWorkdays from "$lib/assets/custom-icons/calendar-week-workdays.svelte";
import CalendarWeek from "$lib/assets/custom-icons/calendar-week.svelte";
import { Button } from "$lib/components/ui/button";
import * as ButtonGroup from "$lib/components/ui/button-group";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
@@ -14,16 +16,19 @@
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import { CalendarDate } from "@internationalized/date";
import { FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
import { CalendarDate, type DateValue, isWeekend } from "@internationalized/date";
import { Calendar, FunnelX, PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
import { type ComponentProps } from "svelte";
import type { CalendarView } from "../types";
import CalendarMonth from "./CalendarMonth.svelte";
import { CALENDAR_ZOOM_STEPS } from "./utils";
let {
shownAppointments = $bindable(),
shownChannels = $bindable(),
shownAgents = $bindable(),
scale = $bindable(),
view = $bindable(),
selectedDate = $bindable(),
ref = $bindable(null),
...restProps
@@ -32,6 +37,7 @@
shownChannels: string[];
shownAgents: string[];
scale: number;
view: CalendarView;
selectedDate: CalendarDate;
} = $props();
@@ -45,13 +51,17 @@
let channels = $derived($channelsStore.channels.filter((x) => !x.archived));
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
const zoomSteps = [1, 2, 3, 4];
const zoom = (direction: number) => {
const currentIndex = zoomSteps.indexOf(scale);
const currentIndex = CALENDAR_ZOOM_STEPS.indexOf(scale);
if (currentIndex === -1) return;
const nextIndex = currentIndex + direction;
scale = zoomSteps[nextIndex];
scale = CALENDAR_ZOOM_STEPS[nextIndex];
document.cookie = `calendarZoom=${CALENDAR_ZOOM_STEPS[nextIndex]}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
};
const changeView = (newView: CalendarView) => {
view = newView;
document.cookie = `calendarView=${newView}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
};
const clearFilters = () => {
@@ -71,7 +81,7 @@
{...restProps}
>
<Sidebar.Header class="border-sidebar-border h-16 border-b">
<div class="flex h-full items-center gap-5">
<div class="flex h-full items-center gap-2">
<Button
size="sm"
variant="ghost"
@@ -80,22 +90,58 @@
>
<PanelRightClose />
</Button>
<ButtonGroup.Root aria-label="Media controls">
<ButtonGroup.Root aria-label={m["calendar.zoomOptions.title"]()}>
<Button
variant="outline"
size="icon"
disabled={zoomSteps[0] === scale}
disabled={CALENDAR_ZOOM_STEPS[0] === scale}
onclick={() => zoom(-1)}
title={m["calendar.zoomOptions.zoomOut"]()}
>
<ZoomOut />
<span class="sr-only">{m["calendar.zoomOptions.zoomOut"]()}</span>
</Button>
<Button
variant="outline"
size="icon"
disabled={zoomSteps.slice(-1)[0] === scale}
disabled={CALENDAR_ZOOM_STEPS.slice(-1)[0] === scale}
onclick={() => zoom(1)}
title={m["calendar.zoomOptions.zoomIn"]()}
>
<ZoomIn />
<span class="sr-only">{m["calendar.zoomOptions.zoomIn"]()}</span>
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root aria-label={m["calendar.viewOptions.title"]()}>
<Button
variant="outline"
size="icon"
onclick={() => changeView("day")}
class={cn(view === "day" && "bg-muted")}
title={m["calendar.viewOptions.day"]()}
>
<Calendar />
<span class="sr-only">{m["calendar.viewOptions.day"]()}</span>
</Button>
<Button
variant="outline"
size="icon"
onclick={() => changeView("week-workdays")}
class={cn(view === "week-workdays" && "bg-muted")}
title={m["calendar.viewOptions.week-workdays"]()}
>
<CalendarWeekWorkdays />
<span class="sr-only">{m["calendar.viewOptions.week-workdays"]()}</span>
</Button>
<Button
variant="outline"
size="icon"
onclick={() => changeView("week")}
class={cn(view === "week" && "bg-muted")}
title={m["calendar.viewOptions.week"]()}
>
<CalendarWeek />
<span class="sr-only">{m["calendar.viewOptions.week"]()}</span>
</Button>
</ButtonGroup.Root>
</div>
@@ -177,13 +223,18 @@
</div>
</HorizontalPagePadding>
<Sidebar.Separator class="mx-0 my-1" />
<HorizontalPagePadding class="pt-2">
<HorizontalPagePadding class="py-2">
<CalendarMonth
bind:selectedDate
{shownAppointments}
{shownAgents}
{shownChannels}
onSelectDay={() => sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)}
onSelectDay={(v: DateValue | undefined) => {
sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded);
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
changeView("week");
}
}}
/>
</HorizontalPagePadding>
</Sidebar.Content>
@@ -9,20 +9,24 @@
import CalendarMonth from "./CalendarMonth.svelte";
import type { TAppointmentFilter } from "$lib/types/calendar";
import type { OnChangeFn } from "vaul-svelte";
import type { CalendarView } from "../types";
let {
selectedDate = $bindable(),
view,
shownAppointments,
shownChannels,
shownAgents,
}: {
selectedDate: CalendarDate;
view: CalendarView;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
} = $props();
let open = $state(false);
let isWeekView = $derived(view !== "day");
const prev = () => {
const nextDate = new CalendarDate(
@@ -30,14 +34,14 @@
selectedDate.month,
selectedDate.day,
).subtract({
days: 1,
days: isWeekView ? 7 : 1,
});
selectedDate = nextDate;
};
const next = () => {
const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
days: 1,
days: isWeekView ? 7 : 1,
});
selectedDate = nextDate;
};
@@ -49,6 +53,24 @@
const onSelectDay: OnChangeFn<unknown> = () => {
open = false;
};
const getISOWeek = (date: CalendarDate): { week: number; year: number } => {
// Convert to a Thursday of the same ISO week
const dayOfWeek = date.toDate("UTC").getUTCDay() || 7; // Mon=1 ... Sun=7
const thursday = date.add({ days: 4 - dayOfWeek });
// The ISO year is the year of that Thursday
const year = thursday.year;
// Day of year for that Thursday
const jan1 = new CalendarDate(year, 1, 1);
const dayOfYear =
(thursday.toDate("UTC").getTime() - jan1.toDate("UTC").getTime()) / 86_400_000 + 1;
const week = Math.ceil(dayOfYear / 7);
return { week, year };
};
</script>
<div
@@ -62,13 +84,18 @@
<Popover.Trigger
class={cn(buttonVariants({ variant: "ghost" }), "h-auto py-1 leading-none font-normal")}
>
{Intl.DateTimeFormat(getLocale(), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
timeZone: getLocalTimeZone().toString(),
}).format(selectedDate.toDate(getLocalTimeZone()))}
{#if isWeekView}
{@const week = getISOWeek(selectedDate)}
{m["calendar.calendarWeek"](week)}
{:else}
{Intl.DateTimeFormat(getLocale(), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
timeZone: getLocalTimeZone().toString(),
}).format(selectedDate.toDate(getLocalTimeZone()))}
{/if}
</Popover.Trigger>
<Popover.Content class="w-64">
<CalendarMonth
@@ -0,0 +1,80 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Separator } from "$lib/components/ui/separator";
import { Text } from "$lib/components/ui/typography";
import { clock } from "$lib/stores/time";
import {
CalendarDate,
getLocalTimeZone,
toCalendarDate,
toCalendarDateTime,
today,
} from "@internationalized/date";
import Loader from "@lucide/svelte/icons/loader-2";
let {
day = $bindable(),
earliestStartHour,
latestEndHour,
scale = $bindable(),
isLoading,
}: {
day: CalendarDate;
earliestStartHour: number;
latestEndHour: number;
scale: number;
isLoading: boolean;
} = $props();
const hourSize = $derived(60 * scale);
const focusAdjustment = $derived(30 * scale);
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
</script>
<div class="relative flex w-16 shrink-0 flex-col">
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Loading state -->
{#if isLoading}
<div class="absolute top-0 left-0 -mt-5">
<Loader class="size-4 animate-spin" strokeWidth={1} />
<span class="sr-only">{m["calendar.loading"]()}</span>
</div>
{/if}
<!-- Current Time Indicator -->
{#if !isLoading && curTimeIndicator}
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
{@const isNotAfterHours =
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
{@const isNotBeforeHours =
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{#if isToday && isNotAfterHours && isNotBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
(curTimeIndicator.minute / 60) * hourSize -
earliestStartHour * hourSize}
<div
class="pointer-events-none absolute right-0 left-0 z-20 flex h-1 items-center transition-all duration-200 select-none"
style:top={`${top}px`}
>
<Text style="xs" class="z-90 -ml-1 rounded-full bg-red-500 px-1 text-white">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))}
</Text>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { getLocale } from "$i18n/runtime";
import { Separator } from "$lib/components/ui/separator";
import { Text } from "$lib/components/ui/typography";
import { clock } from "$lib/stores/time";
import {
CalendarDate,
getLocalTimeZone,
toCalendarDate,
toCalendarDateTime,
today,
} from "@internationalized/date";
let {
day = $bindable(),
earliestStartHour,
latestEndHour,
scale = $bindable(),
isLoading,
isWeekView,
}: {
day: CalendarDate;
earliestStartHour: number;
latestEndHour: number;
scale: number;
isLoading: boolean;
isWeekView?: boolean;
} = $props();
const hourSize = $derived(60 * scale);
const hours = Array.from({ length: 25 }, (_, i) => i);
const shownHours = $derived(hours.slice(0, latestEndHour + 1).slice(earliestStartHour));
const focusAdjustment = $derived(30 * scale);
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
</script>
{#if !isLoading}
<div class="absolute flex w-[calc(100%-2rem)] flex-col">
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Current Time Indicator -->
{#if !isLoading && curTimeIndicator}
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
{@const isNotAfterHours =
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
{@const isNotBeforeHours =
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{#if isToday && isNotAfterHours && isNotBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
(curTimeIndicator.minute / 60) * hourSize -
earliestStartHour * hourSize}
<div
class="pointer-events-none absolute right-0 left-0 z-10 flex h-1 items-center transition-all duration-200 select-none"
style:top={`${top}px`}
>
<div class="absolute right-0 left-0 h-px bg-red-500"></div>
</div>
{/if}
{/if}
<!-- Hour markers -->
{#each shownHours as hour (`hour-${hour}`)}
<div
class="relative z-0 flex w-full items-start justify-between transition-all duration-200 select-none"
style:height={`${hourSize}px`}
>
{#if !isWeekView}
<Text style="xs" class="text-muted-foreground -mt-2 w-16 shrink-0">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))}
</Text>
{/if}
<Separator class="bg-muted-foreground -z-10 -ml-0.5 h-px w-auto! grow" />
<Separator class="bg-secondary absolute top-1/2 right-0 left-16 h-px w-auto!" />
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,64 @@
<script lang="ts">
import { type TAppointmentFilter, type TCalendar } from "$lib/types/calendar";
import { getWeekDays } from "$lib/utils/datetime";
import { CalendarDate } from "@internationalized/date";
import CalendarDay from "./CalendarDay.svelte";
import CalendarLegend from "./CalendarLegend.svelte";
import CalendarLines from "./CalendarLines.svelte";
import type { CalendarView } from "../types";
let {
selectedDate = $bindable(),
day = $bindable(),
calendar,
shownAppointments,
shownChannels,
shownAgents,
earliestStartHour,
latestEndHour,
view,
scale = $bindable(),
}: {
selectedDate: CalendarDate;
day: CalendarDate;
calendar: TCalendar | undefined;
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
earliestStartHour: number;
latestEndHour: number;
view: CalendarView;
scale: number;
} = $props();
let weekDays = $derived(getWeekDays(day, view === "week-workdays"));
</script>
<CalendarLegend
{day}
isLoading={calendar === undefined}
{earliestStartHour}
{latestEndHour}
bind:scale
/>
{#each weekDays as weekDay (weekDay.toString())}
<CalendarDay
day={weekDay}
{selectedDate}
{calendar}
{shownAppointments}
{shownAgents}
{shownChannels}
{earliestStartHour}
{latestEndHour}
isWeekView={true}
bind:scale
/>
{/each}
<CalendarLines
{day}
{earliestStartHour}
{latestEndHour}
bind:scale
isLoading={calendar === undefined}
/>
@@ -1,9 +1,10 @@
import type { CalendarDate } from "@internationalized/date";
import { fetchCalendar } from "./utils";
import { browser } from "$app/env";
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),
enabled: Boolean(tenant) && browser,
staleTime: 5000,
});
@@ -2,17 +2,28 @@ import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import type { AppointmentWithKeyShare } from "$lib/server/services/schedule-service";
import { auth } from "$lib/stores/auth";
import { calendarStore } from "$lib/stores/calendar";
import { staffCrypto } from "$lib/stores/staff-crypto";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import { localToUTCWithoutDST } from "$lib/utils/datetime";
import { getLocalTimeZone, type CalendarDate } from "@internationalized/date";
import type { AppointmentStatus, TCalendar, TCalendarItem } from "$lib/types/calendar";
import type { TChannel } from "$lib/types/channel";
import { getWeekStartsOn, localToUTCWithoutDST } from "$lib/utils/datetime";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
toCalendarDate,
type CalendarDate,
} from "@internationalized/date";
import { get } from "svelte/store";
export const CALENDAR_ZOOM_STEPS = [1, 2, 3, 4];
export const fetchCalendar = async (opts: { tenant: string; selectedDate: CalendarDate }) => {
if (!browser) return;
const weekStartsOn = getWeekStartsOn();
const localStartDate = new Date(
opts.selectedDate.year,
opts.selectedDate.month - 1,
@@ -22,6 +33,11 @@ export const fetchCalendar = async (opts: { tenant: string; selectedDate: Calend
0,
0,
);
// Roll back to the start of the week
while (localStartDate.getDay() !== weekStartsOn) {
localStartDate.setDate(localStartDate.getDate() - 1);
}
const localEndDate = new Date(
opts.selectedDate.year,
opts.selectedDate.month,
@@ -31,6 +47,11 @@ export const fetchCalendar = async (opts: { tenant: string; selectedDate: Calend
59,
999,
);
// Roll forward to the end of the week
const weekEndDay = (weekStartsOn + 6) % 7;
while (localEndDate.getDay() !== weekEndDay) {
localEndDate.setDate(localEndDate.getDate() + 1);
}
const params = new URLSearchParams({
startDate: localStartDate.toISOString(),
@@ -249,29 +270,57 @@ export const confirmAppointment = async (opts: {
};
export const openAppointmentById = async (
appointments: TCalendarItem[],
calendar: TCalendar,
channels: TChannel[],
appointmentId: string,
callback: () => void,
) => {
const appointment = appointments?.find((i) => i.id === appointmentId);
const allAppointments: AppointmentWithKeyShare[] = calendar.calendar.reduce((all, item) => {
return [...all, ...Object.values(item.channels).flatMap((it) => it.appointments)];
}, [] as AppointmentWithKeyShare[]);
const appointment = allAppointments?.find((i) => i.id === appointmentId);
const staffCryptoStore = get(staffCrypto);
if (
staffCryptoStore.crypto &&
appointment?.appointment?.encryptedPayload &&
appointment.appointment.iv &&
appointment.appointment.authTag &&
appointment.appointment.staffKeyShare
appointment?.encryptedPayload &&
appointment.iv &&
appointment.authTag &&
appointment.staffKeyShare
) {
const decrypted = await staffCryptoStore.crypto.decryptStaffAppointment({
encryptedAppointment: {
encryptedPayload: appointment.appointment.encryptedPayload,
iv: appointment.appointment.iv,
authTag: appointment.appointment.authTag,
encryptedPayload: appointment.encryptedPayload,
iv: appointment.iv,
authTag: appointment.authTag,
},
staffKeyShare: appointment.appointment.staffKeyShare,
staffKeyShare: appointment.staffKeyShare,
});
calendarStore.setCurItem({ appointment, decrypted });
const channel = channels.find((it) => it.id === appointment.channelId);
const curCalendarItem: TCalendarItem = {
date: appointment.appointmentDate.toString(),
id: appointment.id,
duration: appointment.duration,
status: appointment.status as AppointmentStatus,
start: "08:00",
channelId: appointment.channelId,
color: channel?.color || "",
appointment: {
...appointment,
encryptedPayload: appointment.encryptedPayload,
iv: appointment.iv,
authTag: appointment.authTag,
dateTime: new Date(appointment.appointmentDate),
},
// TODO: get proper values
column: 0,
};
calendarStore.setCurItem({ appointment: curCalendarItem, decrypted });
}
callback();
};
export const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
return toCalendarDate(zonedDateTime);
};
@@ -0,0 +1,19 @@
import { CALENDAR_ZOOM_STEPS } from "./(components)/utils.js";
import type { CalendarView } from "./types.js";
export const load = ({ cookies }) => {
// zoom
const cookieZoom = cookies.get("calendarZoom");
const parsedCookieZoom = cookieZoom && parseInt(cookieZoom);
const calendarZoom =
parsedCookieZoom && CALENDAR_ZOOM_STEPS.includes(parsedCookieZoom) ? parsedCookieZoom : 1;
// view
const cookieView = cookies.get("calendarView");
const calendarView: CalendarView =
cookieView && ["day", "week", "week-workdays"].includes(cookieView)
? (cookieView as CalendarView)
: "day";
return { calendarView, calendarZoom };
};
@@ -2,7 +2,6 @@
import { replaceState } from "$app/navigation";
import { page } from "$app/state";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { Button } from "$lib/components/ui/button";
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
@@ -12,31 +11,24 @@
import { calendarStore } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendarItem } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
import type { TAppointmentFilter } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
toCalendarDate,
today,
type CalendarDate,
} from "@internationalized/date";
import { getLocalTimeZone, today, type CalendarDate } from "@internationalized/date";
import { SlidersHorizontal } from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
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 { openAppointmentById } from "./(components)/utils";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import CalendarLegend from "./(components)/CalendarLegend.svelte";
import CalendarLines from "./(components)/CalendarLines.svelte";
import CalendarWeek from "./(components)/CalendarWeek.svelte";
import { calendarMonthQuery } from "./(components)/queries";
const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
return toCalendarDate(zonedDateTime);
};
import { convertDate, openAppointmentById } from "./(components)/utils";
import type { CalendarView } from "./types";
const queryClient = useQueryClient();
const tenantId = $derived($auth.user?.tenantId);
@@ -54,6 +46,7 @@
let shownAppointments: TAppointmentFilter = $state("all");
let shownChannels: string[] = $state([]);
let shownAgents: string[] = $state([]);
let view: CalendarView = $state(page.data.calendarView);
let hours = $derived.by(() => {
const from = channels
.map((c) => c.slotTemplates.map((t) => t.from))
@@ -71,7 +64,7 @@
});
return { from: Math.min(...from), to: Math.max(...to) };
});
let scale = $state(1);
let scale = $state(page.data.calendarZoom);
$effect(() => {
const getMonth = (x: string | undefined) => (x ? new Date(x).getMonth() + 1 : undefined);
@@ -81,24 +74,21 @@
});
$effect(() => {
if (items && history.state["sveltekit:states"]?.appointmentId) {
if (calendar && history.state["sveltekit:states"]?.appointmentId) {
// Wait 100ms to ensure that the calendar items are rendered
setTimeout(() => {
openAppointmentById(items, history.state["sveltekit:states"].appointmentId, () => {
replaceState("", {});
});
openAppointmentById(
calendar,
channels,
history.state["sveltekit:states"].appointmentId,
() => {
replaceState("", {});
},
);
}, 100);
}
});
onMount(() => {
if (history.state["sveltekit:states"]?.date) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { date, ...rest } = history.state["sveltekit:states"];
replaceState("", rest);
}
});
// Navigating to appointment, if calendar is already mounted
$effect(() => {
if (
@@ -111,15 +101,28 @@
selectedDate = date;
replaceState("", { appointmentId: page.state.appointmentId });
} else {
if (items) {
openAppointmentById(items, history.state["sveltekit:states"].appointmentId, () => {
replaceState("", {});
});
if (calendar) {
openAppointmentById(
calendar,
channels,
history.state["sveltekit:states"].appointmentId,
() => {
replaceState("", {});
},
);
}
}
}
});
onMount(() => {
if (history.state["sveltekit:states"]?.date) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { date, ...rest } = history.state["sveltekit:states"];
replaceState("", rest);
}
});
const updateCalendar = async () => {
if (tenantId) {
queryClient.invalidateQueries({
@@ -131,77 +134,6 @@
const closeAppointmentDetail = () => {
$calendarStore.curItem = null;
};
let items: TCalendarItem[] | undefined = $derived.by(() => {
if (!calendar) return undefined;
const dayEntry = calendar.calendar.find((d) => d.date === selectedDate.toString());
if (!dayEntry) return [];
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
const channelData = dayEntry.channels[channelId];
const channelItems: TCalendarItem[] = [];
// Available slots
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
const isSlotInPast = utcToLocalWithoutDST(new Date(slot.to)) < new Date();
if (!isSlotInPast) {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
) {
channelItems.push({
id: `${channelId}-${slot.from}`,
date: dayEntry.date,
start: slot.from,
duration: slot.duration,
channelId,
color: channelData.channel.color,
column: 0,
status: "available",
availableAgents: slot.availableAgents,
});
}
}
});
}
}
// Appointments
if (["all", "booked", "reserved"].includes(shownAppointments)) {
channelData.appointments.forEach((appointment) => {
const status = appointment.status === "CONFIRMED" ? "booked" : "reserved";
if (shownAppointments === "all" || shownAppointments === status) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
if (shownAgents.length === 0 || shownAgents.includes(appointment.agentId)) {
channelItems.push({
id: appointment.id,
date: dayEntry.date,
start: new Date(appointment.appointmentDate).toISOString(),
duration: appointment.duration,
channelId,
color: channelData.channel.color,
column: 0,
status,
appointment: {
dateTime: new Date(appointment.appointmentDate),
encryptedPayload: appointment.encryptedPayload,
tunnelId: appointment.tunnelId,
agentId: appointment.agentId,
staffKeyShare: appointment.staffKeyShare,
iv: appointment.iv || undefined,
authTag: appointment.authTag || undefined,
},
});
}
}
}
});
}
return [...allItems, ...channelItems];
}, []);
});
</script>
<svelte:head>
@@ -209,20 +141,54 @@
</svelte:head>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10">
<CalendarHeader bind:selectedDate {shownAppointments} {shownAgents} {shownChannels} />
<div>
<CalendarDay
<div class="flex flex-col gap-10">
<CalendarHeader bind:selectedDate {view} {shownAppointments} {shownAgents} {shownChannels} />
<div
class="flex transition-all duration-200"
style:min-height={`${(hours.to * 30 + 60) * scale}px`}
>
{#if view === "day"}
<CalendarLegend
day={selectedDate}
{items}
isLoading={calendar === undefined}
earliestStartHour={hours.from}
latestEndHour={hours.to}
bind:scale
/>
</div>
<CalendarDay
day={selectedDate}
{selectedDate}
{calendar}
{shownAppointments}
{shownAgents}
{shownChannels}
earliestStartHour={hours.from}
latestEndHour={hours.to}
bind:scale
/>
<CalendarLines
day={selectedDate}
earliestStartHour={hours.from}
latestEndHour={hours.to}
isLoading={calendar === undefined}
bind:scale
/>
{:else}
<CalendarWeek
day={selectedDate}
{selectedDate}
{calendar}
{shownAppointments}
{shownAgents}
{shownChannels}
{view}
earliestStartHour={hours.from}
latestEndHour={hours.to}
bind:scale
/>
{/if}
</div>
</MaxPageWidth>
</div>
{#snippet headerRight()}
<Button
size="sm"
@@ -239,6 +205,7 @@
bind:shownChannels
bind:shownAgents
bind:scale
bind:view
bind:selectedDate
/>
{/snippet}
@@ -0,0 +1 @@
export type CalendarView = "day" | "week" | "week-workdays";