From cc7b41db0ea0b01971042a343d75565d9d3de0de Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:02:56 +0200 Subject: [PATCH] feat(ai-chat): inject current date & per-message timestamps into agent context (#22632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Gives the AI chat agent temporal awareness by injecting the current date into the system prompt and a per-message "sent at" timestamp into each user message, formatted in the member's timezone. Also hardens all timezone formatting against the `"system"` sentinel value, which was crashing the stream job. ## What changed **Message timestamps (new)** - Added `injectMessageTimestamps` util: prepends a `Sent: …` text part to each user message before it's sent to the model, so the agent can reason about "yesterday", "last week", etc. - `loadMessagesFromDB` now stores the message time in the canonical `metadata.createdAt` slot (ISO string, JSON-serializable for the BullMQ job payload) instead of a non-typed top-level `createdAt` field that nothing read. - Migrated the AI chat message pipeline from the generic `UIMessage` to the typed `ExtendedUIMessage` (`chat-execution.service`, `extract-code-interpreter-files`, `replace-unsupported-file-parts`, and related types), since `metadata.createdAt` is declared on `ExtendedUIMessage`. **Current date in context** - System prompt now includes `Current date: …` formatted in the member's timezone (`system-prompt-builder.service`). - Settings › AI prompt preview mirrors the same `Current date` line. **Timezone safety (bug fix)** - Workspace members default `timeZone` to the `"system"` sentinel, which is only resolvable client-side. Passing it (or any invalid IANA zone) to `Intl.DateTimeFormat` throws `RangeError: Invalid time zone specified: system`, which was failing the stream job. - Added `getValidTimeZoneOrUndefined`, which returns a valid IANA zone or `undefined` (letting the runtime fall back to its default). Used in both `injectMessageTimestamps` and `formatCurrentDate`. This mirrors the existing `isValidTimeZone` convention in the calendar module. ## Notes / follow-ups - For members who never changed `timeZone` from `"system"`, timestamps fall back to the server's default zone (UTC). To honor their real local time, the frontend would need to send the browser-detected zone with the chat request (the same way calendar/charts already pass a resolved zone). Not included here. ## Test plan - [x] `inject-message-timestamps.util.spec.ts` — covers timestamp injection, assistant messages untouched, invalid `createdAt`, and the `"system"` timezone no longer throwing. - [ ] Send a chat message and confirm the agent sees the correct date/time. - [ ] Verify a member with `timeZone = "system"` no longer crashes the stream job. Review in cubic --- .../pages/settings/ai/SettingsAiPrompts.tsx | 24 +++++- .../system-prompt-builder.service.spec.ts | 36 ++++++++ .../services/agent-chat-streaming.service.ts | 2 +- .../services/chat-execution.service.ts | 18 ++-- .../services/system-prompt-builder.service.ts | 23 ++++- ...ract-code-interpreter-files-result.type.ts | 4 +- .../inject-message-timestamps.util.spec.ts | 85 +++++++++++++++++++ ...eplace-unsupported-file-parts.util.spec.ts | 8 +- .../extract-code-interpreter-files.util.ts | 5 +- .../utils/inject-message-timestamps.util.ts | 52 ++++++++++++ .../replace-unsupported-file-parts.util.ts | 7 +- .../getValidTimeZoneOrUndefined.test.ts | 21 +++++ .../utils/date/getValidTimeZoneOrUndefined.ts | 17 ++++ packages/twenty-shared/src/utils/index.ts | 1 + 14 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/system-prompt-builder.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/inject-message-timestamps.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util.ts create mode 100644 packages/twenty-shared/src/utils/date/__tests__/getValidTimeZoneOrUndefined.test.ts create mode 100644 packages/twenty-shared/src/utils/date/getValidTimeZoneOrUndefined.ts diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAiPrompts.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAiPrompts.tsx index 393ae8bcc9..2983fd5ffc 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAiPrompts.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAiPrompts.tsx @@ -8,7 +8,11 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState import { useQuery } from '@apollo/client/react'; import { t } from '@lingui/core/macro'; import { SettingsPath } from 'twenty-shared/types'; -import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { + getSettingsPath, + getValidTimeZoneOrUndefined, + isDefined, +} from 'twenty-shared/utils'; import { H2Title, H3Title } from 'twenty-ui/typography'; import { Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -48,10 +52,24 @@ export const SettingsAiPrompts = () => { `**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`, ]; - if (isDefined(currentWorkspaceMember.timeZone)) { - parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`); + const validTimeZone = getValidTimeZoneOrUndefined( + currentWorkspaceMember.timeZone, + ); + + if (isDefined(validTimeZone)) { + parts.push(`**${t`Timezone`}:** ${validTimeZone}`); } + const currentDate = new Intl.DateTimeFormat('en-US', { + timeZone: validTimeZone, + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date()); + + parts.push(`**${t`Current date`}:** ${currentDate}`); + return parts.join('\n\n'); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/system-prompt-builder.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/system-prompt-builder.service.spec.ts new file mode 100644 index 0000000000..c150480ac7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/system-prompt-builder.service.spec.ts @@ -0,0 +1,36 @@ +import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; + +describe('SystemPromptBuilderService', () => { + const buildService = () => + new SystemPromptBuilderService({} as never, {} as never, {} as never); + + describe('buildUserContextSection', () => { + it('omits the timezone line when timezone is the "system" sentinel', () => { + const service = buildService(); + + const result = service.buildUserContextSection({ + firstName: 'John', + lastName: 'Doe', + locale: 'en', + timezone: 'system', + }); + + expect(result).not.toContain('Timezone:'); + expect(result).toContain('Current date:'); + }); + + it('includes the timezone line for a valid IANA timezone', () => { + const service = buildService(); + + const result = service.buildUserContextSection({ + firstName: 'John', + lastName: 'Doe', + locale: 'en', + timezone: 'America/New_York', + }); + + expect(result).toContain('Timezone: America/New_York'); + expect(result).toContain('Current date:'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index dbe29f5e76..fc4fd0708a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -603,7 +603,7 @@ export class AgentChatStreamingService { return part; }), ), - createdAt: message.createdAt, + metadata: { createdAt: message.createdAt.toISOString() }, })), ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 90799abe8c..d28d7a4f02 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -10,10 +10,8 @@ import { streamText, type SystemModelMessage, type ToolSet, - type UIDataTypes, - type UIMessage, - type UITools, } from 'ai'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { type APP_LOCALES } from 'twenty-shared/translations'; import { AppPath } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; @@ -66,6 +64,7 @@ import { MessagePruningService } from 'src/engine/metadata-modules/ai/ai-chat/se import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; import { extractCodeInterpreterFiles } from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util'; +import { injectMessageTimestamps } from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util'; import { getCacheProviderOptions, getCallLevelProviderOptions, @@ -83,7 +82,7 @@ export type ChatExecutionOptions = { workspace: WorkspaceEntity; userWorkspaceId: string; threadId?: string; - messages: UIMessage[]; + messages: ExtendedUIMessage[]; browsingContext: BrowsingContextType | null; onCodeExecutionUpdate?: CodeExecutionStreamEmitter; onCompaction?: () => void; @@ -236,7 +235,7 @@ export class ChatExecutionService { const isCodeInterpreterEnabled = this.codeInterpreterService.isEnabled(); - let processedMessages: UIMessage[] = replaceUnsupportedFileParts( + let processedMessages: ExtendedUIMessage[] = replaceUnsupportedFileParts( messages, modelConfig.modalities, isCodeInterpreterEnabled, @@ -272,6 +271,11 @@ export class ChatExecutionService { ); } + processedMessages = injectMessageTimestamps( + processedMessages, + userContext.timezone, + ); + const systemPrompt = this.systemPromptBuilder.buildFullPrompt( toolCatalog, skillCatalog, @@ -622,9 +626,9 @@ export class ChatExecutionService { } private injectBrowsingContextIntoLastUserMessage( - messages: UIMessage[], + messages: ExtendedUIMessage[], contextString: string, - ): UIMessage[] { + ): ExtendedUIMessage[] { const lastUserIndex = messages .map((message) => message.role) .lastIndexOf('user'); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts index 3ea9fe2a02..8c63c44e0a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { assertUnreachable } from 'twenty-shared/utils'; +import { + assertUnreachable, + getValidTimeZoneOrUndefined, +} from 'twenty-shared/utils'; import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; import { ToolCategory } from 'twenty-shared/ai'; @@ -182,16 +185,30 @@ ${instructions}`; `Locale: ${userContext.locale}`, ]; - if (userContext.timezone) { - parts.push(`Timezone: ${userContext.timezone}`); + const resolvedTimeZone = getValidTimeZoneOrUndefined(userContext.timezone); + + if (resolvedTimeZone) { + parts.push(`Timezone: ${resolvedTimeZone}`); } + parts.push(`Current date: ${this.formatCurrentDate(userContext.timezone)}`); + return ` ## User Context ${parts.join('\n')}`; } + private formatCurrentDate(timezone: string | null): string { + return new Intl.DateTimeFormat('en-US', { + timeZone: getValidTimeZoneOrUndefined(timezone), + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date()); + } + buildUploadedFilesSection( storedFiles: Array<{ filename: string; fileId: string }>, ): string { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts index d1500454d9..6451ebe556 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type.ts @@ -1,8 +1,8 @@ -import { type UIMessage } from 'ai'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; export type ExtractCodeInterpreterFilesResult = { - processedMessages: UIMessage[]; + processedMessages: ExtendedUIMessage[]; extractedFiles: ExtractedFile[]; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/inject-message-timestamps.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/inject-message-timestamps.util.spec.ts new file mode 100644 index 0000000000..4be1cbca45 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/inject-message-timestamps.util.spec.ts @@ -0,0 +1,85 @@ +import { type ExtendedUIMessage } from 'twenty-shared/ai'; + +import { injectMessageTimestamps } from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util'; + +describe('injectMessageTimestamps', () => { + it('prefixes a user message with a timestamp part built from metadata.createdAt', () => { + const messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'What happened yesterday?' }], + metadata: { createdAt: '2026-07-07T09:57:00.000Z' }, + }, + ] as unknown as ExtendedUIMessage[]; + + const [message] = injectMessageTimestamps(messages, 'UTC'); + + expect(message.parts).toHaveLength(2); + expect(message.parts[0]).toEqual({ + type: 'text', + text: expect.stringContaining('Sent: '), + }); + expect((message.parts[0] as { text: string }).text).toContain('UTC'); + expect(message.parts[1]).toEqual({ + type: 'text', + text: 'What happened yesterday?', + }); + }); + + it('leaves assistant messages untouched', () => { + const messages = [ + { + id: '1', + role: 'assistant', + parts: [{ type: 'text', text: 'response' }], + metadata: { createdAt: '2026-07-07T09:57:00.000Z' }, + }, + ] as unknown as ExtendedUIMessage[]; + + const [message] = injectMessageTimestamps(messages, 'UTC'); + + expect(message.parts).toHaveLength(1); + expect(message.parts[0]).toEqual({ type: 'text', text: 'response' }); + }); + + it('does not throw and still injects a timestamp when the timezone is the "system" sentinel', () => { + const messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'What happened yesterday?' }], + metadata: { createdAt: '2026-07-07T09:57:00.000Z' }, + }, + ] as unknown as ExtendedUIMessage[]; + + const [message] = injectMessageTimestamps(messages, 'system'); + + expect(message.parts).toHaveLength(2); + expect(message.parts[0]).toEqual({ + type: 'text', + text: expect.stringContaining('Sent: '), + }); + }); + + it('leaves user messages without a valid createdAt untouched', () => { + const messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + { + id: '2', + role: 'user', + parts: [{ type: 'text', text: 'world' }], + metadata: { createdAt: 'not-a-date' }, + }, + ] as unknown as ExtendedUIMessage[]; + + const result = injectMessageTimestamps(messages, 'UTC'); + + expect(result[0].parts).toHaveLength(1); + expect(result[1].parts).toHaveLength(1); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts index e09ed57467..28e7cc0abe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/replace-unsupported-file-parts.util.spec.ts @@ -1,4 +1,4 @@ -import { type UIMessage } from 'ai'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { replaceUnsupportedFileParts } from 'src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util'; @@ -10,7 +10,9 @@ const buildFilePart = (mediaType: string, filename = 'file.bin') => ({ fileId: 'file-id', }); -const buildUserMessage = (parts: UIMessage['parts']): UIMessage => ({ +const buildUserMessage = ( + parts: ExtendedUIMessage['parts'], +): ExtendedUIMessage => ({ id: 'message-id', role: 'user', parts, @@ -65,7 +67,7 @@ describe('replaceUnsupportedFileParts', () => { }); it('does not touch non-user messages', () => { - const assistantMessage: UIMessage = { + const assistantMessage: ExtendedUIMessage = { id: 'assistant-id', role: 'assistant', parts: [{ type: 'text', text: 'hello' }], diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts index 07e43fe13d..c8fcf7ed6e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util.ts @@ -1,12 +1,11 @@ -import { type UIMessage } from 'ai'; -import { isExtendedFileUIPart } from 'twenty-shared/ai'; +import { type ExtendedUIMessage, isExtendedFileUIPart } from 'twenty-shared/ai'; import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant'; import { type ExtractCodeInterpreterFilesResult } from 'src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type'; import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type'; export const extractCodeInterpreterFiles = ( - messages: UIMessage[], + messages: ExtendedUIMessage[], ): ExtractCodeInterpreterFilesResult => { const extractedFiles: ExtractedFile[] = []; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util.ts new file mode 100644 index 0000000000..9396a8fb82 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util.ts @@ -0,0 +1,52 @@ +import { type ExtendedUIMessage } from 'twenty-shared/ai'; +import { getValidTimeZoneOrUndefined, isDefined } from 'twenty-shared/utils'; + +const formatMessageTimestamp = (date: Date, timezone: string | null): string => + new Intl.DateTimeFormat('en-US', { + timeZone: getValidTimeZoneOrUndefined(timezone), + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date); + +const extractCreatedAt = (message: ExtendedUIMessage): Date | undefined => { + const rawCreatedAt = message.metadata?.createdAt; + + if (!isDefined(rawCreatedAt)) { + return undefined; + } + + const parsedCreatedAt = new Date(rawCreatedAt); + + return isNaN(parsedCreatedAt.getTime()) ? undefined : parsedCreatedAt; +}; + +export const injectMessageTimestamps = ( + messages: ExtendedUIMessage[], + timezone: string | null, +): ExtendedUIMessage[] => + messages.map((message) => { + if (message.role !== 'user') { + return message; + } + + const createdAt = extractCreatedAt(message); + + if (!isDefined(createdAt)) { + return message; + } + + const timestampPart = { + type: 'text' as const, + text: `Sent: ${formatMessageTimestamp(createdAt, timezone)}`, + }; + + return { + ...message, + parts: [timestampPart, ...message.parts], + }; + }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts index ef43f6f542..edd3a5a365 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util.ts @@ -1,14 +1,13 @@ -import { type UIMessage } from 'ai'; -import { isExtendedFileUIPart } from 'twenty-shared/ai'; +import { type ExtendedUIMessage, isExtendedFileUIPart } from 'twenty-shared/ai'; import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant'; import { getNativeMimeTypesForModalities } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util'; export const replaceUnsupportedFileParts = ( - messages: UIMessage[], + messages: ExtendedUIMessage[], modalities: string[] = [], isCodeInterpreterEnabled: boolean, -): UIMessage[] => { +): ExtendedUIMessage[] => { const nativeMimeTypes = getNativeMimeTypesForModalities(modalities); return messages.map((message) => { diff --git a/packages/twenty-shared/src/utils/date/__tests__/getValidTimeZoneOrUndefined.test.ts b/packages/twenty-shared/src/utils/date/__tests__/getValidTimeZoneOrUndefined.test.ts new file mode 100644 index 0000000000..94e24f5f90 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/getValidTimeZoneOrUndefined.test.ts @@ -0,0 +1,21 @@ +import { getValidTimeZoneOrUndefined } from '@/utils/date/getValidTimeZoneOrUndefined'; + +describe('getValidTimeZoneOrUndefined', () => { + it('should return undefined for null, undefined, empty string, and system', () => { + expect(getValidTimeZoneOrUndefined(null)).toBeUndefined(); + expect(getValidTimeZoneOrUndefined(undefined)).toBeUndefined(); + expect(getValidTimeZoneOrUndefined('')).toBeUndefined(); + expect(getValidTimeZoneOrUndefined('system')).toBeUndefined(); + }); + + it('should return undefined for invalid IANA time zones', () => { + expect(getValidTimeZoneOrUndefined('Not/A_Timezone')).toBeUndefined(); + }); + + it('should return the time zone for valid IANA time zones', () => { + expect(getValidTimeZoneOrUndefined('America/New_York')).toBe( + 'America/New_York', + ); + expect(getValidTimeZoneOrUndefined('UTC')).toBe('UTC'); + }); +}); diff --git a/packages/twenty-shared/src/utils/date/getValidTimeZoneOrUndefined.ts b/packages/twenty-shared/src/utils/date/getValidTimeZoneOrUndefined.ts new file mode 100644 index 0000000000..040b69340d --- /dev/null +++ b/packages/twenty-shared/src/utils/date/getValidTimeZoneOrUndefined.ts @@ -0,0 +1,17 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +export const getValidTimeZoneOrUndefined = ( + timeZone: string | null | undefined, +): string | undefined => { + if (!isNonEmptyString(timeZone) || timeZone === 'system') { + return undefined; + } + + try { + new Intl.DateTimeFormat('en-US', { timeZone }); + + return timeZone; + } catch { + return undefined; + } +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index a06f426d46..9a85000204 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -34,6 +34,7 @@ export { ACCEPTED_DATE_FORMATS, ACCEPTED_DATE_TIME_FORMATS, } from './date/dateInputFormats'; +export { getValidTimeZoneOrUndefined } from './date/getValidTimeZoneOrUndefined'; export { isDateWithoutTime } from './date/isDateWithoutTime'; export { isPlainDateAfter } from './date/isPlainDateAfter'; export { isPlainDateBefore } from './date/isPlainDateBefore';