mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-29 12:14:50 +02:00
Fix absences and appointments to block proper slots
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+11
-20
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user