diff --git a/src/lib/server/services/__tests__/schedule-service.test.ts b/src/lib/server/services/__tests__/schedule-service.test.ts index 54b392e..c6cec60 100644 --- a/src/lib/server/services/__tests__/schedule-service.test.ts +++ b/src/lib/server/services/__tests__/schedule-service.test.ts @@ -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, }; diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 0fe065f..d544516 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -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; + timeZone: string; }): Promise { 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) + ); }); } diff --git a/src/lib/server/utils/timezone.ts b/src/lib/server/utils/timezone.ts new file mode 100644 index 0000000..25ab9e0 --- /dev/null +++ b/src/lib/server/utils/timezone.ts @@ -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); +} 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 96a4b9d..70931c4 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 @@ -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"]()} {#each slots as slot (slot.from)} - + {/each} diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts index b20f3e3..acc7efc 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts @@ -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 || "", }); diff --git a/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte index 718601d..50e00d0 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte @@ -27,7 +27,8 @@ variant="ghost" onclick={setCalendarItem} > - + + {item.start} {m["calendar.addAppointment.preview"]({ time: toDisplayDateTime(utcToLocalWithoutDST(new Date(item.start)), { hour: "2-digit", diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts index a70e4b1..0e7c44d 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -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}`, { diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 0b28d6e..bfe0464 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -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)) diff --git a/src/routes/api/tenants/[id]/calendar/+server.ts b/src/routes/api/tenants/[id]/calendar/+server.ts index ff0cac9..b0c8402 100644 --- a/src/routes/api/tenants/[id]/calendar/+server.ts +++ b/src/routes/api/tenants/[id]/calendar/+server.ts @@ -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, }); diff --git a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts index 7fb7e61..c39f778 100644 --- a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts +++ b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts @@ -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, }); }); diff --git a/src/routes/api/tenants/[id]/schedule/+server.ts b/src/routes/api/tenants/[id]/schedule/+server.ts index ae6bec3..1cb1564 100644 --- a/src/routes/api/tenants/[id]/schedule/+server.ts +++ b/src/routes/api/tenants/[id]/schedule/+server.ts @@ -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) diff --git a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts index 2f5cf8a..2fd3686 100644 --- a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts +++ b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts @@ -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", }); }); });