Calendar: Fixed a few display issues

This commit is contained in:
Karl Ludwig Weise
2026-08-27 20:30:10 +02:00
parent 126e117cb9
commit 683e74883b
7 changed files with 120 additions and 38 deletions
@@ -127,6 +127,7 @@
<div class="relative flex w-full flex-col">
{#if isWeekView && items}
<!-- Date -->
{@const isSelected = selectedDate.toString() === day.toString()}
{@const isToday = toCalendarDate(day).toString() === today(getLocalTimeZone()).toString()}
<Text
@@ -142,10 +143,25 @@
weekday: "short",
})}
</Text>
<!-- Vertical line separating days in week view -->
<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" }}
style={{
height: `${
// shown hours in minutes
((latestEndHour - earliestStartHour) * 60 +
// plus 30 minutes before the day starts
30) *
scale +
// plus 3 pixels for the bottom overflow of the line
4 +
// plus 10 pixels for the margin
10
}px`,
marginTop: "-10px",
}}
/>
{/if}
<div class="absolute top-0 right-0 bottom-0 left-0 z-10">
@@ -195,9 +195,9 @@
class="mt-2 mb-1"
>
{#snippet label()}
<div class="flex items-center gap-1">
<div class="flex items-start gap-1">
<div
class="bg-primary h-4 w-2 rounded-sm"
class="bg-primary mt-0.5 h-4 w-2 rounded-sm"
style:background-color={channel.color}
></div>
{name}
@@ -244,6 +244,7 @@
changeView("week");
}
}}
class="min-h-82"
/>
</HorizontalPagePadding>
</Sidebar.Content>
@@ -12,6 +12,7 @@
today,
} from "@internationalized/date";
import Loader from "@lucide/svelte/icons/loader-2";
import { isAfterOperatingHours, isBeforeOperatingHours } from "./utils";
let {
day = $bindable(),
@@ -32,6 +33,8 @@
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const isBeforeHours = $derived(isBeforeOperatingHours(curTimeIndicator, earliestStartHour));
const isAfterHours = $derived(isAfterOperatingHours(curTimeIndicator, latestEndHour));
</script>
<div class="relative flex w-16 shrink-0 flex-col">
@@ -50,14 +53,10 @@
</div>
{/if}
<!-- Current Time Indicator -->
<!-- Current Time Indicator Time Bubble -->
{#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}
{#if isToday && !isAfterHours && !isBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
@@ -10,6 +10,7 @@
toCalendarDateTime,
today,
} from "@internationalized/date";
import { isAfterOperatingHours, isBeforeOperatingHours } from "./utils";
let {
day = $bindable(),
@@ -32,10 +33,13 @@
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const isBeforeHours = $derived(isBeforeOperatingHours(curTimeIndicator, earliestStartHour));
const isAfterHours = $derived(isAfterOperatingHours(curTimeIndicator, latestEndHour));
</script>
{#if !isLoading}
<div class="absolute flex w-[calc(100%-2rem)] flex-col">
<!-- First half hour line before day starts -->
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
@@ -43,14 +47,10 @@
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Current Time Indicator -->
<!-- Current Time Indicator Line -->
{#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}
{#if isToday && !isAfterHours && !isBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
@@ -19,12 +19,14 @@
import { type ComponentProps } from "svelte";
import type { OnChangeFn } from "vaul-svelte";
import { calendarMonthQuery } from "./queries";
import { cn } from "$lib/utils";
let {
selectedDate = $bindable(),
shownAppointments,
shownChannels,
shownAgents,
class: className,
onSelectDay,
ref = $bindable(null),
}: ComponentProps<typeof Sidebar.Root> & {
@@ -32,6 +34,7 @@
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
class?: string;
onSelectDay?: OnChangeFn<DateValue | undefined>;
} = $props();
@@ -176,7 +179,7 @@
type="single"
locale={getLocale()}
calendarLabel={m["calendar.selectDate"]()}
class="bg-transparent p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow"
class={cn("bg-transparent p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow", className)}
preventDeselect={true}
bind:value={selectedDate}
bind:placeholder
@@ -9,10 +9,15 @@ import { staffCrypto } from "$lib/stores/staff-crypto";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import type { TChannel } from "$lib/types/channel";
import { serverAppointmentStatusToUiFilterStatus } from "$lib/utils/appointments";
import { getWeekStartsOn, localToUTCWithoutDST } from "$lib/utils/datetime";
import {
getWeekStartsOn,
localToUTCWithoutDST,
timeUTCToLocalWithoutOffset,
} from "$lib/utils/datetime";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
parseAbsolute,
toCalendarDate,
type CalendarDate,
} from "@internationalized/date";
@@ -174,6 +179,81 @@ export function positionItems(items: TCalendarItem[] | undefined) {
return processedItems;
}
export const getOperatingHours = (channels: TChannel[], calendar: TCalendar | undefined) => {
const earliestSlotStartHour = channels
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
const lastSlotEndHour = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr, minuteStr] = timeUTCToLocalWithoutOffset(time).split(":");
let hour = parseInt(hourStr, 10);
if (minuteStr !== "00") {
hour += 1;
}
return hour;
});
const earliestAppointmentStartHour = (calendar?.calendar || [])
.map((day) =>
Array.from(
Object.values(day.channels)
.map((it) =>
it.appointments.map(
(a) =>
// @ts-expect-error appointmentDate is not typed correctly
parseAbsolute(a.appointmentDate, getLocalTimeZone()).hour,
),
)
.flat(),
),
)
.flat();
const lastAppointmentEndHour = (calendar?.calendar || [])
.map((day) =>
Array.from(
Object.values(day.channels)
.map((it) =>
it.appointments.map(
(a) =>
// @ts-expect-error appointmentDate is not typed correctly
parseAbsolute(a.appointmentDate, getLocalTimeZone()).add({
minutes: a.duration,
}).hour,
),
)
.flat(),
),
)
.flat();
return {
from: Math.min(...earliestSlotStartHour, ...earliestAppointmentStartHour),
to: Math.max(...lastSlotEndHour, ...lastAppointmentEndHour),
};
};
export const isBeforeOperatingHours = (
curTimeIndicator: { hour: number; minute: number } | undefined,
earliestStartHour: number,
) => {
if (!curTimeIndicator) return true;
const result = earliestStartHour * 60 - 0.5 > curTimeIndicator.hour * 60;
return result;
};
export const isAfterOperatingHours = (
curTimeIndicator: { hour: number; minute: number } | undefined,
latestEndHour: number,
) => {
if (!curTimeIndicator) return true;
const result = latestEndHour * 60 + 30 < curTimeIndicator.hour * 60 + curTimeIndicator.minute;
return result;
};
export const moveAppointment = async (opts: {
tenant: string;
appointment: string;
@@ -11,7 +11,6 @@
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { getLocalTimeZone, today, type CalendarDate } from "@internationalized/date";
import { SlidersHorizontal } from "@lucide/svelte";
@@ -25,10 +24,10 @@
import CalendarLegend from "./(components)/CalendarLegend.svelte";
import CalendarLines from "./(components)/CalendarLines.svelte";
import CalendarWeek from "./(components)/CalendarWeek.svelte";
import { calendarMonthQuery } from "./(components)/queries";
import { convertDate, openAppointmentById } from "./(components)/utils";
import type { CalendarView } from "./types";
import MoveAppointment from "./(components)/MoveAppointment.svelte";
import { calendarMonthQuery } from "./(components)/queries";
import { convertDate, getOperatingHours, openAppointmentById } from "./(components)/utils";
import type { CalendarView } from "./types";
const queryClient = useQueryClient();
const tenantId = $derived($auth.user?.tenantId);
@@ -49,23 +48,7 @@
mode: "VIEW",
});
let view: CalendarView = $state(page.data.calendarView);
let hours = $derived.by(() => {
const from = channels
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
const to = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
return { from: Math.min(...from), to: Math.max(...to) };
});
let hours = $derived.by(() => getOperatingHours(channels, calendar));
let scale = $state(page.data.calendarZoom);
$effect(() => {