From 97ef807c7cb48abf2efc291f101816f4cc33fa83 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 6 Apr 2026 21:04:57 +0200 Subject: [PATCH] Fix timezone and daylight saving issues --- docs/datetime.md | 27 ++++ .../components/notification-item.svelte | 10 +- .../ui/public/appointment-card.svelte | 9 +- .../ui/slot-template/slot-template.svelte | 8 +- src/lib/utils/datetime.ts | 141 ++++++++++++++---- .../[[id]]/(components)/select-slot.svelte | 26 ++-- .../(components)/appointment-list.svelte | 6 +- .../(components)/AppointmentDetail.svelte | 4 +- .../calendar/(components)/SlotPreview.svelte | 4 +- .../add-appointment/AddAppointment.svelte | 3 +- .../add-appointment/Summary.svelte | 4 +- .../dashboard/calendar/(components)/utils.ts | 8 +- .../(pages)/dashboard/calendar/+page.svelte | 6 +- .../dashboard/channels/(components)/utils.ts | 6 +- 14 files changed, 183 insertions(+), 79 deletions(-) create mode 100644 docs/datetime.md diff --git a/docs/datetime.md b/docs/datetime.md new file mode 100644 index 0000000..62e85c7 --- /dev/null +++ b/docs/datetime.md @@ -0,0 +1,27 @@ +# Date and Time + +We are adjusting for date and time differences between clients and tenants. + +We are also adjusting for daylight saving, meaning we are not shifting times, when they usually shift due to daylight saving. We are doing this because organizations adjust their appointment schedules according to daylight saving as well (a 9am appointment will always be at 9am). + +We are following these priciples: + +- The Back-End only ever handles UTC times. +- When working with schedules we are displaying local time without daylight saving adjustments. +- Once we create an appointment, we save it using the respective date and time in UTC. +- Once we display appointments, we convert from UTC to local time incl. daylight saving adjustments. + +## How it works + +> This currently only works for the northern himsphere because we use January as a reference date (see `const jan = new Date`). + +1. Our Back-End will only every take and return dates using UTC (no daylight saving there) +1. When managing slots for channels + - we will show times without adjusting for daylight saving + - we will save times without adjusting for daylight saving +1. When booking appointments + - we will show time in local time (with or without daylight saving for the respective date) + - we will save appointments using UTC (effectivly not touching the time during booking process) +1. When looking a appointments in the calendar we will show local time (with or without daylight saving). +1. Client Dashboard converts to localtime (with or without daylight saving). +1. E-Mails going out about appointments include the timezone of the user, so the proper time can be shown. diff --git a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte index 7e67ecd..b8f6242 100644 --- a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte @@ -11,8 +11,9 @@ import { channels as channelsStore } from "$lib/stores/channels"; import type { TAppointment } from "$lib/types/appointments"; import type { TNotification } from "$lib/types/notification"; + import { utcToLocalWithoutDST } from "$lib/utils/datetime"; import { getCurrentTranlslation } from "$lib/utils/localizations"; - import { getLocalTimeZone, parseAbsolute } from "@internationalized/date"; + import { getLocalTimeZone } from "@internationalized/date"; import { onMount } from "svelte"; let { @@ -89,12 +90,7 @@ hour: "2-digit", minute: "2-digit", timeZone: getLocalTimeZone().toString(), - }).format( - parseAbsolute( - appointment.appointmentDate as unknown as string, - getLocalTimeZone(), - ).toDate(), - )} + }).format(utcToLocalWithoutDST(new Date(appointment.appointmentDate)))} {/if} diff --git a/src/lib/components/ui/public/appointment-card.svelte b/src/lib/components/ui/public/appointment-card.svelte index c9ccaeb..8515fbe 100644 --- a/src/lib/components/ui/public/appointment-card.svelte +++ b/src/lib/components/ui/public/appointment-card.svelte @@ -5,8 +5,7 @@ import { publicStore } from "$lib/stores/public"; import type { TPublicAppointment } from "$lib/types/public"; import { cn } from "$lib/utils"; - import { getLocalTimeZone } from "@internationalized/date"; - import { Eye, User, Calendar, FileText } from "@lucide/svelte"; + import { Calendar, Eye, FileText, User } from "@lucide/svelte"; import type { HTMLAttributes } from "svelte/elements"; import { resest } from "../../../../routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils"; import { Button } from "../button"; @@ -76,14 +75,14 @@
- {Intl.DateTimeFormat($publicStore.locale, { + {new Intl.DateTimeFormat($publicStore.locale, { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", - timeZone: getLocalTimeZone().toString(), - }).format(appointment.slot.datetime.toDate(getLocalTimeZone()))} + timeZone: "Etc/GMT-1", + }).format(appointment.slot.datetime.toDate("UTC"))}
{/if} diff --git a/src/lib/components/ui/slot-template/slot-template.svelte b/src/lib/components/ui/slot-template/slot-template.svelte index a58fad3..0dcac55 100644 --- a/src/lib/components/ui/slot-template/slot-template.svelte +++ b/src/lib/components/ui/slot-template/slot-template.svelte @@ -5,6 +5,7 @@ import * as Select from "$lib/components/ui/select"; import type { TNewSlotTemplate, TSlotTemplate } from "$lib/types/channel"; import { cn } from "$lib/utils"; + import { timeLocalWithoutOffsetToUTC, timeUTCToLocalWithoutOffset } from "$lib/utils/datetime"; import { Trash } from "@lucide/svelte/icons"; import type { FsSuperForm } from "formsnap"; import type { HTMLAttributes } from "svelte/elements"; @@ -12,7 +13,6 @@ import { Input } from "../input"; import { Text } from "../typography"; import { durations, times, weekdays } from "./utils"; - import { localTimeToUTC, utcTimeToLocal } from "$lib/utils/datetime"; let { form, @@ -72,7 +72,7 @@ }; const getSelectedTime = (utcTime: string) => { - const localTime = utcTimeToLocal(utcTime) as keyof typeof times; + const localTime = timeUTCToLocalWithoutOffset(utcTime) as keyof typeof times; return times[localTime]?.label ?? ""; }; @@ -139,7 +139,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} @@ -162,7 +162,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index d678e70..d420cc3 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -10,6 +10,7 @@ export const toDisplayDateTime = (date: Date, opts?: Intl.DateTimeFormatOptions) opts ?? { dateStyle: "short", ...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}), + timeZone: "Etc/GMT-1", }, ); return formatter.format(date); @@ -20,47 +21,32 @@ export const calendarItemToDate = (item: TCalendarSlot) => { const parsedDate = new Date(item.start); if (!Number.isNaN(parsedDate.getTime())) { const [year, month, day] = item.date.split("-").map(Number); - return new Date(year, month - 1, day, parsedDate.getHours(), parsedDate.getMinutes(), 0, 0); + + // Get standard (non-DST) offset from a winter date + const jan = new Date(year, 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + // Build a UTC date and apply the standard offset + const utc = Date.UTC( + year, + month - 1, + day, + parsedDate.getUTCHours(), + parsedDate.getUTCMinutes(), + 0, + 0, + ); + return new Date(utc - standardOffsetMs); } } - const [year, month, day] = item.date.split("-").map(Number); - const [hours, minutes] = item.start.split(":").map((it) => Number.parseInt(it)); - const date = new Date(year, month - 1, day); - date.setHours(hours, minutes, 0, 0); - return date; + throw new Error(`Unable to parse date from calendar item: ${item.start}`); }; export const toInputDateTime = (dateStr: string) => { return toCalendarDateTime(parseAbsoluteToLocal(dateStr)); }; -export const localTimeToUTC = (localTime: string) => { - const [hours, minutes, seconds] = localTime.split(":").map(Number); - - const now = new Date(); - now.setHours(hours, minutes, seconds, 0); - - const utcHours = String(now.getUTCHours()).padStart(2, "0"); - const utcMinutes = String(now.getUTCMinutes()).padStart(2, "0"); - const utcSeconds = String(now.getUTCSeconds()).padStart(2, "0"); - - return `${utcHours}:${utcMinutes}:${utcSeconds}`; -}; - -export function utcTimeToLocal(utcTime: string): string { - const [hours, minutes, seconds] = utcTime.split(":").map(Number); - - const now = new Date(); - now.setUTCHours(hours, minutes, seconds, 0); - - const localHours = String(now.getHours()).padStart(2, "0"); - const localMinutes = String(now.getMinutes()).padStart(2, "0"); - const localSeconds = String(now.getSeconds()).padStart(2, "0"); - - return `${localHours}:${localMinutes}:${localSeconds}`; -} - export const getDefaultStartTime = () => { const date = new Date(); date.setHours(7); @@ -78,3 +64,94 @@ export const getDefaultEndTime = () => { date.setMilliseconds(0); return date.toISOString(); }; + +/* + + Date and Time processing + See datetime.md for reference + +*/ + +export const timeLocalWithoutOffsetToUTC = (localTime: string) => { + const [hours, minutes, seconds] = localTime.split(":").map(Number); + + // Get the standard (non-DST) offset by checking a date in winter + const jan = new Date(new Date().getFullYear(), 0, 1); // January 1st + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + const now = new Date(); + now.setHours(hours, minutes, seconds, 0); + + // Remove the current offset and apply the standard one instead + const utc = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000 + standardOffsetMs); + + const utcHours = String(utc.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(utc.getUTCMinutes()).padStart(2, "0"); + const utcSeconds = String(utc.getUTCSeconds()).padStart(2, "0"); + + return `${utcHours}:${utcMinutes}:${utcSeconds}`; +}; + +export function timeUTCToLocalWithoutOffset(utcTime: string): string { + const [hours, minutes, seconds] = utcTime.split(":").map(Number); + + // Standard (non-DST) offset + const jan = new Date(new Date().getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + const now = new Date(); + now.setUTCHours(hours, minutes, seconds, 0); + + // Remove the standard offset, then re-apply the current one + const local = new Date(now.getTime() + now.getTimezoneOffset() * 60 * 1000 - standardOffsetMs); + + const localHours = String(local.getHours()).padStart(2, "0"); + const localMinutes = String(local.getMinutes()).padStart(2, "0"); + const localSeconds = String(local.getSeconds()).padStart(2, "0"); + + return `${localHours}:${localMinutes}:${localSeconds}`; +} + +export const timeLocalToUTC = (localTime: string, date?: Date) => { + const [hours, minutes, seconds] = localTime.split(":").map(Number); + + // Use the provided date (to get the correct DST offset for that day), + // or fall back to today + const target = date ? new Date(date) : new Date(); + target.setHours(hours, minutes, seconds, 0); + + const utcHours = String(target.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(target.getUTCMinutes()).padStart(2, "0"); + const utcSeconds = String(target.getUTCSeconds()).padStart(2, "0"); + + return `${utcHours}:${utcMinutes}:${utcSeconds}`; +}; + +export const timeUTCToLocal = (utcTime: string, date?: Date) => { + const [hours, minutes, seconds] = utcTime.split(":").map(Number); + + const target = date ? new Date(date) : new Date(); + target.setUTCHours(hours, minutes, seconds, 0); + + const localHours = String(target.getHours()).padStart(2, "0"); + const localMinutes = String(target.getMinutes()).padStart(2, "0"); + const localSeconds = String(target.getSeconds()).padStart(2, "0"); + + return `${localHours}:${localMinutes}:${localSeconds}`; +}; + +export const utcToLocalWithoutDST = (utcDate: Date): Date => { + const jan = new Date(utcDate.getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000; + + return new Date(utcDate.getTime() - standardOffsetMs + currentOffsetMs); +}; + +export const localToUTCWithoutDST = (localDate: Date): Date => { + const jan = new Date(localDate.getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + const currentOffsetMs = localDate.getTimezoneOffset() * 60 * 1000; + + return new Date(localDate.getTime() + standardOffsetMs - currentOffsetMs); +}; 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 fbde4d7..3aeb841 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 @@ -22,6 +22,7 @@ import type { DateMatcher } from "bits-ui"; import type { OnChangeFn } from "vaul-svelte"; import { fetchSchedule } from "./utils"; + import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime"; const { channel, @@ -156,21 +157,26 @@ const formatSlotTime = (slot: TPublicSlot) => { const slotDate = new Date(slot.from); - const displayDate = new Date( - slotDate.getFullYear(), - slotDate.getMonth(), - slotDate.getDate(), - slotDate.getHours(), - slotDate.getMinutes(), - 0, - 0, - ); + const utcTime = slot.from.slice(11, 19); // "HH:mm:ss" from ISO string + const localTime = timeUTCToLocalWithoutOffset(utcTime); + + const [hours, minutes] = localTime.split(":"); return new Intl.DateTimeFormat(getLocale(), { hour: "2-digit", minute: "2-digit", hour12: false, - }).format(displayDate); + }).format( + new Date( + slotDate.getFullYear(), + slotDate.getMonth(), + slotDate.getDate(), + Number(hours), + Number(minutes), + 0, + 0, + ), + ); }; diff --git a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte index 2ae45d0..41a7da0 100644 --- a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte +++ b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte @@ -11,7 +11,7 @@ import { Headline, Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; import { publicStore } from "$lib/stores/public"; - import { toDisplayDateTime } from "$lib/utils/datetime"; + import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { getLocalTimeZone } from "@internationalized/date"; import { Calendar, CircleAlert } from "@lucide/svelte"; import { onMount } from "svelte"; @@ -82,7 +82,7 @@ {/if} - {toDisplayDateTime(new Date(item.appointmentDate), { + {toDisplayDateTime(utcToLocalWithoutDST(new Date(item.appointmentDate)), { year: "numeric", month: "long", day: "numeric", @@ -135,7 +135,7 @@
- {toDisplayDateTime(new Date(cancelling.appointmentDate), { + {toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), { year: "numeric", month: "long", day: "numeric", diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte index c0636cc..0cc0c92 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -7,7 +7,7 @@ import { cancelAppointment, denyAppointment, confirmAppointment } from "./utils"; import { toast } from "svelte-sonner"; import { m } from "$i18n/messages"; - import { toDisplayDateTime } from "$lib/utils/datetime"; + import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; let { tenantId, @@ -121,7 +121,7 @@
- {toDisplayDateTime(item.appointment.appointment.dateTime, { + {toDisplayDateTime(utcToLocalWithoutDST(item.appointment.appointment.dateTime), { year: "numeric", month: "long", day: "numeric", diff --git a/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte index 8924d6c..718601d 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/SlotPreview.svelte @@ -4,7 +4,7 @@ import { calendarStore } from "$lib/stores/calendar"; import { channels as channelsStore } from "$lib/stores/channels"; import { type TCalendarItem } from "$lib/types/calendar"; - import { calendarItemToDate, toDisplayDateTime } from "$lib/utils/datetime"; + import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { getCurrentTranlslation } from "$lib/utils/localizations"; let { @@ -29,7 +29,7 @@ > {m["calendar.addAppointment.preview"]({ - time: toDisplayDateTime(calendarItemToDate(item), { + time: toDisplayDateTime(utcToLocalWithoutDST(new Date(item.start)), { hour: "2-digit", minute: "2-digit", }), diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte index 81a1259..696ca62 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte @@ -6,7 +6,6 @@ import { staffCrypto } from "$lib/stores/staff-crypto"; import { tenants } from "$lib/stores/tenants"; import type { TCalendarSlot } from "$lib/types/calendar"; - import { calendarItemToDate } from "$lib/utils/datetime"; import { getDefaultAppointmentLocale } from "$lib/utils/localizations"; import { BanIcon, Check } from "@lucide/svelte"; import { get } from "svelte/store"; @@ -29,7 +28,7 @@ let step: TAddAppointmentStep = $state("email"); let newAppointment: TAddAppointment = $state({ locale: getDefaultAppointmentLocale(get(tenants).currentTenant), - dateTime: calendarItemToDate(item), + dateTime: new Date(item.start), }); let isSubmitting = $state(false); diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte index 8aa5260..719a50b 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte @@ -3,7 +3,7 @@ import { Separator } from "$lib/components/ui/separator"; import { Text } from "$lib/components/ui/typography"; import { agents as agentsStore } from "$lib/stores/agents"; - import { toDisplayDateTime } from "$lib/utils/datetime"; + import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte"; import type { TAddAppointment, TAddAppointmentStep } from "./types"; import * as Select from "$lib/components/ui/select"; @@ -28,7 +28,7 @@
- {toDisplayDateTime(newAppointment.dateTime, { + {toDisplayDateTime(utcToLocalWithoutDST(newAppointment.dateTime), { year: "numeric", month: "long", day: "numeric", diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts index a5c7d5c..d2b56ac 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -41,9 +41,9 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD }; // Convert time string to minutes since midnight -function timeToMinutes(time: string): number { - if (time.includes("T")) { - const parsedDate = new Date(time); +function timeToMinutes(dateTimeStr: string): number { + if (dateTimeStr.includes("T")) { + const parsedDate = new Date(dateTimeStr); if (!Number.isNaN(parsedDate.getTime())) { return ( parsedDate.getHours() * 60 + parsedDate.getMinutes() + parsedDate.getTimezoneOffset() + 60 @@ -51,7 +51,7 @@ function timeToMinutes(time: string): number { } } - const [hours, minutes] = time.split(":").map(Number); + const [hours, minutes] = dateTimeStr.split(":").map(Number); return hours * 60 + minutes; } diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 89b98ac..76a0daa 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -29,7 +29,7 @@ import CalendarFilters from "./(components)/CalendarFilters.svelte"; import CalendarHeader from "./(components)/CalendarHeader.svelte"; import { fetchCalendar, openAppointmentById } from "./(components)/utils"; - import { utcTimeToLocal } from "$lib/utils/datetime"; + import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime"; const convertDate = (dateStr: string) => { const zonedDateTime = parseAbsoluteToLocal(dateStr); @@ -55,14 +55,14 @@ .map((c) => c.slotTemplates.map((t) => t.from)) .flat() .map((time) => { - const [hourStr] = utcTimeToLocal(time).split(":"); + 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] = utcTimeToLocal(time).split(":"); + const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":"); return parseInt(hourStr, 10); }); return { from: Math.min(...from), to: Math.max(...to) }; diff --git a/src/routes/(pages)/dashboard/channels/(components)/utils.ts b/src/routes/(pages)/dashboard/channels/(components)/utils.ts index ad967e6..3d4be61 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/channels/(components)/utils.ts @@ -1,9 +1,9 @@ import type { TNewSlotTemplate } from "$lib/types/channel"; -import { localTimeToUTC } from "$lib/utils/datetime"; +import { timeLocalWithoutOffsetToUTC } from "$lib/utils/datetime"; export const DEFAULT_SLOT_TEMPLATE: TNewSlotTemplate = { weekdays: 31, - from: localTimeToUTC("09:00:00"), - to: localTimeToUTC("17:00:00"), + from: timeLocalWithoutOffsetToUTC("09:00:00"), + to: timeLocalWithoutOffsetToUTC("17:00:00"), duration: 15, };