Fix timezone and daylight saving issues

This commit is contained in:
Karl Ludwig Weise
2026-04-06 21:04:57 +02:00
parent 202cfbffaa
commit 97ef807c7c
14 changed files with 183 additions and 79 deletions
+27
View File
@@ -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.
@@ -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)))}
</Text>
{/if}
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
@@ -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 @@
<div class="flex items-start gap-2">
<Calendar class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{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"))}
</Text>
</div>
{/if}
@@ -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 ?? "";
};
</script>
@@ -139,7 +139,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -162,7 +162,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
+109 -32
View File
@@ -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);
};
@@ -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,
),
);
};
</script>
@@ -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}
</Item.Title>
<Item.Description>
{toDisplayDateTime(new Date(item.appointmentDate), {
{toDisplayDateTime(utcToLocalWithoutDST(new Date(item.appointmentDate)), {
year: "numeric",
month: "long",
day: "numeric",
@@ -135,7 +135,7 @@
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(new Date(cancelling.appointmentDate), {
{toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), {
year: "numeric",
month: "long",
day: "numeric",
@@ -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 @@
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(item.appointment.appointment.dateTime, {
{toDisplayDateTime(utcToLocalWithoutDST(item.appointment.appointment.dateTime), {
year: "numeric",
month: "long",
day: "numeric",
@@ -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 @@
>
<span class="sr-only">
{m["calendar.addAppointment.preview"]({
time: toDisplayDateTime(calendarItemToDate(item), {
time: toDisplayDateTime(utcToLocalWithoutDST(new Date(item.start)), {
hour: "2-digit",
minute: "2-digit",
}),
@@ -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);
@@ -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 @@
<div class="flex gap-2">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(newAppointment.dateTime, {
{toDisplayDateTime(utcToLocalWithoutDST(newAppointment.dateTime), {
year: "numeric",
month: "long",
day: "numeric",
@@ -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;
}
@@ -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) };
@@ -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,
};