From daa5a21eebf70b95711affeea8c1f7a9aaf788e1 Mon Sep 17 00:00:00 2001 From: lukaskett Date: Thu, 23 Apr 2026 23:12:30 +0200 Subject: [PATCH 1/4] feat: merge date and zerotime into single field, keep iso8601 approach, also removes ambiguity of the date field, which when converted to local date-time might end up in the day before, #165 --- apps/client/src/hooks/useEvent.ts | 1 - apps/client/src/pages/Event/EventInfoView.tsx | 4 +- .../Event/Settings/EventExternalLinkCard.tsx | 3 +- .../Settings/EventPublishingScheduleCard.tsx | 5 +-- .../Event/Settings/EventSettingsPage.tsx | 9 +++-- apps/client/src/types/event.ts | 1 - .../migration.sql | 7 ++++ apps/server/prisma/schema.prisma | 3 +- apps/server/src/graphql/event/index.ts | 3 -- apps/server/src/graphql/event/schema.ts | 3 +- .../__tests__/event.status.service.test.ts | 9 ++--- .../modules/event/event.public.handlers.ts | 15 +------ .../modules/event/event.secure.handlers.ts | 37 ++++++------------ .../src/modules/event/event.status.service.ts | 39 ++----------------- apps/server/src/modules/user/user.service.ts | 1 - packages/shared/src/models/event.ts | 3 -- pnpm-lock.yaml | 15 +++++++ 17 files changed, 52 insertions(+), 106 deletions(-) create mode 100644 apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql diff --git a/apps/client/src/hooks/useEvent.ts b/apps/client/src/hooks/useEvent.ts index 70a894b..267287e 100644 --- a/apps/client/src/hooks/useEvent.ts +++ b/apps/client/src/hooks/useEvent.ts @@ -19,7 +19,6 @@ const GET_EVENT = gql` } date timezone - zeroTime discipline externalSource externalEventId diff --git a/apps/client/src/pages/Event/EventInfoView.tsx b/apps/client/src/pages/Event/EventInfoView.tsx index 3285e11..ce17522 100644 --- a/apps/client/src/pages/Event/EventInfoView.tsx +++ b/apps/client/src/pages/Event/EventInfoView.tsx @@ -156,11 +156,11 @@ export function EventInfoView({ event }: EventInfoViewProps) {
{formatDate(event.date, localeKey)}
- {event.zeroTime && ( + {event.date && (
{t('Pages.Event.Detail.ZeroTime')}:{' '} {formatStoredUtcTimeForTimezone( - event.zeroTime, + new Date(event.date).toISOString().slice(11, 19), event.date, event.timezone || 'UTC' )} diff --git a/apps/client/src/pages/Event/Settings/EventExternalLinkCard.tsx b/apps/client/src/pages/Event/Settings/EventExternalLinkCard.tsx index 5ab7c85..475c01a 100644 --- a/apps/client/src/pages/Event/Settings/EventExternalLinkCard.tsx +++ b/apps/client/src/pages/Event/Settings/EventExternalLinkCard.tsx @@ -366,8 +366,7 @@ export const EventExternalLinkCard: React.FC = ({ !date || !timezone || !organizer || - !location || - !zeroTime + !location ) { toast({ title: t('Operations.Error', { ns: 'common' }), diff --git a/apps/client/src/pages/Event/Settings/EventPublishingScheduleCard.tsx b/apps/client/src/pages/Event/Settings/EventPublishingScheduleCard.tsx index 2e57838..7157772 100644 --- a/apps/client/src/pages/Event/Settings/EventPublishingScheduleCard.tsx +++ b/apps/client/src/pages/Event/Settings/EventPublishingScheduleCard.tsx @@ -130,8 +130,7 @@ export const EventPublishingScheduleCard: React.FC< !eventData.date || !eventData.timezone || !eventData.organizer || - !eventData.location || - !eventData.zeroTime + !eventData.location ) { toast({ title: t('Operations.Error', { ns: 'common' }), @@ -157,7 +156,7 @@ export const EventPublishingScheduleCard: React.FC< latitude: eventData.latitude, longitude: eventData.longitude, countryCode: eventData.country?.countryCode || undefined, - zeroTime: eventData.zeroTime, + zeroTime: new Date(eventData.date).toISOString().slice(11, 19), discipline: eventData.discipline, ranking: eventData.ranking, coefRanking: eventData.coefRanking, diff --git a/apps/client/src/pages/Event/Settings/EventSettingsPage.tsx b/apps/client/src/pages/Event/Settings/EventSettingsPage.tsx index a05a8de..62e7a19 100644 --- a/apps/client/src/pages/Event/Settings/EventSettingsPage.tsx +++ b/apps/client/src/pages/Event/Settings/EventSettingsPage.tsx @@ -1,5 +1,5 @@ import { config } from '@/config'; -import { formatDate, formatDateForInput } from '@/lib/utils'; +import { formatDate } from '@/lib/utils'; import { gql } from '@apollo/client'; import { useQuery } from '@apollo/client/react'; import { useParams } from '@tanstack/react-router'; @@ -42,7 +42,6 @@ export const GET_EVENT = gql` sportId date timezone - zeroTime discipline externalSource externalEventId @@ -110,14 +109,16 @@ export const EventSettingsPage = () => { id: data.event.id, name: data.event.name, sportId: data.event.sportId, - date: formatDateForInput(data.event.date), + date: data.event.date ? data.event.date.slice(0, 10) : '', timezone: data.event.timezone || 'Europe/Prague', organizer: data.event.organizer, location: data.event.location, latitude: data.event.latitude, longitude: data.event.longitude, countryCode: data.event.country?.countryCode || '', - zeroTime: data.event.zeroTime ?? '', + zeroTime: data.event.date + ? new Date(data.event.date).toISOString().slice(11, 19) + : '', discipline: data.event.discipline, ranking: data.event.ranking || false, coefRanking: data.event.coefRanking, diff --git a/apps/client/src/types/event.ts b/apps/client/src/types/event.ts index 787a01d..5ea98c2 100644 --- a/apps/client/src/types/event.ts +++ b/apps/client/src/types/event.ts @@ -80,7 +80,6 @@ export interface Event { sportId: number; sport: EventSport; discipline: EventDiscipline; - zeroTime?: string; // UTC time-of-day (HH:mm:ss) timezone?: string; // IANA timezone (e.g., 'Europe/Prague', 'America/New_York') externalSource?: 'ORIS' | 'EVENTOR'; externalEventId?: string; diff --git a/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql b/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql new file mode 100644 index 0000000..6c2d089 --- /dev/null +++ b/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql @@ -0,0 +1,7 @@ +-- Merge separate date (DATE) and zeroTime (TIME) columns into a single DATETIME column. +-- The new `date` stores the UTC event start timestamp. +ALTER TABLE `event` ADD COLUMN `date_tmp` DATETIME(3) NOT NULL DEFAULT '1970-01-01 00:00:00.000'; +UPDATE `event` SET `date_tmp` = TIMESTAMP(DATE(`date`), `zeroTime`); +ALTER TABLE `event` DROP COLUMN `zeroTime`; +ALTER TABLE `event` DROP COLUMN `date`; +ALTER TABLE `event` CHANGE `date_tmp` `date` DATETIME(3) NOT NULL; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 38ebf90..331ccb9 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -124,7 +124,7 @@ model Event { sportId Int @db.UnsignedInt name String organizer String? - date DateTime @db.Date + date DateTime timezone String @default("Europe/Prague") location String? latitude Float? // GPS Latitude (nullable) @@ -134,7 +134,6 @@ model Event { externalSource ExternalSource? externalEventId String? @db.VarChar(128) featuredImageKey String? @db.VarChar(512) - zeroTime DateTime @db.Time(0) relay Boolean @default(false) discipline EventDiscipline @default(OTHER) startMode StartMode @default(Individual) diff --git a/apps/server/src/graphql/event/index.ts b/apps/server/src/graphql/event/index.ts index ecfa437..c25cabe 100644 --- a/apps/server/src/graphql/event/index.ts +++ b/apps/server/src/graphql/event/index.ts @@ -8,8 +8,6 @@ import { getEventStatusSummary } from '../../modules/event/event.status.service. import { getDecryptedEventPassword } from '../../modules/event/event.service.js'; import { requireEventOwnerOrAdmin } from '../../utils/authz.js'; import prisma from '../../utils/context.js'; -import { normalizeUtcTimeString } from '../../utils/time.js'; - export { resolvers, typeDef }; const buildPublicImageUrl = (key, eventId) => { @@ -58,7 +56,6 @@ const resolvers = { } return decryptedPassword; }, - zeroTime: (parent) => normalizeUtcTimeString(parent.zeroTime) ?? '00:00:00', featuredImage: (parent) => buildPublicImageUrl(parent.featuredImageKey, parent.id), statusSummary: (parent) => getEventStatusSummary(prisma, parent), }, diff --git a/apps/server/src/graphql/event/schema.ts b/apps/server/src/graphql/event/schema.ts index 4cf634e..d4adc33 100644 --- a/apps/server/src/graphql/event/schema.ts +++ b/apps/server/src/graphql/event/schema.ts @@ -126,14 +126,13 @@ export const typeDef = /* GraphQL */ ` sportId: Int! name: String! organizer: String - date: Date! + date: DateTime! timezone: String! externalSource: ExternalEventProvider externalEventId: String location: String latitude: Float longitude: Float - zeroTime: String! relay: Boolean! discipline: EventDiscipline! ranking: Boolean! diff --git a/apps/server/src/modules/event/__tests__/event.status.service.test.ts b/apps/server/src/modules/event/__tests__/event.status.service.test.ts index 1b89865..0cc8f2e 100644 --- a/apps/server/src/modules/event/__tests__/event.status.service.test.ts +++ b/apps/server/src/modules/event/__tests__/event.status.service.test.ts @@ -6,18 +6,16 @@ describe('event.status.service', () => { it('keeps same-day events upcoming until zero time in the event timezone', () => { const beforeStart = computeEventStatusSummary({ published: true, - date: new Date('2026-04-12T00:00:00.000Z'), + date: new Date('2026-04-12T08:00:00.000Z'), timezone: 'Europe/Prague', - zeroTime: '08:00:00', hasResultData: false, now: new Date('2026-04-12T07:59:00.000Z'), }); const atStart = computeEventStatusSummary({ published: true, - date: new Date('2026-04-12T00:00:00.000Z'), + date: new Date('2026-04-12T08:00:00.000Z'), timezone: 'Europe/Prague', - zeroTime: '08:00:00', hasResultData: false, now: new Date('2026-04-12T08:00:00.000Z'), }); @@ -63,9 +61,8 @@ describe('event.status.service', () => { it('shows the primary status as done when final results are available', () => { const summary = computeEventStatusSummary({ published: true, - date: new Date('2026-04-12T00:00:00.000Z'), + date: new Date('2026-04-12T10:00:00.000Z'), timezone: 'Europe/Prague', - zeroTime: '10:00:00', hasResultData: true, resultsOfficialAt: new Date('2026-04-12T08:30:00.000Z'), now: new Date('2026-04-12T08:35:00.000Z'), diff --git a/apps/server/src/modules/event/event.public.handlers.ts b/apps/server/src/modules/event/event.public.handlers.ts index 3f9988c..6e2374a 100644 --- a/apps/server/src/modules/event/event.public.handlers.ts +++ b/apps/server/src/modules/event/event.public.handlers.ts @@ -1,7 +1,6 @@ import { z } from '@hono/zod-openapi'; import prisma from '../../utils/context.js'; -import { normalizeUtcTimeString } from '../../utils/time.js'; import { getPublicObject } from '../../lib/storage/s3.js'; import { error, success, validation } from '../../utils/responseApi.js'; import { calculateCzechRankingPointsForEvent } from '../../utils/czech-ranking.js'; @@ -166,7 +165,6 @@ export function registerPublicEventRoutes(router) { ranking: true, coefRanking: true, sport: true, - zeroTime: true, hundredthPrecision: true, classes: true, }, @@ -189,19 +187,8 @@ export function registerPublicEventRoutes(router) { ); } - const zeroTime = normalizeUtcTimeString(dbResponse.zeroTime); - return c.json( - success( - 'OK', - { - data: { - ...dbResponse, - zeroTime, - }, - }, - 200, - ), + success('OK', { data: dbResponse }, 200), 200, ); }); diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts index 5105500..047d5d7 100644 --- a/apps/server/src/modules/event/event.secure.handlers.ts +++ b/apps/server/src/modules/event/event.secure.handlers.ts @@ -27,7 +27,7 @@ import { } from '../../utils/authz.js'; import { createCompetitorSchema, updateCompetitorSchema } from '../../utils/validateCompetitor.js'; import eventWriteSchema from '../../utils/validateEvent.js'; -import { normalizeUtcTimeString, toPrismaTimeDate } from '../../utils/time.js'; +import { normalizeUtcTimeString } from '../../utils/time.js'; import { encodeBase64, encrypt } from '../../lib/crypto/encryption.js'; import { formatErrors } from '../../utils/errors.js'; import { @@ -748,7 +748,6 @@ export function registerSecureEventRoutes(router) { // Everything went fine. try { - const dateTime = new Date(date); const normalizedZeroTime = normalizeUtcTimeString(zeroTime); const parsedEntriesOpenAt = parseOptionalIsoDateTime(entriesOpenAt); const parsedEntriesCloseAt = parseOptionalIsoDateTime(entriesCloseAt); @@ -761,17 +760,19 @@ export function registerSecureEventRoutes(router) { throw new ValidationError('Invalid zero time. Expected HH:mm or HH:mm:ss.'); } + const datePart = new Date(date).toISOString().slice(0, 10); + const eventDateTime = new Date(`${datePart}T${normalizedZeroTime}Z`); + const createdEvent = await appPrisma.event.create({ data: { name, - date: dateTime, + date: eventDateTime, timezone, organizer, location, latitude, longitude, countryId: countryCode, - zeroTime: toPrismaTimeDate(normalizedZeroTime), ranking, coefRanking, discipline, @@ -806,16 +807,7 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse( - 'OK', - { - data: { - ...createdEvent, - zeroTime: normalizeUtcTimeString(createdEvent.zeroTime), - }, - }, - res.statusCode, - ), + successResponse('OK', { data: createdEvent }, res.statusCode), ); } catch (error) { if (error instanceof ValidationError) { @@ -1118,6 +1110,9 @@ export function registerSecureEventRoutes(router) { throw new ValidationError('Invalid zero time. Expected HH:mm or HH:mm:ss.'); } + const datePart = new Date(date).toISOString().slice(0, 10); + const eventDateTime = new Date(`${datePart}T${normalizedZeroTime}Z`); + // TODO: Add permission checks to ensure the user is allowed to edit the event // 🔥 Normalize latitude and longitude @@ -1141,14 +1136,13 @@ export function registerSecureEventRoutes(router) { where: { id: eventId }, data: { name, - date: new Date(date), + date: eventDateTime, timezone, organizer, location, latitude: dbLatitude, longitude: dbLongitude, countryId: countryCode ?? country, - zeroTime: toPrismaTimeDate(normalizedZeroTime), ranking, coefRanking, discipline, @@ -1188,16 +1182,7 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse( - 'OK', - { - data: { - ...updatedEvent, - zeroTime: normalizeUtcTimeString(updatedEvent.zeroTime), - }, - }, - res.statusCode, - ), + successResponse('OK', { data: updatedEvent }, res.statusCode), ); } catch (error) { if (error instanceof ValidationError) { diff --git a/apps/server/src/modules/event/event.status.service.ts b/apps/server/src/modules/event/event.status.service.ts index ee7c0eb..a720b12 100644 --- a/apps/server/src/modules/event/event.status.service.ts +++ b/apps/server/src/modules/event/event.status.service.ts @@ -1,6 +1,5 @@ import type { ExternalSource } from '../../generated/prisma/client.js'; import type { AppPrismaClient } from '../../db/prisma-client.js'; -import { normalizeUtcTimeString } from '../../utils/time.js'; export type EventLifecycleStatus = 'DRAFT' | 'UPCOMING' | 'LIVE' | 'DONE'; export type EventPrimaryStatus = EventLifecycleStatus; @@ -24,7 +23,6 @@ type EventStatusComputationInput = { published: boolean; date: Date; timezone: string; - zeroTime?: string | Date | null; entriesOpenAt?: Date | null; entriesCloseAt?: Date | null; resultsOfficialAt?: Date | null; @@ -41,7 +39,6 @@ type StatusSummaryEvent = { published: boolean; date: Date; timezone: string; - zeroTime: Date | null; entriesOpenAt: Date | null; entriesCloseAt: Date | null; resultsOfficialAt: Date | null; @@ -91,36 +88,6 @@ function getStoredEventDateKey(date: Date): string { return date.toISOString().slice(0, 10); } -function resolveEventStartAt( - eventDate: Date, - zeroTime: string | Date | null | undefined, - timeZone: string, -): Date | null { - const normalizedZeroTime = normalizeUtcTimeString(zeroTime); - if (!normalizedZeroTime) { - return null; - } - - const eventDateKey = getStoredEventDateKey(eventDate); - const candidate = new Date(`${eventDateKey}T${normalizedZeroTime}Z`); - - if (!Number.isFinite(candidate.getTime())) { - return null; - } - - const candidateDateKeyInTimeZone = - getDateKeyInTimeZone(candidate, timeZone) ?? getStoredEventDateKey(candidate); - - if (candidateDateKeyInTimeZone < eventDateKey) { - return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); - } - - if (candidateDateKeyInTimeZone > eventDateKey) { - return new Date(candidate.getTime() - 24 * 60 * 60 * 1000); - } - - return candidate; -} export function buildOfficialResultsUrl( externalSource: ExternalSource | null | undefined, @@ -141,7 +108,8 @@ export function buildOfficialResultsUrl( export function computeEventStatusSummary(input: EventStatusComputationInput): EventStatusSummary { const now = input.now ?? new Date(); - const eventDateKey = getStoredEventDateKey(input.date); + const eventDateKey = + getDateKeyInTimeZone(input.date, input.timezone) ?? getStoredEventDateKey(input.date); const nowDateKey = getDateKeyInTimeZone(now, input.timezone) ?? getStoredEventDateKey(now); let lifecycle: EventLifecycleStatus; @@ -150,8 +118,7 @@ export function computeEventStatusSummary(input: EventStatusComputationInput): E } else if (nowDateKey < eventDateKey) { lifecycle = 'UPCOMING'; } else if (nowDateKey === eventDateKey) { - const eventStartAt = resolveEventStartAt(input.date, input.zeroTime, input.timezone); - lifecycle = eventStartAt && now < eventStartAt ? 'UPCOMING' : 'LIVE'; + lifecycle = now < input.date ? 'UPCOMING' : 'LIVE'; } else { lifecycle = 'DONE'; } diff --git a/apps/server/src/modules/user/user.service.ts b/apps/server/src/modules/user/user.service.ts index c8c4437..5642df9 100644 --- a/apps/server/src/modules/user/user.service.ts +++ b/apps/server/src/modules/user/user.service.ts @@ -14,7 +14,6 @@ export async function listMyEvents(userId: number | string) { relay: true, published: true, timezone: true, - zeroTime: true, entriesOpenAt: true, entriesCloseAt: true, resultsOfficialAt: true, diff --git a/packages/shared/src/models/event.ts b/packages/shared/src/models/event.ts index 6d7691b..2596248 100644 --- a/packages/shared/src/models/event.ts +++ b/packages/shared/src/models/event.ts @@ -19,9 +19,6 @@ export const eventSchema = z.object({ longitude: z.number().nullable().optional(), countryId: z.string().nullable().optional(), featuredImageKey: z.string().nullable().optional(), - zeroTime: z - .string() - .regex(/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/, 'Expected UTC time as HH:mm:ss'), relay: z.boolean(), discipline: eventDisciplineSchema, startMode: startModeSchema, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d568fd4..141529a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: baseline-browser-mapping: specifier: ^2.10.20 version: 2.10.20 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.6.1) @@ -536,6 +539,9 @@ importers: '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.6.1) @@ -4964,6 +4970,11 @@ packages: engines: {node: '>=20'} hasBin: true + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-inspect@1.0.1: resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} engines: {node: '>=16.0.0'} @@ -13804,6 +13815,10 @@ snapshots: '@epic-web/invariant': 1.0.0 cross-spawn: 7.0.6 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-inspect@1.0.1: dependencies: tslib: 2.8.1 From bd6bff50d3f0c2f632ad079aae252004d7256e3f Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sat, 25 Apr 2026 22:17:01 +0200 Subject: [PATCH 2/4] feat: keep zeroTime in the API reponse to stay compatible --- apps/server/src/graphql/event/index.ts | 3 +++ apps/server/src/graphql/event/schema.ts | 1 + apps/server/src/modules/event/event.public.handlers.ts | 3 ++- apps/server/src/modules/event/event.secure.handlers.ts | 4 ++-- packages/shared/src/models/event.ts | 3 +++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/server/src/graphql/event/index.ts b/apps/server/src/graphql/event/index.ts index c25cabe..c5f4256 100644 --- a/apps/server/src/graphql/event/index.ts +++ b/apps/server/src/graphql/event/index.ts @@ -8,6 +8,8 @@ import { getEventStatusSummary } from '../../modules/event/event.status.service. import { getDecryptedEventPassword } from '../../modules/event/event.service.js'; import { requireEventOwnerOrAdmin } from '../../utils/authz.js'; import prisma from '../../utils/context.js'; +import { normalizeUtcTimeString } from '../../utils/time.js'; + export { resolvers, typeDef }; const buildPublicImageUrl = (key, eventId) => { @@ -56,6 +58,7 @@ const resolvers = { } return decryptedPassword; }, + zeroTime: (parent) => normalizeUtcTimeString(parent.date) ?? '00:00:00', featuredImage: (parent) => buildPublicImageUrl(parent.featuredImageKey, parent.id), statusSummary: (parent) => getEventStatusSummary(prisma, parent), }, diff --git a/apps/server/src/graphql/event/schema.ts b/apps/server/src/graphql/event/schema.ts index d4adc33..7201f19 100644 --- a/apps/server/src/graphql/event/schema.ts +++ b/apps/server/src/graphql/event/schema.ts @@ -128,6 +128,7 @@ export const typeDef = /* GraphQL */ ` organizer: String date: DateTime! timezone: String! + zeroTime: String! externalSource: ExternalEventProvider externalEventId: String location: String diff --git a/apps/server/src/modules/event/event.public.handlers.ts b/apps/server/src/modules/event/event.public.handlers.ts index 6e2374a..c411ca8 100644 --- a/apps/server/src/modules/event/event.public.handlers.ts +++ b/apps/server/src/modules/event/event.public.handlers.ts @@ -1,6 +1,7 @@ import { z } from '@hono/zod-openapi'; import prisma from '../../utils/context.js'; +import { normalizeUtcTimeString } from '../../utils/time.js'; import { getPublicObject } from '../../lib/storage/s3.js'; import { error, success, validation } from '../../utils/responseApi.js'; import { calculateCzechRankingPointsForEvent } from '../../utils/czech-ranking.js'; @@ -188,7 +189,7 @@ export function registerPublicEventRoutes(router) { } return c.json( - success('OK', { data: dbResponse }, 200), + success('OK', { data: { ...dbResponse, zeroTime: normalizeUtcTimeString(dbResponse.date) } }, 200), 200, ); }); diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts index 047d5d7..466a7e1 100644 --- a/apps/server/src/modules/event/event.secure.handlers.ts +++ b/apps/server/src/modules/event/event.secure.handlers.ts @@ -807,7 +807,7 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse('OK', { data: createdEvent }, res.statusCode), + successResponse('OK', { data: { ...createdEvent, zeroTime: normalizeUtcTimeString(createdEvent.date) } }, res.statusCode), ); } catch (error) { if (error instanceof ValidationError) { @@ -1182,7 +1182,7 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse('OK', { data: updatedEvent }, res.statusCode), + successResponse('OK', { data: { ...updatedEvent, zeroTime: normalizeUtcTimeString(updatedEvent.date) } }, res.statusCode), ); } catch (error) { if (error instanceof ValidationError) { diff --git a/packages/shared/src/models/event.ts b/packages/shared/src/models/event.ts index 2596248..6d7691b 100644 --- a/packages/shared/src/models/event.ts +++ b/packages/shared/src/models/event.ts @@ -19,6 +19,9 @@ export const eventSchema = z.object({ longitude: z.number().nullable().optional(), countryId: z.string().nullable().optional(), featuredImageKey: z.string().nullable().optional(), + zeroTime: z + .string() + .regex(/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/, 'Expected UTC time as HH:mm:ss'), relay: z.boolean(), discipline: eventDisciplineSchema, startMode: startModeSchema, From b2cd557629bf6e301a6bf31ece31163bf44179ec Mon Sep 17 00:00:00 2001 From: lukaskett Date: Sat, 25 Apr 2026 22:29:16 +0200 Subject: [PATCH 3/4] feat: change zeroTime response position (in fornt of classes) --- apps/server/src/modules/event/event.public.handlers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/event/event.public.handlers.ts b/apps/server/src/modules/event/event.public.handlers.ts index c411ca8..a4d023a 100644 --- a/apps/server/src/modules/event/event.public.handlers.ts +++ b/apps/server/src/modules/event/event.public.handlers.ts @@ -188,8 +188,9 @@ export function registerPublicEventRoutes(router) { ); } + const { id, name, date, timezone, location, ...restData } = dbResponse; return c.json( - success('OK', { data: { ...dbResponse, zeroTime: normalizeUtcTimeString(dbResponse.date) } }, 200), + success('OK', { data: { id, name, date, timezone, zeroTime: normalizeUtcTimeString(date), location, ...restData } }, 200), 200, ); }); From f41e65d9abb0543043f7d8ae8ff619237c7f9eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20K=C5=99ivda?= Date: Mon, 27 Apr 2026 17:16:35 +0200 Subject: [PATCH 4/4] fix(server): preserve event date position and RFC3339 serialization --- .../migration.sql | 4 +- apps/server/prisma/schema.prisma | 166 +++++++++--------- apps/server/src/graphql/scalars/dateTime.ts | 26 +-- .../server/src/modules/admin/admin.service.ts | 4 +- .../modules/event/event.public.handlers.ts | 37 +++- .../modules/event/event.secure.handlers.ts | 53 ++++-- apps/server/src/modules/user/user.service.ts | 14 +- apps/server/src/utils/__tests__/time.test.ts | 27 ++- apps/server/src/utils/time.ts | 49 ++++-- 9 files changed, 234 insertions(+), 146 deletions(-) diff --git a/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql b/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql index 6c2d089..a566a55 100644 --- a/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql +++ b/apps/server/prisma/migrations/20260423120000_merge_event_date_zerotime/migration.sql @@ -1,7 +1,7 @@ -- Merge separate date (DATE) and zeroTime (TIME) columns into a single DATETIME column. -- The new `date` stores the UTC event start timestamp. -ALTER TABLE `event` ADD COLUMN `date_tmp` DATETIME(3) NOT NULL DEFAULT '1970-01-01 00:00:00.000'; +ALTER TABLE `event` ADD COLUMN `date_tmp` DATETIME(0) NOT NULL DEFAULT '1970-01-01 00:00:00' AFTER `organizer`; UPDATE `event` SET `date_tmp` = TIMESTAMP(DATE(`date`), `zeroTime`); ALTER TABLE `event` DROP COLUMN `zeroTime`; ALTER TABLE `event` DROP COLUMN `date`; -ALTER TABLE `event` CHANGE `date_tmp` `date` DATETIME(3) NOT NULL; +ALTER TABLE `event` CHANGE `date_tmp` `date` DATETIME(0) NOT NULL AFTER `organizer`; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 331ccb9..ad37608 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -22,18 +22,18 @@ model Country { } model RankingCzech { - id Int @id @default(autoincrement()) @db.UnsignedInt + id Int @id @default(autoincrement()) @db.UnsignedInt rankingType CzechRankingType rankingCategory CzechRankingCategory - validForMonth DateTime @db.Date + validForMonth DateTime @db.Date place Int firstName String lastName String - registration String @db.VarChar(10) + registration String @db.VarChar(10) points Int rankIndex Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([registration, rankingType, rankingCategory, validForMonth], map: "ranking_czech_reg_type_cat_month_uq") @@index([rankingType, rankingCategory, validForMonth], map: "ranking_czech_type_cat_month_idx") @@ -65,9 +65,9 @@ model CzechRankingEventResult { } model Sport { - id Int @id @default(autoincrement()) @db.UnsignedInt - name String @unique - events Event[] + id Int @id @default(autoincrement()) @db.UnsignedInt + name String @unique + events Event[] userCards UserCard[] } @@ -104,9 +104,9 @@ model UserCard { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([userId, sportId, type, cardNumber]) @@index([userId, sportId]) @@index([userId, sportId, isDefault]) - @@unique([userId, sportId, type, cardNumber]) } enum UserCardType { @@ -119,46 +119,46 @@ enum UserRole { } model Event { - id String @id @default(cuid()) - sport Sport @relation(fields: [sportId], references: [id]) - sportId Int @db.UnsignedInt - name String - organizer String? - date DateTime - timezone String @default("Europe/Prague") - location String? - latitude Float? // GPS Latitude (nullable) - longitude Float? // GPS Longitude (nullable) - countryId String? @db.Char(2) - country Country? @relation(fields: [countryId], references: [countryCode]) - externalSource ExternalSource? - externalEventId String? @db.VarChar(128) - featuredImageKey String? @db.VarChar(512) - relay Boolean @default(false) - discipline EventDiscipline @default(OTHER) - startMode StartMode @default(Individual) - ranking Boolean @default(false) - coefRanking Float? - hundredthPrecision Boolean @default(false) // Measure finish time in hundredths of a second - published Boolean @default(false) - demo Boolean @default(false) - entriesOpenAt DateTime? - entriesCloseAt DateTime? - splitPublicationMode SplitPublicationMode @default(UNRESTRICTED) - splitPublicationAt DateTime? - resultsOfficialAt DateTime? + id String @id @default(cuid()) + sport Sport @relation(fields: [sportId], references: [id]) + sportId Int @db.UnsignedInt + name String + organizer String? + date DateTime @db.DateTime(0) + timezone String @default("Europe/Prague") + location String? + latitude Float? // GPS Latitude (nullable) + longitude Float? // GPS Longitude (nullable) + countryId String? @db.Char(2) + country Country? @relation(fields: [countryId], references: [countryCode]) + externalSource ExternalSource? + externalEventId String? @db.VarChar(128) + featuredImageKey String? @db.VarChar(512) + relay Boolean @default(false) + discipline EventDiscipline @default(OTHER) + startMode StartMode @default(Individual) + ranking Boolean @default(false) + coefRanking Float? + hundredthPrecision Boolean @default(false) // Measure finish time in hundredths of a second + published Boolean @default(false) + demo Boolean @default(false) + entriesOpenAt DateTime? + entriesCloseAt DateTime? + splitPublicationMode SplitPublicationMode @default(UNRESTRICTED) + splitPublicationAt DateTime? + resultsOfficialAt DateTime? resultsOfficialManuallySetAt DateTime? - author User? @relation(fields: [authorId], references: [id]) - authorId Int? @db.UnsignedInt - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - classes Class[] - protocols Protocol[] - eventPasswords EventPassword[] - externalResultsSync EventExternalResultsSyncState? + author User? @relation(fields: [authorId], references: [id]) + authorId Int? @db.UnsignedInt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + classes Class[] + protocols Protocol[] + eventPasswords EventPassword[] + externalResultsSync EventExternalResultsSyncState? - @@fulltext([name, organizer, location]) @@index([externalSource, externalEventId]) + @@fulltext([name, organizer, location]) } model Class { @@ -184,33 +184,33 @@ model Class { } model Competitor { - id Int @id @default(autoincrement()) @db.UnsignedInt - class Class @relation(fields: [classId], references: [id]) - classId Int @db.UnsignedInt - firstname String - lastname String - bibNumber Int? - nationality String? @db.Char(3) - registration String @db.VarChar(10) - license String? @db.Char(1) - rankingPoints Int? @db.UnsignedInt - rankingReferenceValue Int? @db.UnsignedInt - organisation String? - shortName String? @db.VarChar(10) - card Int? @db.UnsignedInt - startTime DateTime? - finishTime DateTime? - time Int? - team Team? @relation(fields: [teamId], references: [id]) - teamId Int? @db.UnsignedInt - leg Int? @db.UnsignedInt - status ResultStatus @default(Inactive) - lateStart Boolean @default(false) - note String? - updatedAt DateTime @default(now()) @updatedAt - externalId String? - protocols Protocol[] - splits Split[] + id Int @id @default(autoincrement()) @db.UnsignedInt + class Class @relation(fields: [classId], references: [id]) + classId Int @db.UnsignedInt + firstname String + lastname String + bibNumber Int? + nationality String? @db.Char(3) + registration String @db.VarChar(10) + license String? @db.Char(1) + rankingPoints Int? @db.UnsignedInt + rankingReferenceValue Int? @db.UnsignedInt + organisation String? + shortName String? @db.VarChar(10) + card Int? @db.UnsignedInt + startTime DateTime? + finishTime DateTime? + time Int? + team Team? @relation(fields: [teamId], references: [id]) + teamId Int? @db.UnsignedInt + leg Int? @db.UnsignedInt + status ResultStatus @default(Inactive) + lateStart Boolean @default(false) + note String? + updatedAt DateTime @default(now()) @updatedAt + externalId String? + protocols Protocol[] + splits Split[] } model Split { @@ -258,17 +258,17 @@ model EventPassword { } model EventExternalResultsSyncState { - id Int @id @default(autoincrement()) @db.UnsignedInt - event Event @relation(fields: [eventId], references: [id]) - eventId String @unique - provider ExternalSource - lastCheckedAt DateTime? - lastSuccessfulCheckAt DateTime? + id Int @id @default(autoincrement()) @db.UnsignedInt + event Event @relation(fields: [eventId], references: [id]) + eventId String @unique + provider ExternalSource + lastCheckedAt DateTime? + lastSuccessfulCheckAt DateTime? lastDetectedOfficialAt DateTime? - lastStatus ExternalResultsSyncStatus @default(PENDING) - lastError String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastStatus ExternalResultsSyncStatus @default(PENDING) + lastError String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([provider, lastStatus]) } diff --git a/apps/server/src/graphql/scalars/dateTime.ts b/apps/server/src/graphql/scalars/dateTime.ts index 3afc5a5..305a4bb 100644 --- a/apps/server/src/graphql/scalars/dateTime.ts +++ b/apps/server/src/graphql/scalars/dateTime.ts @@ -1,26 +1,19 @@ import { GraphQLScalarType, Kind } from 'graphql'; +import { formatUtcDateTimeRfc3339 } from '../../utils/time.js'; + export const typeDef = /* GraphQL */ ` scalar DateTime `; -// You can use any format here. This uses ISO 8601. -// If you want "yyyy-MM-dd HH:mm:ss" etc., see note below. const serializeDate = (value) => { - if (value instanceof Date) { - return value.toISOString(); // <-- format output here - } - - if (typeof value === 'string' || typeof value === 'number') { - const date = new Date(value); - if (isNaN(date.getTime())) { - throw new TypeError(`DateTime cannot represent an invalid Date: ${value}`); - } - return date.toISOString(); + const formatted = formatUtcDateTimeRfc3339(value); + if (formatted) { + return formatted; } throw new TypeError( - `DateTime cannot be serialized from a non-date type: ${JSON.stringify(value)}` + `DateTime cannot be serialized from a non-date type: ${JSON.stringify(value)}`, ); }; @@ -35,17 +28,14 @@ const parseDate = (value) => { export const resolvers = { DateTime: new GraphQLScalarType({ name: 'DateTime', - description: - 'Custom DateTime scalar (backed by JS Date, serialized as ISO8601 string)', + description: 'Custom DateTime scalar (backed by JS Date, serialized as ISO8601 string)', serialize: serializeDate, parseValue: parseDate, parseLiteral(ast) { if (ast.kind === Kind.STRING) { return parseDate(ast.value); } - throw new TypeError( - `DateTime cannot represent non string literal: ${ast.kind}` - ); + throw new TypeError(`DateTime cannot represent non string literal: ${ast.kind}`); }, }), }; diff --git a/apps/server/src/modules/admin/admin.service.ts b/apps/server/src/modules/admin/admin.service.ts index 06f9949..19bb1b5 100644 --- a/apps/server/src/modules/admin/admin.service.ts +++ b/apps/server/src/modules/admin/admin.service.ts @@ -8,6 +8,8 @@ import { type AdminUserListItem, } from '@repo/shared'; +import { formatUtcDateTimeRfc3339 } from '../../utils/time.js'; + const DASHBOARD_ACTIVITY_MONTHS = 6; const DASHBOARD_RECENT_LIMIT = 8; const LIST_LIMIT = 50; @@ -132,7 +134,7 @@ function mapEventListItem(event: { id: event.id, name: event.name, organizer: event.organizer, - date: event.date, + date: formatUtcDateTimeRfc3339(event.date) ?? event.date, discipline: event.discipline, published: event.published, ranking: event.ranking, diff --git a/apps/server/src/modules/event/event.public.handlers.ts b/apps/server/src/modules/event/event.public.handlers.ts index a4d023a..d486e2a 100644 --- a/apps/server/src/modules/event/event.public.handlers.ts +++ b/apps/server/src/modules/event/event.public.handlers.ts @@ -1,7 +1,7 @@ import { z } from '@hono/zod-openapi'; import prisma from '../../utils/context.js'; -import { normalizeUtcTimeString } from '../../utils/time.js'; +import { formatUtcDateTimeRfc3339, normalizeUtcTimeString } from '../../utils/time.js'; import { getPublicObject } from '../../lib/storage/s3.js'; import { error, success, validation } from '../../utils/responseApi.js'; import { calculateCzechRankingPointsForEvent } from '../../utils/czech-ranking.js'; @@ -29,6 +29,10 @@ function responseValidationString(issues: z.ZodIssue[]) { return validation(toValidationMessage(issues)); } +function serializeEventDateForResponse(date: Date) { + return formatUtcDateTimeRfc3339(date) ?? date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + export function registerPublicEventRoutes(router) { const eventCompetitorsQuerySchema = z.object({ class: z.string().regex(/^\d+$/).optional(), @@ -102,7 +106,20 @@ export function registerPublicEventRoutes(router) { logEndpoint(c, 'error', 'Public event list query failed', getErrorDetails(err)); return c.json(error(`Database error${err.message}`, 500), 500); } finally { - return c.json(success('OK', { data: dbResponse }, 200), 200); + return c.json( + success( + 'OK', + { + data: + dbResponse?.map((event) => ({ + ...event, + date: serializeEventDateForResponse(event.date), + })) ?? [], + }, + 200, + ), + 200, + ); } }); @@ -190,7 +207,21 @@ export function registerPublicEventRoutes(router) { const { id, name, date, timezone, location, ...restData } = dbResponse; return c.json( - success('OK', { data: { id, name, date, timezone, zeroTime: normalizeUtcTimeString(date), location, ...restData } }, 200), + success( + 'OK', + { + data: { + id, + name, + date: serializeEventDateForResponse(date), + timezone, + zeroTime: normalizeUtcTimeString(date), + location, + ...restData, + }, + }, + 200, + ), 200, ); }); diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts index 466a7e1..042bcf7 100644 --- a/apps/server/src/modules/event/event.secure.handlers.ts +++ b/apps/server/src/modules/event/event.secure.handlers.ts @@ -27,7 +27,11 @@ import { } from '../../utils/authz.js'; import { createCompetitorSchema, updateCompetitorSchema } from '../../utils/validateCompetitor.js'; import eventWriteSchema from '../../utils/validateEvent.js'; -import { normalizeUtcTimeString } from '../../utils/time.js'; +import { + combineEventDateWithZeroTime, + formatUtcDateTimeRfc3339, + normalizeUtcTimeString, +} from '../../utils/time.js'; import { encodeBase64, encrypt } from '../../lib/crypto/encryption.js'; import { formatErrors } from '../../utils/errors.js'; import { @@ -289,6 +293,10 @@ function parseOptionalIsoDateTime(value: string | null | undefined): Date | null return parsed; } +function serializeEventDateForResponse(date: Date) { + return formatUtcDateTimeRfc3339(date) ?? date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + async function syncExternalResultsStateForEvent(params: { eventId: string; externalSource: 'ORIS' | 'EVENTOR' | null | undefined; @@ -748,21 +756,17 @@ export function registerSecureEventRoutes(router) { // Everything went fine. try { - const normalizedZeroTime = normalizeUtcTimeString(zeroTime); const parsedEntriesOpenAt = parseOptionalIsoDateTime(entriesOpenAt); const parsedEntriesCloseAt = parseOptionalIsoDateTime(entriesCloseAt); const parsedSplitPublicationAt = parseOptionalIsoDateTime(splitPublicationAt); const parsedResultsOfficialManuallySetAt = parseOptionalIsoDateTime( resultsOfficialManuallySetAt, ); - - if (!normalizedZeroTime) { - throw new ValidationError('Invalid zero time. Expected HH:mm or HH:mm:ss.'); + const eventDateTime = combineEventDateWithZeroTime(date, zeroTime); + if (!eventDateTime) { + throw new ValidationError('Invalid event date or zero time.'); } - const datePart = new Date(date).toISOString().slice(0, 10); - const eventDateTime = new Date(`${datePart}T${normalizedZeroTime}Z`); - const createdEvent = await appPrisma.event.create({ data: { name, @@ -807,7 +811,17 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse('OK', { data: { ...createdEvent, zeroTime: normalizeUtcTimeString(createdEvent.date) } }, res.statusCode), + successResponse( + 'OK', + { + data: { + ...createdEvent, + date: serializeEventDateForResponse(createdEvent.date), + zeroTime: normalizeUtcTimeString(createdEvent.date), + }, + }, + res.statusCode, + ), ); } catch (error) { if (error instanceof ValidationError) { @@ -1099,20 +1113,17 @@ export function registerSecureEventRoutes(router) { const { userId } = ownership; try { - const normalizedZeroTime = normalizeUtcTimeString(zeroTime); const parsedEntriesOpenAt = parseOptionalIsoDateTime(entriesOpenAt); const parsedEntriesCloseAt = parseOptionalIsoDateTime(entriesCloseAt); const parsedSplitPublicationAt = parseOptionalIsoDateTime(splitPublicationAt); const parsedResultsOfficialManuallySetAt = parseOptionalIsoDateTime( resultsOfficialManuallySetAt, ); - if (!normalizedZeroTime) { - throw new ValidationError('Invalid zero time. Expected HH:mm or HH:mm:ss.'); + const eventDateTime = combineEventDateWithZeroTime(date, zeroTime); + if (!eventDateTime) { + throw new ValidationError('Invalid event date or zero time.'); } - const datePart = new Date(date).toISOString().slice(0, 10); - const eventDateTime = new Date(`${datePart}T${normalizedZeroTime}Z`); - // TODO: Add permission checks to ensure the user is allowed to edit the event // 🔥 Normalize latitude and longitude @@ -1182,7 +1193,17 @@ export function registerSecureEventRoutes(router) { }); return res.status(200).json( - successResponse('OK', { data: { ...updatedEvent, zeroTime: normalizeUtcTimeString(updatedEvent.date) } }, res.statusCode), + successResponse( + 'OK', + { + data: { + ...updatedEvent, + date: serializeEventDateForResponse(updatedEvent.date), + zeroTime: normalizeUtcTimeString(updatedEvent.date), + }, + }, + res.statusCode, + ), ); } catch (error) { if (error instanceof ValidationError) { diff --git a/apps/server/src/modules/user/user.service.ts b/apps/server/src/modules/user/user.service.ts index 5642df9..b1c33bf 100644 --- a/apps/server/src/modules/user/user.service.ts +++ b/apps/server/src/modules/user/user.service.ts @@ -1,6 +1,7 @@ -import type { AppPrismaClient } from "../../db/prisma-client.js"; -import { getEventStatusSummary } from "../event/event.status.service.js"; -import prisma from "../../utils/context.js"; +import type { AppPrismaClient } from '../../db/prisma-client.js'; +import { getEventStatusSummary } from '../event/event.status.service.js'; +import prisma from '../../utils/context.js'; +import { formatUtcDateTimeRfc3339 } from '../../utils/time.js'; export async function listMyEvents(userId: number | string) { const events = await prisma.event.findMany({ @@ -25,16 +26,13 @@ export async function listMyEvents(userId: number | string) { return Promise.all( events.map(async (event) => { - const statusSummary = await getEventStatusSummary( - prisma as AppPrismaClient, - event, - ); + const statusSummary = await getEventStatusSummary(prisma as AppPrismaClient, event); return { id: event.id, name: event.name, organizer: event.organizer, - date: event.date, + date: formatUtcDateTimeRfc3339(event.date) ?? event.date, location: event.location, relay: event.relay, published: event.published, diff --git a/apps/server/src/utils/__tests__/time.test.ts b/apps/server/src/utils/__tests__/time.test.ts index 25f6804..0b38df2 100644 --- a/apps/server/src/utils/__tests__/time.test.ts +++ b/apps/server/src/utils/__tests__/time.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { parseIofDateTime } from '../time.js'; +import { + combineEventDateWithZeroTime, + formatUtcDateTimeRfc3339, + parseIofDateTime, +} from '../time.js'; describe('parseIofDateTime', () => { it('parses local IOF datetime in the event timezone and converts it to UTC', () => { @@ -29,3 +33,24 @@ describe('parseIofDateTime', () => { expect(parseIofDateTime('', 'Europe/Prague')).toBeUndefined(); }); }); + +describe('combineEventDateWithZeroTime', () => { + it('preserves the provided calendar day even when the input datetime carries an offset', () => { + expect(combineEventDateWithZeroTime('2026-04-26T00:00:00+02:00', '10:15')?.toISOString()).toBe( + '2026-04-26T10:15:00.000Z', + ); + }); + + it('returns undefined for invalid inputs', () => { + expect(combineEventDateWithZeroTime('not-a-date', '10:15')).toBeUndefined(); + expect(combineEventDateWithZeroTime('2026-04-26T00:00:00Z', '25:15')).toBeUndefined(); + }); +}); + +describe('formatUtcDateTimeRfc3339', () => { + it('serializes UTC datetimes without milliseconds', () => { + expect(formatUtcDateTimeRfc3339(new Date('2026-04-26T10:15:00.000Z'))).toBe( + '2026-04-26T10:15:00Z', + ); + }); +}); diff --git a/apps/server/src/utils/time.ts b/apps/server/src/utils/time.ts index 3ac38ec..be60ba2 100644 --- a/apps/server/src/utils/time.ts +++ b/apps/server/src/utils/time.ts @@ -2,8 +2,8 @@ const HH_MM_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const HH_MM_SS_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; const HAS_TIMEZONE_PATTERN = /(?:[zZ]|[+-]\d{2}:\d{2})$/; const DATETIME_TIME_PART_PATTERN = /(?:T|\s)(\d{2}):(\d{2})(?::(\d{2}))?/; -const LOCAL_DATETIME_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})(?::(\d{2}))?$/; +const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/; +const LOCAL_DATETIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})(?::(\d{2}))?$/; type LocalDateTimeParts = { year: number; @@ -39,10 +39,7 @@ function getDateTimeFormatter(timeZone: string): Intl.DateTimeFormat | null { } } -function getLocalDateTimeParts( - date: Date, - timeZone: string, -): LocalDateTimeParts | null { +function getLocalDateTimeParts(date: Date, timeZone: string): LocalDateTimeParts | null { const formatter = getDateTimeFormatter(timeZone); if (!formatter) { return null; @@ -103,10 +100,7 @@ function getTimeZoneOffsetMs(date: Date, timeZone: string): number | null { return utcTimestamp - date.getTime(); } -function sameLocalDateTime( - a: LocalDateTimeParts, - b: LocalDateTimeParts | null, -): boolean { +function sameLocalDateTime(a: LocalDateTimeParts, b: LocalDateTimeParts | null): boolean { return ( b !== null && a.year === b.year && @@ -135,10 +129,7 @@ function parseLocalDateTime(value: string): LocalDateTimeParts | null { }; } -function parseLocalDateTimeInTimeZone( - value: string, - timeZone: string, -): Date | undefined { +function parseLocalDateTimeInTimeZone(value: string, timeZone: string): Date | undefined { const requested = parseLocalDateTime(value); if (!requested) { return undefined; @@ -244,6 +235,36 @@ export function normalizeUtcTimeString(value: string | Date | null | undefined): return parsed.toISOString().slice(11, 19); } +export function formatUtcDateTimeRfc3339( + value: string | Date | number | null | undefined, +): string | null { + if (value === null || value === undefined) { + return null; + } + + const parsed = value instanceof Date ? value : new Date(value); + if (!isValidDate(parsed)) { + return null; + } + + return parsed.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +export function combineEventDateWithZeroTime(date: string, zeroTime: string): Date | undefined { + const normalizedZeroTime = normalizeUtcTimeString(zeroTime); + if (!normalizedZeroTime) { + return undefined; + } + + const dateMatch = date.trim().match(DATE_PREFIX_PATTERN); + if (!dateMatch) { + return undefined; + } + + const combined = new Date(`${dateMatch[1]}T${normalizedZeroTime}Z`); + return isValidDate(combined) ? combined : undefined; +} + export function toPrismaTimeDate(utcTime: string): Date { return new Date(`1970-01-01T${utcTime}Z`); }