Fix absences and appointments to block proper slots

This commit is contained in:
Karl Ludwig Weise
2026-04-23 16:49:12 +02:00
parent c322b557c0
commit c74a8dfb37
12 changed files with 107 additions and 33 deletions
@@ -148,6 +148,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -248,6 +249,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -270,6 +272,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -284,6 +287,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-03T23:59:59.999Z", // 3 days
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -316,6 +320,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z", // Monday
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -388,6 +393,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -463,6 +469,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z", // Monday
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -572,6 +579,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -645,6 +653,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
@@ -729,6 +738,7 @@ describe("ScheduleService", () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
timeZone: "Europe/Berlin",
tenantId: mockTenantId,
};
+31 -9
View File
@@ -12,11 +12,16 @@ import { eq, and, between, sql, or, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError } from "../utils/errors";
import { isValidTimeZone, toLocalTime, toLocalTimeIgnoringDst } from "../utils/timezone";
const scheduleRequestSchema = z.object({
startDate: z.string().datetime({ offset: true }), // ISO date string with timezone
endDate: z.string().datetime({ offset: true }), // ISO date string with timezone
tenantId: z.string().uuid({ message: "Invalid tenant ID format" }),
timeZone: z
.string()
.refine((tz) => isValidTimeZone(tz), { message: "Invalid IANA timezone format" })
.default("UTC"),
channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(),
agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(),
staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(),
@@ -217,6 +222,7 @@ export class ScheduleService {
absences,
channelAgents,
staffKeyShares,
timeZone: request.timeZone,
});
log.debug("Schedule generated successfully", {
@@ -252,6 +258,7 @@ export class ScheduleService {
absences,
channelAgents,
staffKeyShares,
timeZone,
}: {
startDate: Date;
endDate: Date;
@@ -261,6 +268,7 @@ export class ScheduleService {
absences: SelectAgentAbsence[];
channelAgents: { channelId: string; agent: SelectAgent }[];
staffKeyShares: Record<string, string>;
timeZone: string;
}): Promise<DaySchedule[]> {
const dailySchedules: DaySchedule[] = [];
@@ -316,6 +324,7 @@ export class ScheduleService {
appointments: dayAppointments,
agents: channelAgentsList,
absences,
timeZone,
});
daySchedule.channels[channel.id] = {
@@ -343,12 +352,14 @@ export class ScheduleService {
appointments,
agents,
absences,
timeZone,
}: {
date: Date;
slotTemplates: SelectSlotTemplate[];
appointments: SelectAppointment[];
agents: SelectAgent[];
absences: SelectAgentAbsence[];
timeZone: string;
}): TimeSlot[] {
const availableSlots: TimeSlot[] = [];
@@ -393,7 +404,9 @@ export class ScheduleService {
);
const availableAgents = agents.filter((agent) => {
if (this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences)) {
if (
this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences, timeZone)
) {
return false;
}
@@ -402,6 +415,7 @@ export class ScheduleService {
slotStartDateTime,
slotEndDateTime,
appointments,
timeZone,
);
});
@@ -430,19 +444,21 @@ export class ScheduleService {
slotStartDateTime: Date,
slotEndDateTime: Date,
absences: SelectAgentAbsence[],
timeZone: string,
): boolean {
return absences.some((absence) => {
if (absence.agentId !== agentId) return false;
const absenceStart = new Date(absence.startDate);
const absenceEnd = new Date(absence.endDate);
const absenceStart = toLocalTime(new Date(absence.startDate), timeZone);
const absenceEnd = toLocalTime(new Date(absence.endDate), timeZone);
const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone);
const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone);
// For time-specific absences, check if the time slot overlaps
return (
(slotStartDateTime >= absenceStart && slotStartDateTime < absenceEnd) ||
(slotEndDateTime > absenceStart && slotEndDateTime <= absenceEnd) ||
(slotStartDateTime <= absenceStart && slotEndDateTime >= absenceEnd)
);
const slotStartsDuringAbsence = slotStart >= absenceStart && slotStart < absenceEnd;
const slotEndsDuringAbsence = slotEnd > absenceStart && slotEnd <= absenceEnd;
const slotCoversEntireAbsence = slotStart <= absenceStart && slotEnd >= absenceEnd;
return slotStartsDuringAbsence || slotEndsDuringAbsence || slotCoversEntireAbsence;
});
}
@@ -454,6 +470,7 @@ export class ScheduleService {
slotStartDateTime: Date,
slotEndDateTime: Date,
appointments: SelectAppointment[],
timeZone: string,
): boolean {
return appointments.some((appointment) => {
if (appointment.agentId !== agentId) return false;
@@ -461,8 +478,13 @@ export class ScheduleService {
const appointmentStart = new Date(appointment.appointmentDate);
const appointmentDuration = Number.isFinite(appointment.duration) ? appointment.duration : 0;
const appointmentEnd = new Date(appointmentStart.getTime() + appointmentDuration * 60 * 1000);
const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone);
const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone);
return slotStartDateTime < appointmentEnd && slotEndDateTime > appointmentStart;
return (
slotStart < toLocalTime(appointmentEnd, timeZone) &&
slotEnd > toLocalTime(appointmentStart, timeZone)
);
});
}
+30
View File
@@ -0,0 +1,30 @@
export function isValidTimeZone(timeZone: string): boolean {
if (timeZone === "UTC" || timeZone === "Etc/UTC") {
return true;
}
const supportedValuesOf = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] })
.supportedValuesOf;
if (supportedValuesOf) {
return supportedValuesOf("timeZone").includes(timeZone);
}
// Conservative fallback when Intl.supportedValuesOf is unavailable.
return timeZone === "UTC" || /^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timeZone);
}
export function toLocalTime(utcDate: Date, timeZone: string): Date {
const utcStr = utcDate.toLocaleString("en-US", { timeZone: "UTC" });
const tzStr = utcDate.toLocaleString("en-US", { timeZone });
const offsetMs = new Date(tzStr).getTime() - new Date(utcStr).getTime();
return new Date(utcDate.getTime() + offsetMs);
}
export function toLocalTimeIgnoringDst(utcDate: Date, timeZone: string): Date {
// Use January 1st to get the standard (non-DST) offset for this timezone
const jan = new Date(utcDate.getFullYear(), 0, 1);
const utcStr = jan.toLocaleString("en-US", { timeZone: "UTC" });
const tzStr = jan.toLocaleString("en-US", { timeZone });
const standardOffsetMs = new Date(tzStr).getTime() - new Date(utcStr).getTime();
return new Date(utcDate.getTime() + standardOffsetMs);
}
@@ -9,6 +9,11 @@
import { Text } from "$lib/components/ui/typography";
import { publicStore } from "$lib/stores/public.js";
import type { TPublicAppointment, TPublicSchedule, TPublicSlot } from "$lib/types/public.js";
import {
localToUTC,
timeUTCToLocalWithoutOffset,
utcToLocalWithoutDST,
} from "$lib/utils/datetime";
import {
CalendarDate,
fromDate,
@@ -23,11 +28,6 @@
import type { DateMatcher } from "bits-ui";
import type { OnChangeFn } from "vaul-svelte";
import { fetchSchedule } from "./utils";
import {
localToUTC,
timeUTCToLocalWithoutOffset,
utcToLocalWithoutDST,
} from "$lib/utils/datetime";
const {
channel,
@@ -113,18 +113,8 @@
curDateStr === dateStr
? slots.filter((slot) => {
if (dateStr === curDateStr) {
const now = new Date();
const slotDate = new Date(slot.from);
const slotTime = new Date(
date.year,
date.month - 1,
date.day,
slotDate.getHours(),
slotDate.getMinutes(),
0,
0,
);
return slotTime > now;
// double check, when be changed
return utcToLocalWithoutDST(new Date(slot.from)) > new Date();
}
return true;
})
@@ -240,9 +230,10 @@
{m["public.steps.slot.selectTime"]()}
</Text>
{#each slots as slot (slot.from)}
<Button onclick={() => selectSlot(slot)} class="w-full"
>{formatSlotTime(slot)}</Button
>
<Button onclick={() => selectSlot(slot)} class="w-full">
{formatSlotTime(slot)}
{slot.from}
</Button>
{/each}
</div>
</ScrollArea>
@@ -112,6 +112,7 @@ export const fetchSchedule = async (opts: {
const params = new URLSearchParams({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timeZone: getLocalTimeZone().toString(),
channel: opts.channel,
agent: opts.agent || "",
});
@@ -27,7 +27,8 @@
variant="ghost"
onclick={setCalendarItem}
>
<span class="sr-only">
<span class="">
{item.start}
{m["calendar.addAppointment.preview"]({
time: toDisplayDateTime(utcToLocalWithoutDST(new Date(item.start)), {
hour: "2-digit",
@@ -7,7 +7,7 @@ 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 type { CalendarDate } from "@internationalized/date";
import { getLocalTimeZone, type CalendarDate } from "@internationalized/date";
import { get } from "svelte/store";
export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => {
@@ -23,6 +23,7 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD
const params = new URLSearchParams({
startDate: localStartDate.toISOString(),
endDate: localEndDate.toISOString(),
timeZone: getLocalTimeZone().toString(),
});
await auth.waitForRefresh();
const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, {
@@ -13,6 +13,7 @@
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendar, TCalendarItem } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import {
getLocalTimeZone,
@@ -29,7 +30,6 @@
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar, openAppointmentById } from "./(components)/utils";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
@@ -136,7 +136,8 @@
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
if (utcToLocalWithoutDST(new Date(slot.to)) > new Date()) {
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))
@@ -33,6 +33,14 @@ registerOpenAPIRoute("/tenants/{id}/calendar", "GET", {
schema: { type: "string", format: "date-time" },
description: "End date for the calendar range (ISO 8601 format with timezone)",
},
{
name: "timeZone",
in: "query",
required: false,
schema: { type: "string" },
description:
"IANA timezone name for schedule calculations (e.g., 'Europe/Berlin', 'America/New_York'). Defaults to 'UTC' if not provided. This ensures slots are calculated with the correct daylight saving time offset.",
},
],
responses: {
"200": {
@@ -124,6 +132,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
const timeZoneParam = url.searchParams.get("timeZone") || "UTC";
if (!startDateParam || !endDateParam) {
throw new ValidationError("Both startDate and endDate query parameters are required");
@@ -153,6 +162,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
tenantId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timeZone: timeZoneParam,
});
const scheduleService = await ScheduleService.forTenant(tenantId);
@@ -164,6 +174,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
tenantId,
startDate: startDateParam,
endDate: endDateParam,
timeZone: timeZoneParam,
staffUserId,
});
@@ -142,6 +142,7 @@ describe("Calendar API", () => {
tenantId: "tenant-123",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
timeZone: "UTC",
staffUserId: undefined,
});
});
@@ -138,6 +138,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
const timeZoneParam = url.searchParams.get("timeZone") || "UTC";
const channelIdParam = url.searchParams.get("channel");
const agentIdParam = url.searchParams.get("agent");
@@ -169,6 +170,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
tenantId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
timeZone: timeZoneParam,
});
const scheduleService = await ScheduleService.forTenant(tenantId);
@@ -178,6 +180,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
endDate: endDateParam,
channelId: channelIdParam || undefined,
agentId: agentIdParam || undefined,
timeZone: timeZoneParam,
});
// Transform schedule to client-friendly format (remove appointments, simplify available slots)
@@ -126,6 +126,7 @@ describe("Schedule API Route", () => {
tenantId: mockTenantId,
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
timeZone: "UTC",
});
});
@@ -305,6 +306,7 @@ describe("Schedule API Route", () => {
tenantId: mockTenantId,
startDate,
endDate,
timeZone: "UTC",
});
});
});