From 14f60e3580c17473ef938cf122cfb836d428d2c7 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Mon, 27 Apr 2026 10:46:31 +0100 Subject: [PATCH 1/2] fix: convert appointment time from UTC to local time for display Closes #393 Appointment times were stored as UTC strings by the API but displayed without timezone conversion, causing times to appear 2 hours early for users in CEST (UTC+2). Changes: - parseTime in AccompanyingDetails helpers now converts UTC HH:mm strings to local time using setUTCHours before extracting via toTimeString - formatAccompanyingDate in OpportunityCard.helpers now formats the date with date-fns and applies the same UTC-to-local conversion for the time --- .../Opportunities/OpportunityCard.helpers.tsx | 25 +++++++++++++++++++ .../sections/AccompanyingDetails/helpers.ts | 10 +++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx index 9423549d..be77bab8 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx @@ -1,4 +1,5 @@ import { ConfettiIcon, PersonSimpleWalkIcon, ShootingStarIcon, TranslateIcon } from "@phosphor-icons/react"; +import { format } from "date-fns"; import { ApiVolunteerOpportunityGetList, OpportunityStatusType, ProfileVolunteeringType } from "need4deed-sdk"; import { JSX } from "react"; @@ -9,6 +10,30 @@ export function formatAvailability(availability: ApiVolunteerOpportunityGetList[ return parts.join(", "); } +export function formatAccompanyingDate(details?: { + appointmentDate?: string; + appointmentTime?: string; +}): string | null { + if (!details?.appointmentDate) return null; + + const date = new Date(details.appointmentDate); + const formattedDate = isNaN(date.getTime()) ? details.appointmentDate : format(date, "dd.MM.yyyy"); + + let formattedTime: string | null = null; + if (details.appointmentTime) { + const [h, m] = details.appointmentTime.split(":").map(Number); + if (!isNaN(h) && !isNaN(m)) { + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + formattedTime = d.toTimeString().slice(0, 5); + } else { + formattedTime = details.appointmentTime; + } + } + + return [formattedDate, formattedTime].filter(Boolean).join(" "); +} + export const statusColorMap: Record = { [OpportunityStatusType.NEW]: "var(--color-red-500)", [OpportunityStatusType.SEARCHING]: "var(--color-orange-500, var(--color-red-500))", diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 26c76c85..b8689e5b 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -30,7 +30,15 @@ export const parseDate = (date: Date | string | undefined): Date | null => { export const parseTime = (time: Date | string | undefined): string => { if (!time) return ""; - if (typeof time === "string") return time; + if (typeof time === "string") { + const [h, m] = time.split(":").map(Number); + if (!isNaN(h) && !isNaN(m)) { + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + return d.toTimeString().slice(0, 5); + } + return time; + } return time.toTimeString().slice(0, 5); }; From 1f7a925fcf145c4426ef276fe509e6a3a32829cf Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Tue, 28 Apr 2026 09:03:54 +0100 Subject: [PATCH 2/2] Address review feedback: fix round-trip time corruption and extract shared utility - Extract utcHhmmToLocal() into src/utils to avoid duplicated conversion logic - Revert parseTime to a passthrough so form state keeps the raw UTC string - Apply UTC-to-local conversion only in AccompanyingDetailsDisplay and OpportunityCard.helpers (display-only paths), preventing time drift on save - Use padStart formatting instead of toTimeString().slice(0, 5) --- .../Opportunities/OpportunityCard.helpers.tsx | 13 ++----------- .../AccompanyingDetailsDisplay.tsx | 3 ++- .../Profile/sections/AccompanyingDetails/helpers.ts | 12 ++---------- src/utils/index.ts | 12 ++++++++++++ 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx index be77bab8..c6e3d69e 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx @@ -1,4 +1,5 @@ import { ConfettiIcon, PersonSimpleWalkIcon, ShootingStarIcon, TranslateIcon } from "@phosphor-icons/react"; +import { utcHhmmToLocal } from "@/utils"; import { format } from "date-fns"; import { ApiVolunteerOpportunityGetList, OpportunityStatusType, ProfileVolunteeringType } from "need4deed-sdk"; import { JSX } from "react"; @@ -19,17 +20,7 @@ export function formatAccompanyingDate(details?: { const date = new Date(details.appointmentDate); const formattedDate = isNaN(date.getTime()) ? details.appointmentDate : format(date, "dd.MM.yyyy"); - let formattedTime: string | null = null; - if (details.appointmentTime) { - const [h, m] = details.appointmentTime.split(":").map(Number); - if (!isNaN(h) && !isNaN(m)) { - const d = new Date(); - d.setUTCHours(h, m, 0, 0); - formattedTime = d.toTimeString().slice(0, 5); - } else { - formattedTime = details.appointmentTime; - } - } + const formattedTime = details.appointmentTime ? utcHhmmToLocal(details.appointmentTime) : null; return [formattedDate, formattedTime].filter(Boolean).join(" "); } diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index 64e9d9fd..e3155d1f 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -1,5 +1,6 @@ import { EditableField } from "@/components/EditableField/EditableField"; import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants"; +import { utcHhmmToLocal } from "@/utils"; import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { AccompanyingDetailsFormData } from "./accompanyingDetailsSchema"; @@ -30,7 +31,7 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => - {values.appointmentTime || EMPTY_PLACEHOLDER_VALUE} + {values.appointmentTime ? utcHhmmToLocal(values.appointmentTime) : EMPTY_PLACEHOLDER_VALUE} { export const parseTime = (time: Date | string | undefined): string => { if (!time) return ""; - if (typeof time === "string") { - const [h, m] = time.split(":").map(Number); - if (!isNaN(h) && !isNaN(m)) { - const d = new Date(); - d.setUTCHours(h, m, 0, 0); - return d.toTimeString().slice(0, 5); - } - return time; - } - return time.toTimeString().slice(0, 5); + if (typeof time === "string") return time; + return String(time.getHours()).padStart(2, "0") + ":" + String(time.getMinutes()).padStart(2, "0"); }; export const getInitialFormValues = ( diff --git a/src/utils/index.ts b/src/utils/index.ts index 82b7de1b..56bf8cd9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,18 @@ export * from "./helpers"; import { cloudfrontURL } from "@/config/constants"; import { DocumentStatusType } from "need4deed-sdk"; +/** + * Converts a UTC "HH:mm" string to the browser's local time "HH:mm" string. + * Returns the original value unchanged if it cannot be parsed. + */ +export function utcHhmmToLocal(hhmm: string): string { + const [h, m] = hhmm.split(":").map(Number); + if (isNaN(h) || isNaN(m)) return hhmm; + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); +} + export function getDateLocalTooUTC(dateStr: string | undefined) { if (!dateStr) return undefined;