mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-13 20:57:39 +02:00
Fix display times and appointment time when added using calendar
This commit is contained in:
+4
-4
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+9
-5
@@ -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"
|
||||
|
||||
+5
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user