Fix display times and appointment time when added using calendar

This commit is contained in:
Karl Ludwig Weise
2026-04-17 16:39:48 +02:00
parent d51de0fbd0
commit 39bb66ec78
18 changed files with 86 additions and 72 deletions
+4 -4
View File
@@ -8,12 +8,12 @@ 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.
- Once we select a slot for an appointment, we save it using the respective date and time in UTC incl. daylight saving adjustments.
- When 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`).
> This currently only works for the northern himsphere because we use January as a reference date (see `const jan = new Date`). We will improve this, when needed.
1. Our Back-End will only every take and return dates using UTC (no daylight saving there)
1. When managing slots for channels
@@ -21,7 +21,7 @@ We are following these priciples:
- 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)
- we will save appointments using UTC (converting it to local time and then to utc)
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.
@@ -3,7 +3,6 @@
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
@@ -11,7 +10,7 @@
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 { toDisplayDateTime } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { getLocalTimeZone } from "@internationalized/date";
import { onMount } from "svelte";
@@ -82,15 +81,15 @@
style="xs"
class="text-muted-foreground -mb-1 flex pr-8 text-start font-light whitespace-break-spaces"
>
{Intl.DateTimeFormat(getLocale(), {
{toDisplayDateTime(new Date(appointment.appointmentDate), {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(utcToLocalWithoutDST(new Date(appointment.appointmentDate)))}
timeZone: getLocalTimeZone(),
timeZoneName: "short",
})}
</Text>
{/if}
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
@@ -10,6 +10,7 @@
import { resest } from "../../../../routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils";
import { Button } from "../button";
import LocalizedText from "./localized-text.svelte";
import { toDisplayDateTime } from "$lib/utils/datetime";
let {
class: className,
@@ -75,14 +76,7 @@
<div class="flex items-start gap-2">
<Calendar class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{new Intl.DateTimeFormat($publicStore.locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Etc/GMT-1",
}).format(appointment.slot.datetime.toDate("UTC"))}
{toDisplayDateTime(appointment.slot.datetime.toDate("UTC"))}
</Text>
</div>
{/if}
-1
View File
@@ -50,7 +50,6 @@
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
@@ -47,7 +47,6 @@
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
@@ -47,7 +47,6 @@
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
@@ -50,7 +50,6 @@
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
-1
View File
@@ -47,7 +47,6 @@
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+34 -5
View File
@@ -1,16 +1,35 @@
import { getLocale } from "$i18n/runtime";
import type { TCalendarSlot } from "$lib/types/calendar";
import { parseAbsoluteToLocal, toCalendarDateTime } from "@internationalized/date";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
toCalendarDateTime,
} from "@internationalized/date";
export const toDisplayDateTime = (date: Date, opts?: Intl.DateTimeFormatOptions) => {
export const toDisplayDateTime = (
date: Date,
opts: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone(),
timeZoneName: "short",
},
) => {
const hours = date.getHours();
const minutes = date.getMinutes();
const formatter = new Intl.DateTimeFormat(
getLocale(),
opts ?? {
opts || {
// TODO: Should be moved outside, in cases where needed
dateStyle: "short",
...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}),
timeZone: "Etc/GMT-1",
...(hours !== 0 || minutes !== 0
? { timeStyle: "short" }
: { hour: "2-digit", minute: "2-digit" }),
timeZone: getLocalTimeZone(),
timeZoneName: "short",
},
);
return formatter.format(date);
@@ -155,3 +174,13 @@ export const localToUTCWithoutDST = (localDate: Date): Date => {
return new Date(localDate.getTime() + standardOffsetMs - currentOffsetMs);
};
export const localToUTC = (localDate: Date): Date => {
const currentOffsetMs = localDate.getTimezoneOffset() * 60 * 1000;
return new Date(localDate.getTime() + currentOffsetMs);
};
export const utcToLocal = (utcDate: Date): Date => {
const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000;
return new Date(utcDate.getTime() - currentOffsetMs);
};
@@ -11,6 +11,7 @@
import type { TPublicAppointment, TPublicSchedule, TPublicSlot } from "$lib/types/public.js";
import {
CalendarDate,
fromDate,
getLocalTimeZone,
parseDate,
toCalendarDate,
@@ -22,7 +23,11 @@
import type { DateMatcher } from "bits-ui";
import type { OnChangeFn } from "vaul-svelte";
import { fetchSchedule } from "./utils";
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
import {
localToUTC,
timeUTCToLocalWithoutOffset,
utcToLocalWithoutDST,
} from "$lib/utils/datetime";
const {
channel,
@@ -137,6 +142,8 @@
const selectSlot = (slot: TPublicSlot) => {
if (selectedDate && slot.availableAgents.length > 0) {
const localTime = utcToLocalWithoutDST(new Date(slot.from));
const utc = localToUTC(localTime);
proceed({
...appointment,
agent: {
@@ -145,10 +152,7 @@
image: slot.availableAgents[0].image || null,
},
slot: {
datetime: toCalendarDateTime(selectedDate).set({
hour: new Date(slot.from).getUTCHours(),
minute: new Date(slot.from).getUTCMinutes(),
}),
datetime: toCalendarDateTime(fromDate(utc, getLocalTimeZone())),
duration: slot.duration,
},
});
@@ -7,7 +7,6 @@
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { publicStore } from "$lib/stores/public.js";
import { toZoned } from "@internationalized/date";
import { toast } from "svelte-sonner";
const tenant = $derived($publicStore.tenant);
@@ -30,7 +29,7 @@
$publicStore.crypto
.createAppointment(
appointment.data,
toZoned(appointment.slot.datetime, "UTC").toAbsoluteString(),
appointment.slot.datetime.toDate("UTC").toISOString(),
appointment.agent.id,
channel.id,
appointment.slot.duration,
@@ -82,15 +82,7 @@
{/if}
</Item.Title>
<Item.Description>
{toDisplayDateTime(utcToLocalWithoutDST(new Date(item.appointmentDate)), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
})}
{toDisplayDateTime(new Date(item.appointmentDate))}
</Item.Description>
</Item.Content>
<Item.Actions>
@@ -1,13 +1,12 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { getLocalTimeZone } from "@internationalized/date";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Mail, Phone, User } from "@lucide/svelte";
import { cancelAppointment, denyAppointment, confirmAppointment } from "./utils";
import { toast } from "svelte-sonner";
import { m } from "$i18n/messages";
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
let {
tenantId,
@@ -121,15 +120,7 @@
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(utcToLocalWithoutDST(item.appointment.appointment.dateTime), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
})}
{toDisplayDateTime(item.appointment.appointment.dateTime)}
</Text>
</div>
<div class="mt-5 flex w-full flex-col gap-4">
@@ -65,6 +65,8 @@
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Hour markers -->
{#each shownHours as hour (`hour-${hour}`)}
<div
class="relative flex w-full items-start justify-between transition-all duration-200 select-none"
@@ -14,6 +14,7 @@
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { utcToLocalWithoutDST } from "$lib/utils/datetime";
let {
tenantId,
@@ -25,10 +26,13 @@
updateCalendar: () => void;
} = $props();
// Set the utc time for it to be properly saved
const localTime = utcToLocalWithoutDST(new Date(item.start));
let step: TAddAppointmentStep = $state("email");
let newAppointment: TAddAppointment = $state({
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
dateTime: new Date(item.start),
dateTime: localTime,
});
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, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { toDisplayDateTime } 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,14 +28,7 @@
<div class="flex gap-2">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(utcToLocalWithoutDST(newAppointment.dateTime), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
})}
{toDisplayDateTime(newAppointment.dateTime)}
</Text>
</div>
{#if newAppointment.name}
@@ -6,6 +6,7 @@ import { auth } from "$lib/stores/auth";
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 { get } from "svelte/store";
@@ -60,8 +61,19 @@ function timeToMinutes(dateTimeStr: string): number {
export function positionItems(items: TCalendarItem[] | undefined) {
if (!items) return [];
// Adjust actual appointments
const adjustedItems = [...items].map((item) => {
if (item.appointment) {
return {
...item,
start: localToUTCWithoutDST(item.appointment.dateTime).toISOString(),
};
}
return item;
});
// Sort items by start time
const sortedItems = [...items]
const sortedItems = [...adjustedItems]
.sort((a, b) => b.duration - a.duration)
.sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start));
@@ -29,7 +29,7 @@
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar, openAppointmentById } from "./(components)/utils";
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
@@ -136,7 +136,7 @@
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
if (new Date(slot.to) > new Date()) {
if (utcToLocalWithoutDST(new Date(slot.to)) > new Date()) {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))