diff --git a/apps/client/src/hooks/useEvent.ts b/apps/client/src/hooks/useEvent.ts
index 70a894bb..267287e0 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 3285e119..ce175228 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 5ab7c85f..475c01a5 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 2e57838c..7157772d 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 a05a8de3..62e7a191 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 787a01de..5ea98c2d 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 00000000..a566a557
--- /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(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(0) NOT NULL AFTER `organizer`;
diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma
index 38ebf907..ad37608e 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,47 +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 @db.Date
- 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)
- zeroTime DateTime @db.Time(0)
- 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 {
@@ -185,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 {
@@ -259,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/event/index.ts b/apps/server/src/graphql/event/index.ts
index ecfa437f..c5f4256b 100644
--- a/apps/server/src/graphql/event/index.ts
+++ b/apps/server/src/graphql/event/index.ts
@@ -58,7 +58,7 @@ const resolvers = {
}
return decryptedPassword;
},
- zeroTime: (parent) => normalizeUtcTimeString(parent.zeroTime) ?? '00:00:00',
+ 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 4cf634e0..7201f19c 100644
--- a/apps/server/src/graphql/event/schema.ts
+++ b/apps/server/src/graphql/event/schema.ts
@@ -126,14 +126,14 @@ export const typeDef = /* GraphQL */ `
sportId: Int!
name: String!
organizer: String
- date: Date!
+ date: DateTime!
timezone: String!
+ zeroTime: 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/graphql/scalars/dateTime.ts b/apps/server/src/graphql/scalars/dateTime.ts
index 3afc5a57..305a4bb9 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 06f99498..19bb1b5a 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/__tests__/event.status.service.test.ts b/apps/server/src/modules/event/__tests__/event.status.service.test.ts
index 1b89865a..0cc8f2e2 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 3f9988c0..d486e2a0 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,
+ );
}
});
@@ -166,7 +183,6 @@ export function registerPublicEventRoutes(router) {
ranking: true,
coefRanking: true,
sport: true,
- zeroTime: true,
hundredthPrecision: true,
classes: true,
},
@@ -189,15 +205,19 @@ export function registerPublicEventRoutes(router) {
);
}
- const zeroTime = normalizeUtcTimeString(dbResponse.zeroTime);
-
+ const { id, name, date, timezone, location, ...restData } = dbResponse;
return c.json(
success(
'OK',
{
data: {
- ...dbResponse,
- zeroTime,
+ id,
+ name,
+ date: serializeEventDateForResponse(date),
+ timezone,
+ zeroTime: normalizeUtcTimeString(date),
+ location,
+ ...restData,
},
},
200,
diff --git a/apps/server/src/modules/event/event.secure.handlers.ts b/apps/server/src/modules/event/event.secure.handlers.ts
index 51055003..042bcf76 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, toPrismaTimeDate } 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,30 +756,27 @@ 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);
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 createdEvent = await appPrisma.event.create({
data: {
name,
- date: dateTime,
+ date: eventDateTime,
timezone,
organizer,
location,
latitude,
longitude,
countryId: countryCode,
- zeroTime: toPrismaTimeDate(normalizedZeroTime),
ranking,
coefRanking,
discipline,
@@ -811,7 +816,8 @@ export function registerSecureEventRoutes(router) {
{
data: {
...createdEvent,
- zeroTime: normalizeUtcTimeString(createdEvent.zeroTime),
+ date: serializeEventDateForResponse(createdEvent.date),
+ zeroTime: normalizeUtcTimeString(createdEvent.date),
},
},
res.statusCode,
@@ -1107,15 +1113,15 @@ 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.');
}
// TODO: Add permission checks to ensure the user is allowed to edit the event
@@ -1141,14 +1147,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,
@@ -1193,7 +1198,8 @@ export function registerSecureEventRoutes(router) {
{
data: {
...updatedEvent,
- zeroTime: normalizeUtcTimeString(updatedEvent.zeroTime),
+ date: serializeEventDateForResponse(updatedEvent.date),
+ zeroTime: normalizeUtcTimeString(updatedEvent.date),
},
},
res.statusCode,
diff --git a/apps/server/src/modules/event/event.status.service.ts b/apps/server/src/modules/event/event.status.service.ts
index ee7c0ebb..a720b128 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 c8c4437f..b1c33bf9 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({
@@ -14,7 +15,6 @@ export async function listMyEvents(userId: number | string) {
relay: true,
published: true,
timezone: true,
- zeroTime: true,
entriesOpenAt: true,
entriesCloseAt: true,
resultsOfficialAt: true,
@@ -26,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 25f6804b..0b38df24 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 3ac38ecc..be60ba2e 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`);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d568fd40..141529a7 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