From c1ee527818fc4d03d9303c21f48429d7b10494bf Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 12 Mar 2026 17:43:27 +0100 Subject: [PATCH 01/21] wip --- docker-compose.prod.yml | 9 ++++++++- src/server-hooks/secHeaderHandle.ts | 2 +- svelte.config.js | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index db303b1..3ec690d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -68,7 +68,14 @@ services: - open-reception-internal healthcheck: test: - ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:3000/api/health", + ] interval: 30s timeout: 10s retries: 5 diff --git a/src/server-hooks/secHeaderHandle.ts b/src/server-hooks/secHeaderHandle.ts index c1fb1e4..1fd75f1 100644 --- a/src/server-hooks/secHeaderHandle.ts +++ b/src/server-hooks/secHeaderHandle.ts @@ -40,7 +40,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { ]; if (!dev) { cspDirectives.unshift("default-src 'self' 'wasm-unsafe-eval'"); - cspDirectives.push("img-src 'self' data: https:"); + cspDirectives.push("img-src 'self' data: blob: https:"); } response.headers.set("Content-Security-Policy", cspDirectives.join("; ")); diff --git a/svelte.config.js b/svelte.config.js index b5d398f..eb83f29 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -16,6 +16,18 @@ const config = { alias: { $i18n: path.resolve("./src/i18n"), }, + csp: { + mode: "hash", + directives: { + "script-src": [ + "'self'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + "'unsafe-eval'", + "https://unpkg.com", + ], + }, + }, }, }; From b8a650ebdd05fd855b50e4bd1ff6e2afb6ec8f10 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 16 Mar 2026 09:18:04 +0100 Subject: [PATCH 02/21] Added checkOrigin to svelte config --- svelte.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/svelte.config.js b/svelte.config.js index eb83f29..258012b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -7,6 +7,7 @@ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), + checkOrigin: true, kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. From e16905ee4f60f84c4c359f0c6a62ef0722f575e3 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 16 Mar 2026 13:08:42 +0100 Subject: [PATCH 03/21] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++++ CONTRIBUTION.md | 97 +++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 CONTRIBUTION.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 0000000..4b02d18 --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,97 @@ +# OpenReception Contributing Guide + +Hi! Thank you so much for being interested in contributing to OpenReception. + +As a contributor, here are the guidelines we would like you to read: + +- [Code of Conduct](#coc) +- [Got a Question or Problem?](#question) +- [Found a Bug?](#issue) +- [Do you have a Feature Idea?](#feature) +- [Contributing Guidelines](#submit) + +## Code of Conduct + +Help us keep OpenReception community safe and inclusive. +Please read and follow our [Code of Conduct](https://open-reception.org/community/code-of-conduct/). + +## Got a Question or Problem? + +Do not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests. +Instead, we recommend using our Discussion Forum space to ask support-related questions. + +## Found a Bug? + +If you find a bug in the source code, you can help us by [Opening a Ticket](#submit-issue) to our Ticketing System. + +Even better, you can [Submit a Pull Request](#submit-pr) with a fix. + +## Do you have a Feature Idea? + +You can suggest a new feature by [Opening a Ticket](#submit-issue) to our Ticketing System. +If you would like to implement a new feature, please consider the size of the change in order to determine the right steps to proceed: + +- For a **Major Feature**, first open an issue and outline your proposal so that it can be discussed. + This process allows us to better coordinate our efforts, prevent duplication of work, and help you to craft the change so that it is successfully accepted into the project. + +- **Small Features** can be crafted and directly [Submitting a Pull Request](#submit-pr). + +## Submission Guidelines + +### Legal implications + +OpenReception maintainers have offered selected business partners a commercial license for the software. Developments made by these partners are not subject to the AGPL. + +By contributing to the Open Source project, you agree that these commercial partners may also use and incorporate all enhancements and modifications made to the Open Source product under their commercial license. + +### Opening a Ticket + +Before you open a ticket, please search through the [List of Tickets](https://github.com/open-reception/appointment-booking-software/issues). A ticket for your problem might already exist and the discussion might inform you of workarounds readily available. + +We want to fix all the issues as soon as possible, but before fixing a bug, we need to reproduce and confirm it. +In order to reproduce bugs, we require that you provide a minimal reproduction. +Having a minimal reproducible scenario gives us a wealth of important information without going back and forth to you with additional questions. + +A minimal reproduction allows us to quickly confirm a bug (or point out a coding problem) as well as confirm that we are fixing the right problem. + +We require a minimal reproduction to save maintainers' time and ultimately be able to fix more bugs. +Often, developers find coding problems themselves while preparing a minimal reproduction. +We understand that sometimes it might be hard to extract essential bits of code from a larger codebase, but we really need to isolate the problem before we can fix it. + +Unfortunately, we are not able to investigate / fix bugs without a minimal reproduction, so if we your issue lacks this information, we are going to close it. + +You can file new issues by selecting from our [new issue templates](https://github.com/open-reception/appointment-booking-software/issues/new/choose) and filling out the issue template. + +### Submitting a Pull Request + +Before you submit your Pull Request consider the following guidelines: + +1. Read and accept the [legal implications](#submission-legal). + +1. Search on the [List of Pull Requests](https://github.com/open-reception/appointment-booking-software/pulls) for an open or closed pull requests that relates to your contribution. + You don't want to duplicate existing efforts. + +1. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add. + Discussing the design upfront helps to ensure that we're ready to accept your work. + +1. Fork the `appointment-booking-software` repository. + +1. In your forked repository, make your changes in dedicated git branch. + +1. Create your patch, documenting your code and including appropriate test cases. + +1. Follow standard coding guidelines for each specific language. + +1. Run the full `appointment-booking-software` test suite and ensure that all tests pass. + +1. Also run full formatting, linting, and type checking. + +1. Commit your changes using a descriptive commit message. + +1. In GitHub, send a pull request to `main` branch. + +--- + +A big thank you to everyone who has already contributed to the development of OpenReception! + +This contribution guide was largely inspired by the [GlobaLeaks Contribution Guide](https://www.globaleaks.org) From 0c0bfcbeffe86daef357ba8758f96d061b9de020 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 23 Mar 2026 09:46:08 +0100 Subject: [PATCH 04/21] Update Trivy action version in release workflow Due to info stealer mentioned in https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9e7ad7..0fa51ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: run: docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:${{ github.ref_name }} . - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@0.35.0 with: image-ref: "openreception/open-reception:${{ github.ref_name }}" format: "table" From 065c53416b366f29a0bc5f5e9c6b7219fe12ab20 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 16 Mar 2026 20:17:52 +0100 Subject: [PATCH 05/21] Fix pausing/activating channels --- .../pause-channel-form/pause-channel-form.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte index 88c53e0..4fe3c03 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte @@ -11,6 +11,7 @@ import { superForm } from "sveltekit-superforms"; import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; + import { Checkbox } from "$lib/components/ui/checkbox"; let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props(); @@ -59,6 +60,13 @@ {/snippet} +
From c1bd9e3b6667edc935fa8368528e597837d51b50 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 18 Mar 2026 21:54:08 +0100 Subject: [PATCH 06/21] Added translation fixes --- project.inlang/messages/de.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index eaecd38..0e35894 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -962,16 +962,16 @@ "preview": "Neuer Termin {time} Uhr {channel}", "steps": { "selectClient": { - "hasNoEmail": "Klient hat keine E-Mail Adresse", - "action": "Klient suchen", - "confirmNewClient": "Dieser Klient hat noch kein Konto. Möchten Sie für ihn ein Konto erstellen?", - "proceedExistingClient": "Klient ausgewählt." + "hasNoEmail": "Klient:in hat keine E-Mail Adresse", + "action": "Klient:in suchen", + "confirmNewClient": "Diese Klient:in hat noch kein Konto. Möchten Sie ein Konto erstellen?", + "proceedExistingClient": "Klient:in ausgewählt." }, "agents": { "title": "Akteur auswählen" }, "clientData": { - "shareEmail": "Klient wünscht Benachrichtigungen per E-Mail", + "shareEmail": "Klient:in wünscht Benachrichtigungen per E-Mail", "phoneHint": "Format: +1234567890 (optional)", "action": "Daten hinzufügen" }, @@ -1071,7 +1071,8 @@ "checking-auth": "Überprüfe Anmeldedaten...", "login": { "title": "Als Klient anmelden", - "description": "Bitte geben Sie Ihre E-Mail Adresse und Ihre PIN an." + "description": "Bitte geben Sie Ihre E-Mail Adresse und Ihre PIN an.", + "altAction": "Ich bin Mitarbeiter:in hier" }, "appointments": { "title": "Meine Termine", From 29c64532f6109b04d250ff2006ea450108e2d31a Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 24 Mar 2026 20:23:12 +0100 Subject: [PATCH 07/21] Fix calendar appointment positions coming in utc --- src/routes/(pages)/dashboard/calendar/(components)/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts index c4bb5a4..a5c7d5c 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -45,7 +45,9 @@ function timeToMinutes(time: string): number { if (time.includes("T")) { const parsedDate = new Date(time); if (!Number.isNaN(parsedDate.getTime())) { - return parsedDate.getHours() * 60 + parsedDate.getMinutes(); + return ( + parsedDate.getHours() * 60 + parsedDate.getMinutes() + parsedDate.getTimezoneOffset() + 60 + ); } } From 1b4b5d92ff3d7a21c8b0eceb66abf5d15c191506 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 25 Mar 2026 10:36:49 +0100 Subject: [PATCH 08/21] Removed trivy vunerability scanner due to their security issues --- .github/workflows/release.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fa51ff..9d0f0c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,16 +65,6 @@ jobs: - name: Build Docker image run: docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:${{ github.ref_name }} . - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@0.35.0 - with: - image-ref: "openreception/open-reception:${{ github.ref_name }}" - format: "table" - exit-code: "1" - ignore-unfixed: true - vuln-type: "os,library" - severity: "CRITICAL,HIGH" - - name: Push Docker images run: npm run docker:build-and-push From 6c9290692e0d0b9dc1317cb57d2e1083b09b22ec Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 31 Mar 2026 17:26:56 +0200 Subject: [PATCH 09/21] Added --ignore-scripts to all npm ci installs --- .github/workflows/main-deploy.yml | 2 +- .github/workflows/pr-checks.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main-deploy.yml b/.github/workflows/main-deploy.yml index 7b3372a..eb3294e 100644 --- a/.github/workflows/main-deploy.yml +++ b/.github/workflows/main-deploy.yml @@ -19,7 +19,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Security audit run: npm audit --audit-level=high diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2513c4e..a981e56 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -20,7 +20,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run linting run: npm run lint @@ -38,7 +38,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run type checking run: npm run check @@ -56,7 +56,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Run unit tests run: npm run test:unit -- --run @@ -85,7 +85,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build application run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d0f0c7..888b935 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Update package.json version run: | From 816b7ee7ded4448e1998c957b98375369058d513 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Sat, 4 Apr 2026 19:20:09 +0200 Subject: [PATCH 10/21] Added title to settings page --- project.inlang/messages/de.json | 1 + project.inlang/messages/en.json | 1 + src/routes/(pages)/dashboard/settings/+page.svelte | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 0e35894..24acbe6 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -634,6 +634,7 @@ } }, "settings": { + "title": "Einstellungen", "loading": "Lade Einstellungen...", "form": { "sections": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index f2c0ee5..b47a588 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -643,6 +643,7 @@ } }, "settings": { + "title": "Settings", "loading": "Loading settings...", "form": { "sections": { diff --git a/src/routes/(pages)/dashboard/settings/+page.svelte b/src/routes/(pages)/dashboard/settings/+page.svelte index 0d448e5..2067f08 100644 --- a/src/routes/(pages)/dashboard/settings/+page.svelte +++ b/src/routes/(pages)/dashboard/settings/+page.svelte @@ -11,6 +11,10 @@ let { data } = $props(); + + {m["settings.title"]()} - OpenReception + + Date: Mon, 6 Apr 2026 21:04:57 +0200 Subject: [PATCH 11/21] 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, }; From 758e0d5ddd1528341129b07343654343e0ab32c1 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 7 Apr 2026 14:39:59 +0200 Subject: [PATCH 12/21] Fix: Dashboard does not auto-logout when session refresh races with other calls --- project.inlang/messages/de.json | 2 +- project.inlang/messages/en.json | 2 +- src/lib/stores/agents.ts | 1 + src/lib/stores/auth.ts | 23 ++++++++++++++----- src/lib/stores/channels.ts | 1 + src/lib/stores/notifications.ts | 5 +++- src/lib/stores/staff.ts | 5 +++- src/lib/utils/session.ts | 11 +++++---- src/routes/(pages)/dashboard/+layout.svelte | 3 +-- .../dashboard/calendar/(components)/utils.ts | 2 ++ 10 files changed, 38 insertions(+), 17 deletions(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 24acbe6..d8d92a2 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -929,7 +929,7 @@ "today": "Heute", "loading": "Kalender wird geladen", "decrypting": "wird entschlüsselt...", - "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", + "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu laden/anmelden.", "decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.", "notificationHint": "Klienten werden per E-Mail benachrichtigt, wenn diese Benachrichtigungen aktiviert haben.", "shownAppointments": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index b47a588..5a2755a 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -938,7 +938,7 @@ "today": "Today", "loading": "Loading calendar", "decrypting": "decrypting...", - "decryptingError": "Unable to decrypt data. Please log-in again", + "decryptingError": "Unable to decrypt data. Please reload/log-in again", "decryptingErrorNoKeyShare": "Unable to decrypt data. Get a team member to grant you access.", "notificationHint": "Clients will be notified via e-mail, if they have enabled e-mail notifications.", "shownAppointments": { diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index a19bcf5..69b77e6 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -25,6 +25,7 @@ const createAgentsStore = () => { try { const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/agents`, { method: "GET", headers: { diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 648c23f..128ffb1 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -11,7 +11,7 @@ export interface PasskeyAuthData { export interface AuthState { isAuthenticated: boolean; - isRefreshing: boolean; + refreshPromise: Promise | null; user?: { id: string; email: string; @@ -26,17 +26,17 @@ export interface AuthState { function createAuthStore() { const store = writable({ isAuthenticated: false, - isRefreshing: false, + refreshPromise: null, }); return { ...store, - setRefreshing: (isRefreshing: boolean) => { - store.update((state) => ({ ...state, isRefreshing })); - }, setAuthenticated: (isAuthenticated: boolean) => { store.update((state) => ({ ...state, isAuthenticated })); }, + setRefreshPromise: (promise: Promise | null) => { + store.update((state) => ({ ...state, refreshPromise: promise })); + }, setUser: (user: AuthState["user"]) => { store.update((state) => ({ ...state, isAuthenticated: true, user })); @@ -57,7 +57,7 @@ function createAuthStore() { reset: () => { store.set({ isAuthenticated: false, - isRefreshing: false, + refreshPromise: null, user: undefined, passkeyAuthData: undefined, }); @@ -94,6 +94,17 @@ function createAuthStore() { unsubscribe(); return authState!.user?.tenantId || null; }, + waitForRefresh: async () => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + + if (authState!.refreshPromise) { + await authState!.refreshPromise; + } + }, }; } diff --git a/src/lib/stores/channels.ts b/src/lib/stores/channels.ts index 9dfbb20..1f63985 100644 --- a/src/lib/stores/channels.ts +++ b/src/lib/stores/channels.ts @@ -25,6 +25,7 @@ const createChannelsStore = () => { try { const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/channels`, { method: "GET", headers: { diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts index ab827b9..99a0d08 100644 --- a/src/lib/stores/notifications.ts +++ b/src/lib/stores/notifications.ts @@ -19,12 +19,15 @@ const createNotificationsStore = () => { load: async () => { if (!browser) return; + const tenantId = auth.getTenant(); + if (!tenantId) return; + store.update((state) => { return { ...state, isLoading: true }; }); try { - const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/notifications`, { method: "GET", headers: { diff --git a/src/lib/stores/staff.ts b/src/lib/stores/staff.ts index aef08ab..5fdbad0 100644 --- a/src/lib/stores/staff.ts +++ b/src/lib/stores/staff.ts @@ -19,12 +19,15 @@ const createStaffStore = () => { load: async () => { if (!browser) return; + const tenantId = auth.getTenant(); + if (!tenantId) return; + store.update((state) => { return { ...state, isLoading: true }; }); try { - const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/staff`, { method: "GET", headers: { diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index c2a9c31..425e485 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -6,10 +6,8 @@ import { auth } from "$lib/stores/auth"; export const refreshSession = async () => { if (!auth.isAuthenticated()) return; - auth.setRefreshing(true); - try { - const response = await fetch("/api/auth/refresh", { + const refreshPromise = fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json", @@ -17,9 +15,12 @@ export const refreshSession = async () => { credentials: "same-origin", }); + auth.setRefreshPromise(refreshPromise); + const response = await refreshPromise; + if (!response.ok) { if (response.status === 401) { - auth.setRefreshing(false); + auth.setRefreshPromise(null); goto(resolve(ROUTES.LOGOUT)); return; } @@ -27,7 +28,7 @@ export const refreshSession = async () => { refreshUserData(); } finally { - auth.setRefreshing(false); + auth.setRefreshPromise(null); } }; diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 5b16fef..acde2c7 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -23,12 +23,11 @@ updateStores(); if (!intervalData) { - refreshSession(); intervalData = setInterval(updateStores, 2 * 60 * 1000); // 2 minutes } if (!intervalSession) { refreshSession(); - intervalSession = setInterval(refreshSession, 5 * 60 * 1000); // 5 minutes + intervalSession = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes } const unsubscribe = () => { diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts index d2b56ac..e8f4cf1 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -2,6 +2,7 @@ import { browser } from "$app/environment"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import { ROUTES } from "$lib/const/routes"; +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"; @@ -22,6 +23,7 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD startDate: localStartDate.toISOString(), endDate: localEndDate.toISOString(), }); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, { method: "GET", }); From 9a49a80c025b9e3d44dd9f59e90c75669ce23ead Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 7 Apr 2026 14:40:02 +0200 Subject: [PATCH 13/21] Fix lint --- .github/ISSUE_TEMPLATE/bug_report.md | 24 ++++++++++++----------- .github/ISSUE_TEMPLATE/feature_request.md | 7 +++---- docker-compose.prod.yml | 9 +-------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7..9b77ea7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,9 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..2bc5d5f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,10 +1,9 @@ --- name: Feature request about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Is your feature request related to a problem? Please describe.** diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3ec690d..db303b1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -68,14 +68,7 @@ services: - open-reception-internal healthcheck: test: - [ - "CMD", - "wget", - "--no-verbose", - "--tries=1", - "--spider", - "http://127.0.0.1:3000/api/health", - ] + ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s retries: 5 From 4f73625c97acace51786781130a6ded24e20e66e Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 7 Apr 2026 14:43:53 +0200 Subject: [PATCH 14/21] Changed object csp object-src to self --- src/hooks.server.test.ts | 2 +- src/server-hooks/secHeaderHandle.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index 7b3c71e..2b7530c 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -233,7 +233,7 @@ describe("hooks.server", () => { expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); expect(response.headers.get("Content-Security-Policy")).toContain( - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", ); }); diff --git a/src/server-hooks/secHeaderHandle.ts b/src/server-hooks/secHeaderHandle.ts index 1fd75f1..7033c6d 100644 --- a/src/server-hooks/secHeaderHandle.ts +++ b/src/server-hooks/secHeaderHandle.ts @@ -32,7 +32,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { "font-src 'self' data: https://unpkg.com", "connect-src 'self'", "media-src 'self'", - "object-src 'none'", + "object-src 'self'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", From 527e1f49e9b7193295884795feb9519959fdb17d Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 8 Apr 2026 15:43:27 +0200 Subject: [PATCH 15/21] Save timezone, use timezone in appointment mails. --- src/lib/client/appointment-crypto.ts | 4 + src/lib/emails/AppointmentBooked.svelte | 4 +- src/lib/emails/AppointmentCancelled.svelte | 4 +- src/lib/emails/AppointmentRejected.svelte | 4 +- src/lib/emails/AppointmentReminder.svelte | 4 +- src/lib/emails/AppointmentRequest.svelte | 4 +- src/lib/emails/utils.ts | 32 +- src/lib/server/db/tenant-schema.ts | 2 + .../server/services/appointment-service.ts | 7 + src/lib/types/appointment.ts | 2 + .../appointments/add-to-tunnel/+server.ts | 4 + .../appointments/create-new-client/+server.ts | 6 + tenant-migrations/0014_fluffy_ezekiel.sql | 2 + tenant-migrations/meta/0014_snapshot.json | 1031 +++++++++++++++++ tenant-migrations/meta/_journal.json | 9 +- 15 files changed, 1098 insertions(+), 21 deletions(-) create mode 100644 tenant-migrations/0014_fluffy_ezekiel.sql create mode 100644 tenant-migrations/meta/0014_snapshot.json diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index c46ffec..6483f5d 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -444,6 +444,8 @@ export class UnifiedAppointmentCrypto { ? `/api/tenants/${tenantId}/appointments/create-new-client` : `/api/tenants/${tenantId}/appointments/add-to-tunnel`; + const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const requestData = isFirstAppointment ? { // New client @@ -451,6 +453,7 @@ export class UnifiedAppointmentCrypto { agentId, channelId, appointmentDate, + appointmentTimeZone, duration, emailHash: this.emailHash, clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, @@ -470,6 +473,7 @@ export class UnifiedAppointmentCrypto { agentId, channelId, appointmentDate, + appointmentTimeZone, duration, clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, clientLanguage, diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte index 76ef184..b683f98 100644 --- a/src/lib/emails/AppointmentBooked.svelte +++ b/src/lib/emails/AppointmentBooked.svelte @@ -48,8 +48,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentCancelled.svelte b/src/lib/emails/AppointmentCancelled.svelte index 3052fd1..df8ea47 100644 --- a/src/lib/emails/AppointmentCancelled.svelte +++ b/src/lib/emails/AppointmentCancelled.svelte @@ -45,8 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte index 9df32f8..a2b496a 100644 --- a/src/lib/emails/AppointmentRejected.svelte +++ b/src/lib/emails/AppointmentRejected.svelte @@ -45,8 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte index 0d51729..ca17a30 100644 --- a/src/lib/emails/AppointmentReminder.svelte +++ b/src/lib/emails/AppointmentReminder.svelte @@ -48,8 +48,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte index dcc96a0..404be16 100644 --- a/src/lib/emails/AppointmentRequest.svelte +++ b/src/lib/emails/AppointmentRequest.svelte @@ -45,8 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/utils.ts b/src/lib/emails/utils.ts index 73f4154..768f285 100644 --- a/src/lib/emails/utils.ts +++ b/src/lib/emails/utils.ts @@ -1,6 +1,4 @@ import type { SupportedLocale } from "$lib/const/locales"; -import { format, type Locale } from "date-fns"; -import { de, enUS } from "date-fns/locale"; // is not exported from svelte export type RenderOutput = { @@ -68,15 +66,29 @@ export const htmlToText = (html: string) => { return text; }; -const localeMap: { [key: string]: Locale } = { - en: enUS, - de: de, +export const renderAppointmentDate = (date: Date, locale: SupportedLocale, timezone: string) => { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric", + timeZone: timezone, + }).format(date); }; -export const renderAppointmentDate = (date: Date, locale: SupportedLocale) => { - return format(date, "PPP", { locale: localeMap[locale] as unknown as Locale }); -}; +export const renderAppointmentTime = (date: Date, locale: SupportedLocale, timezone: string) => { + const time = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: timezone, + }).format(date); -export const renderAppointmentTime = (date: Date, locale: SupportedLocale) => { - return format(date, "p", { locale: localeMap[locale] as unknown as Locale }); + const timeZoneName = + new Intl.DateTimeFormat(locale, { + timeZone: timezone, + timeZoneName: "long", + }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value ?? timezone; + + return `${time}, ${timeZoneName}`; }; diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 64eaf7a..2f345bb 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -171,6 +171,8 @@ export const appointment = pgTable("appointment", { appointmentDate: timestamp("appointment_date").notNull(), /** Duration of the appointment in minutes */ duration: integer("duration").notNull(), + /** Timezone of client booking appointment */ + timezone: text("timezone").notNull(), /** When appointment data expires and can be auto-deleted */ expiryDate: date("expiry_date"), /** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */ diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 5a03571..e0be8a6 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -24,6 +24,7 @@ export interface ClientTunnelData { channelId: string; agentId: string; appointmentDate: string; + appointmentTimeZone: string; duration: number; emailHash: string; clientEmail?: string; @@ -500,6 +501,7 @@ export class AppointmentService { channelId: string; agentId: string; appointmentDate: string; + appointmentTimeZone: string; duration: number; clientEmail: string; clientLanguage?: string; @@ -567,6 +569,7 @@ export class AppointmentService { channelId: appointmentData.channelId, agentId: appointmentData.agentId, appointmentDate: new Date(appointmentData.appointmentDate), + timezone: appointmentData.appointmentTimeZone, duration: appointmentData.duration, encryptedPayload: appointmentData.encryptedAppointment.encryptedPayload, iv: appointmentData.encryptedAppointment.iv, @@ -588,6 +591,7 @@ export class AppointmentService { const response: AppointmentResponse = { id: result.id, appointmentDate: result.appointmentDate.toISOString(), + appointmentTimeZone: appointmentData.appointmentTimeZone, status: result.status, requiresConfirmation, }; @@ -716,6 +720,7 @@ export class AppointmentService { channelId: clientData.channelId, agentId: clientData.agentId, appointmentDate: new Date(clientData.appointmentDate), + timezone: clientData.appointmentTimeZone, duration: clientData.duration, encryptedPayload: clientData.encryptedAppointment.encryptedPayload, iv: clientData.encryptedAppointment.iv, @@ -725,6 +730,7 @@ export class AppointmentService { .returning({ id: tenantSchema.appointment.id, appointmentDate: tenantSchema.appointment.appointmentDate, + timezone: tenantSchema.appointment.timezone, status: tenantSchema.appointment.status, tunnelId: tenantSchema.appointment.tunnelId, channelId: tenantSchema.appointment.channelId, @@ -758,6 +764,7 @@ export class AppointmentService { const response: AppointmentResponse = { id: result.appointment.id, appointmentDate: result.appointment.appointmentDate.toISOString(), + appointmentTimeZone: result.appointment.timezone, status: result.appointment.status, requiresConfirmation: result.requiresConfirmation, }; diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index 0539dc0..44ed87d 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -91,6 +91,7 @@ export interface AddAppointmentToTunnelRequest { emailHash: string; tunnelId: string; appointmentDate: string; + appointmentTimeZone: string; encryptedAppointment: EncryptedAppointmentData; } @@ -132,6 +133,7 @@ export interface ClientAppointmentsTunnel { export interface AppointmentResponse { id: string; appointmentDate: string; + appointmentTimeZone: string; status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW"; requiresConfirmation?: boolean; } diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index 5259796..615dd2f 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -27,6 +27,7 @@ const requestSchema = z.object({ channelId: z.string(), agentId: z.string(), appointmentDate: z.string(), + appointmentTimeZone: z.string(), duration: z.number().int().positive(), clientEmail: z.email().optional(), clientLanguage: z.string().optional().default("en"), @@ -254,6 +255,7 @@ export const POST: RequestHandler = async ({ request, params }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: new Date(validatedData.appointmentDate), + timezone: validatedData.appointmentTimeZone, duration: validatedData.duration, encryptedPayload: validatedData.encryptedAppointment.encryptedPayload, iv: validatedData.encryptedAppointment.iv, @@ -266,6 +268,7 @@ export const POST: RequestHandler = async ({ request, params }) => { channelId: appointment.channelId, agentId: appointment.agentId, appointmentDate: appointment.appointmentDate, + timezone: appointment.timezone, expiryDate: appointment.expiryDate, status: appointment.status, encryptedPayload: appointment.encryptedPayload, @@ -287,6 +290,7 @@ export const POST: RequestHandler = async ({ request, params }) => { const response: AppointmentResponse = { id: result.id, appointmentDate: result.appointmentDate.toISOString(), + appointmentTimeZone: result.timezone, status: result.status, }; diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index 9061d68..5d89eff 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -22,6 +22,7 @@ const requestSchema = z.object({ channelId: z.string(), agentId: z.string(), appointmentDate: z.string(), + appointmentTimeZone: z.string(), duration: z.number().int().positive(), emailHash: z.string(), clientEmail: z.email().optional(), @@ -136,6 +137,11 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { format: "date-time", description: "Appointment date and time (ISO 8601)", }, + appointmentTimezone: { + type: "string", + format: "IANA Timezone", + description: "Time zone of the appointment (e.g. Europe/Berlin)", + }, emailHash: { type: "string", description: "SHA-256 hash of client email", diff --git a/tenant-migrations/0014_fluffy_ezekiel.sql b/tenant-migrations/0014_fluffy_ezekiel.sql new file mode 100644 index 0000000..870ed9c --- /dev/null +++ b/tenant-migrations/0014_fluffy_ezekiel.sql @@ -0,0 +1,2 @@ +ALTER TABLE "appointment" ADD COLUMN "timezone" text NOT NULL DEFAULT 'UTC'; +ALTER TABLE "appointment" ALTER COLUMN "timezone" DROP DEFAULT; \ No newline at end of file diff --git a/tenant-migrations/meta/0014_snapshot.json b/tenant-migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..2b2e839 --- /dev/null +++ b/tenant-migrations/meta/0014_snapshot.json @@ -0,0 +1,1031 @@ +{ + "id": "7e551bff-abaf-48b5-bb08-0ba14cb3292d", + "prevId": "726f8502-95ab-458f-83e5-9897a8b46a03", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": [ + "tunnel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_access_token": { + "name": "booking_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": [ + "slot_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_staff": { + "name": "channel_staff", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_staff_channel_id_channel_id_fk": { + "name": "channel_staff_channel_id_channel_id_fk", + "tableFrom": "channel_staff", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pin_reset_token": { + "name": "client_pin_reset_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_pin_reset_token_token_unique": { + "name": "client_pin_reset_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "client_tunnel_staff_key_share", + "tableTo": "client_appointment_tunnel", + "columnsFrom": [ + "tunnel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'APPOINTMENT_CONFIRMED'" + }, + "meta_data": { + "name": "meta_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "NEW", + "CONFIRMED", + "HELD", + "REJECTED", + "NO_SHOW" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "APPOINTMENT_CONFIRMED", + "APPOINTMENT_CANCELLED", + "APPOINTMENT_REQUESTED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 312ab27..5b44ca7 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1773231146366, "tag": "0013_youthful_grim_reaper", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1775654963847, + "tag": "0014_fluffy_ezekiel", + "breakpoints": true } ] -} +} \ No newline at end of file From d51de0fbd0b7b0c9f79644592c80528aa1a00c97 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 16 Apr 2026 16:59:58 +0200 Subject: [PATCH 16/21] Fix adding appointments in calendar --- src/lib/client/appointment-crypto.ts | 2 ++ .../api/tenants/[id]/appointments/staff-create/+server.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 6483f5d..75d8101 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -596,12 +596,14 @@ export class UnifiedAppointmentCrypto { }; // Call endpoint + const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const sendEmail = params.appointmentData.shareEmail && Boolean(params.email); const requestData = { clientEmail: params.appointmentData.shareEmail ? usedEmail : undefined, hasNoEmail: params.hasNoEmail, emailHash: await hashEmail(usedEmail), appointmentDate: params.appointmentDate.toISOString(), + appointmentTimeZone, duration: params.duration, agentId: params.agentId, channelId: params.channelId, diff --git a/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts index e31dbe7..fe85812 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts @@ -16,6 +16,7 @@ const requestSchema = z // Appointment details appointmentDate: z.string(), + appointmentTimeZone: z.string(), duration: z.number().int().positive(), channelId: z.string(), agentId: z.string(), @@ -315,6 +316,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: validatedData.appointmentDate, + appointmentTimeZone: validatedData.appointmentTimeZone, duration: validatedData.duration, clientEmail: validatedData.clientEmail || "", clientLanguage: validatedData.clientLanguage, @@ -349,6 +351,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: validatedData.appointmentDate, + appointmentTimeZone: validatedData.appointmentTimeZone, duration: validatedData.duration, emailHash: validatedData.emailHash, clientEmail: validatedData.clientEmail || "", From 39bb66ec787f39fc921c0580baef36d3de18eaad Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 17 Apr 2026 16:39:48 +0200 Subject: [PATCH 17/21] Fix display times and appointment time when added using calendar --- docs/datetime.md | 8 ++-- .../components/notification-item.svelte | 15 ++++--- .../ui/public/appointment-card.svelte | 10 +---- src/lib/emails/AppointmentBooked.svelte | 1 - src/lib/emails/AppointmentCancelled.svelte | 1 - src/lib/emails/AppointmentRejected.svelte | 1 - src/lib/emails/AppointmentReminder.svelte | 1 - src/lib/emails/AppointmentRequest.svelte | 1 - src/lib/utils/datetime.ts | 39 ++++++++++++++++--- .../[[id]]/(components)/select-slot.svelte | 14 ++++--- .../[[id]]/(components)/summary.svelte | 3 +- .../(components)/appointment-list.svelte | 10 +---- .../(components)/AppointmentDetail.svelte | 17 ++------ .../calendar/(components)/CalendarDay.svelte | 2 + .../add-appointment/AddAppointment.svelte | 6 ++- .../add-appointment/Summary.svelte | 11 +----- .../dashboard/calendar/(components)/utils.ts | 14 ++++++- .../(pages)/dashboard/calendar/+page.svelte | 4 +- 18 files changed, 86 insertions(+), 72 deletions(-) diff --git a/docs/datetime.md b/docs/datetime.md index 62e85c7..f479b2d 100644 --- a/docs/datetime.md +++ b/docs/datetime.md @@ -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. 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 b8f6242..f629082 100644 --- a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte @@ -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", + })}
{/if} diff --git a/src/lib/components/ui/public/appointment-card.svelte b/src/lib/components/ui/public/appointment-card.svelte index 8515fbe..bdf1f15 100644 --- a/src/lib/components/ui/public/appointment-card.svelte +++ b/src/lib/components/ui/public/appointment-card.svelte @@ -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 @@
- {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"))}
{/if} diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte index b683f98..93e20fc 100644 --- a/src/lib/emails/AppointmentBooked.svelte +++ b/src/lib/emails/AppointmentBooked.svelte @@ -50,7 +50,6 @@ {appointment.agentName}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} - {m["emails.oclock"]()} {tenant.longName} diff --git a/src/lib/emails/AppointmentCancelled.svelte b/src/lib/emails/AppointmentCancelled.svelte index df8ea47..f5e884f 100644 --- a/src/lib/emails/AppointmentCancelled.svelte +++ b/src/lib/emails/AppointmentCancelled.svelte @@ -47,7 +47,6 @@ {appointment.agentName}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} - {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte index a2b496a..106060e 100644 --- a/src/lib/emails/AppointmentRejected.svelte +++ b/src/lib/emails/AppointmentRejected.svelte @@ -47,7 +47,6 @@ {appointment.agentName}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} - {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte index ca17a30..1db9bb8 100644 --- a/src/lib/emails/AppointmentReminder.svelte +++ b/src/lib/emails/AppointmentReminder.svelte @@ -50,7 +50,6 @@ {appointment.agentName}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} - {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte index 404be16..da948ac 100644 --- a/src/lib/emails/AppointmentRequest.svelte +++ b/src/lib/emails/AppointmentRequest.svelte @@ -47,7 +47,6 @@ {appointment.agentName}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)} - {m["emails.oclock"]()}
{tenant.longName} diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index d420cc3..2e04464 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -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); +}; 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 3aeb841..96a4b9d 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 @@ -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, }, }); diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index 4cd2985..24b2ba4 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -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, diff --git a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte index 41a7da0..4564a28 100644 --- a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte +++ b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte @@ -82,15 +82,7 @@ {/if} - {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))} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte index 0cc0c92..71df577 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -1,13 +1,12 @@