fix: normalize date-time field input on backend to prevent timeline crash (#22035)

## Context

Reported via support
([private-issues#477](https://github.com/twentyhq/private-issues/issues/477)):
a customer saw **"Invalid Configuration"** in red on a record's
**Timeline** tab. The dev console was flooded with:

```
RangeError: Cannot parse: 2026-05-07
    at Temporal.Instant.from (...)
    at RecordFieldComponent ...
```

## Root cause

A `DATE_TIME` field in their workspace holds **date-only** values like
`2026-05-07`.

`validateDateTimeFieldOrThrow` (the write-path validator) **accepts**
date-only formats — `'yyyy-MM-dd'` is in `ACCEPTED_DATE_TIME_FORMATS` —
and **returns the raw input string unchanged**, with no normalization.
So a date-only string passes validation and propagates verbatim into the
mutation response and the timeline event payload.

On render, `DateTimeDisplay` builds the timezone hint with
`Temporal.Instant.from(value)`. That's strict — it requires a full
instant (time + offset/`Z`) and throws `RangeError` on a bare date. The
throw escapes into the page-layout widget error boundary, which renders
the **"Invalid Configuration"** fallback and breaks the whole timeline.

## Fix

**Backend (root cause) — normalize on write.**
`validateDateTimeFieldOrThrow` now canonicalizes every accepted value to
a full ISO 8601 instant, so a date-only value can never reach storage,
the mutation response, or timeline events for a `DATE_TIME` field:

- strict ISO-8601 carrying an offset/`Z` -> kept as its exact instant
(server-timezone-independent)
- zoneless / date-only / lenient formats -> interpreted as **UTC**
(date-only -> midnight UTC), deterministically

Lenient input is preserved — parsing still uses date-fns for the ~20
accepted formats (which `Temporal.Instant.from` cannot parse); only the
*output* is canonicalized, via Temporal.

| input | before (stored raw) | after (normalized) |
|---|---|---|
| `2026-05-07` | `2026-05-07` | `2026-05-07T00:00:00Z` |
| `2026-05-07T12:00:00+02:00` | `2026-05-07T12:00:00+02:00` |
`2026-05-07T10:00:00Z` |
| `2026-05-07T12:00:00.000Z` | `2026-05-07T12:00:00.000Z` |
`2026-05-07T12:00:00Z` |
| `January 15, 2024` | `January 15, 2024` | `2024-01-15T00:00:00Z` |

**Frontend (existing data) — Temporal-native guard.** Existing
workspaces already have date-only values stored in events, so the
backend fix alone won't un-break the reporting customer's timeline.
`DateTimeDisplay` now parses the value via a new
`parseStringToInstantOrNull` helper (Temporal `Instant.from` with a
`PlainDate` start-of-day-UTC fallback) and only renders the timezone
hint when valid — so stored bad data renders gracefully instead of
crashing. This replaces the initial `new Date()` guard with a
Temporal-native one, in line with the codebase's Temporal migration.

## Tests

- `validate-date-time-field-or-throw.util.spec.ts` updated to assert the
normalized instant output, incl. explicit date-only -> midnight-UTC
cases.
- `parseStringToInstantOrNull.test.ts` — unit coverage for the frontend
helper (instant, offset, date-only, unparseable).
- `DateTimeDisplay.stories.tsx` — story rendering a date-only value
under a non-system timezone (the previously-crashing path).
This commit is contained in:
Charles Bochet
2026-06-24 14:42:05 +02:00
committed by GitHub
parent 8842a80a44
commit dd7435b807
11 changed files with 249 additions and 193 deletions
@@ -186,7 +186,7 @@ export const successfulInputsByFieldMetadataType: {
{ input: { dateTimeField: null }, expectedOutput: { dateTimeField: null } },
{
input: { dateTimeField: '2025-01-13T10:30:00.000Z' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000Z' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13T10:30:00Z' },
@@ -194,83 +194,83 @@ export const successfulInputsByFieldMetadataType: {
},
{
input: { dateTimeField: '2025-01-13T10:30:00.000+02:00' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000+02:00' },
expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13T10:30:00+02:00' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00+02:00' },
expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13T10:30:00.000' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13T10:30:00' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13 10:30:00.000' },
expectedOutput: { dateTimeField: '2025-01-13 10:30:00.000' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13 10:30:00' },
expectedOutput: { dateTimeField: '2025-01-13 10:30:00' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13 10:30' },
expectedOutput: { dateTimeField: '2025-01-13 10:30' },
expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' },
},
{
input: { dateTimeField: '2025-01-13' },
expectedOutput: { dateTimeField: '2025-01-13' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '20250113' },
expectedOutput: { dateTimeField: '20250113' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '2025.01.13' },
expectedOutput: { dateTimeField: '2025.01.13' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '2025/01/13' },
expectedOutput: { dateTimeField: '2025/01/13' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '01-13-2025' },
expectedOutput: { dateTimeField: '01-13-2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '01/13/2025' },
expectedOutput: { dateTimeField: '01/13/2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '01.13.2025' },
expectedOutput: { dateTimeField: '01.13.2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: 'January 13, 2025' },
expectedOutput: { dateTimeField: 'January 13, 2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: 'Jan 13, 2025' },
expectedOutput: { dateTimeField: 'Jan 13, 2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '13 January 2025' },
expectedOutput: { dateTimeField: '13 January 2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '13 Jan 2025' },
expectedOutput: { dateTimeField: '13 Jan 2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '13-Jan-2025' },
expectedOutput: { dateTimeField: '13-Jan-2025' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
{
input: { dateTimeField: '2025-Jan-13' },
expectedOutput: { dateTimeField: '2025-Jan-13' },
expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' },
},
],
[FieldMetadataType.BOOLEAN]: [
@@ -2,102 +2,122 @@ import { validateDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
describe('validateDateTimeFieldOrThrow', () => {
describe('valid inputs', () => {
describe('valid inputs (normalized to a canonical instant)', () => {
it('should return null when value is null', () => {
const result = validateDateTimeFieldOrThrow(null, 'testField');
expect(result).toBeNull();
});
it('should return the value when it is a valid ISO datetime string with Z timezone', () => {
const datetimeString = '2024-01-15T10:30:00Z';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should keep the instant for an ISO datetime string with Z timezone', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00Z',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid ISO datetime string with milliseconds and Z timezone', () => {
const datetimeString = '2024-01-15T10:30:00.000Z';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should drop a zero millisecond fraction', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00.000Z',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid ISO datetime string with timezone offset', () => {
const datetimeString = '2024-01-15T10:30:00+02:00';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should convert a timezone offset to its UTC instant', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00+02:00',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T08:30:00Z');
});
it('should return the value when it is a valid ISO datetime string with milliseconds and timezone offset', () => {
const datetimeString = '2024-01-15T10:30:00.000+02:00';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should convert a timezone offset with milliseconds to its UTC instant', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00.000+02:00',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T08:30:00Z');
});
it('should return the value when it is a valid ISO datetime string without timezone', () => {
const datetimeString = '2024-01-15T10:30:00';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should interpret a zoneless ISO datetime as UTC', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid ISO datetime string with milliseconds without timezone', () => {
const datetimeString = '2024-01-15T10:30:00.000';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should interpret a zoneless ISO datetime with milliseconds as UTC', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15T10:30:00.000',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid datetime with space separator', () => {
const datetimeString = '2024-01-15 10:30:00';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should normalize a datetime with a space separator', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15 10:30:00',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid datetime with space separator and milliseconds', () => {
const datetimeString = '2024-01-15 10:30:00.000';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should normalize a datetime with a space separator and milliseconds', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15 10:30:00.000',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid datetime with space separator without seconds', () => {
const datetimeString = '2024-01-15 10:30';
const result = validateDateTimeFieldOrThrow(datetimeString, 'testField');
it('should normalize a datetime with a space separator without seconds', () => {
const result = validateDateTimeFieldOrThrow(
'2024-01-15 10:30',
'testField',
);
expect(result).toBe(datetimeString);
expect(result).toBe('2024-01-15T10:30:00Z');
});
it('should return the value when it is a valid ISO date string (will be treated as midnight)', () => {
const dateString = '2024-01-15';
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
it('should normalize a date-only value to midnight UTC', () => {
const result = validateDateTimeFieldOrThrow('2024-01-15', 'testField');
expect(result).toBe(dateString);
expect(result).toBe('2024-01-15T00:00:00Z');
});
it('should return the value when it is a valid ISO compact date string', () => {
const dateString = '20240115';
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
it('should normalize a compact date-only value to midnight UTC', () => {
const result = validateDateTimeFieldOrThrow('20240115', 'testField');
expect(result).toBe(dateString);
expect(result).toBe('2024-01-15T00:00:00Z');
});
it('should return the value when it is a valid date with full month name', () => {
const dateString = 'January 15, 2024';
const result = validateDateTimeFieldOrThrow(dateString, 'testField');
it('should normalize a date with a full month name to midnight UTC', () => {
const result = validateDateTimeFieldOrThrow(
'January 15, 2024',
'testField',
);
expect(result).toBe(dateString);
expect(result).toBe('2024-01-15T00:00:00Z');
});
it('should return the value when it is a Date object', () => {
const dateObject = new Date('2024-01-15T10:30:00Z');
const result = validateDateTimeFieldOrThrow(dateObject, 'testField');
it('should normalize a Date object to its instant', () => {
const result = validateDateTimeFieldOrThrow(
new Date('2024-01-15T10:30:00Z'),
'testField',
);
expect(result).toBe(dateObject);
expect(result).toBe('2024-01-15T10:30:00Z');
});
});
@@ -3,34 +3,13 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isDate, isNull, isString } from '@sniptt/guards';
import { isValid, parse } from 'date-fns';
import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
const ACCEPTED_DATE_FORMATS = [
'yyyy-MM-dd',
'yyyyMMdd',
'yyyy.MM.dd',
'yyyy/MM/dd',
'MM-dd-yyyy',
'MM/dd/yyyy',
'MM.dd.yyyy',
'MMMM d, yyyy',
'MMM d, yyyy',
'd MMMM yyyy',
'd MMM yyyy',
'dd-MMM-yyyy',
'yyyy-MMM-dd',
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm:ss.SSS',
];
const isValidDateFormat = (value: string): boolean => {
for (const format of ACCEPTED_DATE_FORMATS) {
const parsed = parse(value, format, new Date());
@@ -2,64 +2,19 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isDate, isNull, isString } from '@sniptt/guards';
import { isValid, parse } from 'date-fns';
import { isValid } from 'date-fns';
import { Temporal } from 'temporal-polyfill';
import { parseToInstantOrThrow } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
const ACCEPTED_DATE_TIME_FORMATS = [
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
"yyyy-MM-dd'T'HH:mm:ssxxx",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss.SSS',
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm',
'yyyy-MM-dd',
'yyyyMMdd',
'yyyy.MM.dd',
'yyyy/MM/dd',
'MM-dd-yyyy',
'MM/dd/yyyy',
'MM.dd.yyyy',
'MMMM d, yyyy',
'MMM d, yyyy',
'd MMMM yyyy',
'd MMM yyyy',
'dd-MMM-yyyy',
'yyyy-MMM-dd',
];
const isValidDateTimeFormat = (value: string): boolean => {
for (const format of ACCEPTED_DATE_TIME_FORMATS) {
const parsed = parse(value, format, new Date());
if (isValid(parsed)) {
return true;
}
}
return false;
};
export const validateDateTimeFieldOrThrow = (
const throwInvalidDateTimeFieldException = (
value: unknown,
fieldName: string,
): unknown => {
if (isNull(value)) return null;
if (isDate(value) && isValid(value)) {
return value;
}
if (isString(value) && isValidDateTimeFormat(value)) {
return value;
}
): never => {
const inspectedValue = inspect(value);
throw new CommonQueryRunnerException(
@@ -70,3 +25,24 @@ export const validateDateTimeFieldOrThrow = (
},
);
};
export const validateDateTimeFieldOrThrow = (
value: unknown,
fieldName: string,
): unknown => {
if (isNull(value)) return null;
if (isDate(value) && isValid(value)) {
return Temporal.Instant.fromEpochMilliseconds(value.getTime()).toString();
}
if (isString(value)) {
try {
return parseToInstantOrThrow(value).toString();
} catch {
throwInvalidDateTimeFieldException(value, fieldName);
}
}
throwInvalidDateTimeFieldException(value, fieldName);
};
@@ -3,34 +3,13 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isDate, isNull, isString } from '@sniptt/guards';
import { isValid, parse } from 'date-fns';
import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
const ACCEPTED_DATE_FORMATS = [
'yyyy-MM-dd',
'yyyyMMdd',
'yyyy.MM.dd',
'yyyy/MM/dd',
'MM-dd-yyyy',
'MM/dd/yyyy',
'MM.dd.yyyy',
'MMMM d, yyyy',
'MMM d, yyyy',
'd MMMM yyyy',
'd MMM yyyy',
'dd-MMM-yyyy',
'yyyy-MMM-dd',
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm:ss.SSS',
];
const isValidDateFormat = (value: string): boolean => {
for (const format of ACCEPTED_DATE_FORMATS) {
const parsed = parse(value, format, new Date());
@@ -3,37 +3,13 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isDate, isNull, isString } from '@sniptt/guards';
import { isValid, parse } from 'date-fns';
import { ACCEPTED_DATE_TIME_FORMATS } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
const ACCEPTED_DATE_TIME_FORMATS = [
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
"yyyy-MM-dd'T'HH:mm:ssxxx",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss.SSS',
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm',
'yyyy-MM-dd',
'yyyyMMdd',
'yyyy.MM.dd',
'yyyy/MM/dd',
'MM-dd-yyyy',
'MM/dd/yyyy',
'MM.dd.yyyy',
'MMMM d, yyyy',
'MMM d, yyyy',
'd MMMM yyyy',
'd MMM yyyy',
'dd-MMM-yyyy',
'yyyy-MMM-dd',
];
const isValidDateTimeFormat = (value: string): boolean => {
for (const format of ACCEPTED_DATE_TIME_FORMATS) {
const parsed = parse(value, format, new Date());
@@ -227,6 +227,16 @@ export const successfulCreateInputByFieldMetadataType: {
);
},
},
{
input: {
dateTimeField: '2026-05-07',
},
validateInput: (record: Record<string, any>) => {
const date = new Date(record.dateTimeField);
return date.toISOString() === '2026-05-07T00:00:00.000Z';
},
},
],
[FieldMetadataType.BOOLEAN]: [
{
@@ -0,0 +1,26 @@
import { parseToInstantOrThrow } from '../parseToInstantOrThrow';
describe('parseToInstantOrThrow', () => {
it.each([
['2024-01-15T10:30:00Z', '2024-01-15T10:30:00Z'],
['2024-01-15T10:30:00.000Z', '2024-01-15T10:30:00Z'],
['2024-01-15T10:30:00+02:00', '2024-01-15T08:30:00Z'],
['2024-01-15T10:30:00', '2024-01-15T10:30:00Z'],
['2024-01-15 10:30:00', '2024-01-15T10:30:00Z'],
['2024-01-15 10:30', '2024-01-15T10:30:00Z'],
['2024-01-15', '2024-01-15T00:00:00Z'],
['20240115', '2024-01-15T00:00:00Z'],
['01/15/2024', '2024-01-15T00:00:00Z'],
['01-15-2024', '2024-01-15T00:00:00Z'],
['January 15, 2024', '2024-01-15T00:00:00Z'],
])('should parse %s to the instant %s', (input, expected) => {
expect(parseToInstantOrThrow(input).toString()).toBe(expected);
});
it.each([['2024'], ['2024-01'], ['not-a-date'], ['']])(
'should throw for the unparseable value %s',
(input) => {
expect(() => parseToInstantOrThrow(input)).toThrow();
},
);
});
@@ -0,0 +1,40 @@
export const NON_ISO_DATE_FORMATS = [
'yyyy.MM.dd',
'yyyy/MM/dd',
'MM-dd-yyyy',
'MM/dd/yyyy',
'MM.dd.yyyy',
'MMMM d, yyyy',
'MMM d, yyyy',
'd MMMM yyyy',
'd MMM yyyy',
'dd-MMM-yyyy',
'yyyy-MMM-dd',
];
export const ACCEPTED_DATE_FORMATS = [
'yyyy-MM-dd',
'yyyyMMdd',
...NON_ISO_DATE_FORMATS,
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm:ss.SSS',
];
export const ACCEPTED_DATE_TIME_FORMATS = [
"yyyy-MM-dd'T'HH:mm:ss.SSSX",
"yyyy-MM-dd'T'HH:mm:ssX",
"yyyy-MM-dd'T'HH:mm:ss.SSSxxx",
"yyyy-MM-dd'T'HH:mm:ssxxx",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss",
'yyyy-MM-dd HH:mm:ss.SSS',
'yyyy-MM-dd HH:mm:ss',
'yyyy-MM-dd HH:mm',
'yyyy-MM-dd',
'yyyyMMdd',
...NON_ISO_DATE_FORMATS,
];
@@ -0,0 +1,44 @@
import { isValid, parse } from 'date-fns';
import { Temporal } from 'temporal-polyfill';
import { NON_ISO_DATE_FORMATS } from '@/utils/date/dateInputFormats';
import { turnJSDateToPlainDate } from '@/utils/date/turnJSDateToPlainDate';
import { isDefined } from '@/utils/validation';
const getIsoInstant = (stringDateTime: string): Temporal.Instant | null => {
try {
return Temporal.Instant.from(stringDateTime);
} catch {
try {
return Temporal.PlainDateTime.from(stringDateTime)
.toZonedDateTime('UTC')
.toInstant();
} catch {
return null;
}
}
};
export const parseToInstantOrThrow = (
stringDateTime: string,
): Temporal.Instant => {
const isoInstant = getIsoInstant(stringDateTime);
if (isDefined(isoInstant)) {
return isoInstant;
}
for (const format of NON_ISO_DATE_FORMATS) {
const parsedDate = parse(stringDateTime, format, new Date());
if (isValid(parsedDate)) {
return turnJSDateToPlainDate(parsedDate)
.toZonedDateTime('UTC')
.toInstant();
}
}
throw new Error(
`Cannot parse date-time string as Instant: "${stringDateTime}"`,
);
};
@@ -29,6 +29,11 @@ export { interpolateCommandMenuItemTemplate } from './command-menu-items/interpo
export { resolveObjectMetadataLabel } from './command-menu-items/resolveObjectMetadataLabel';
export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export {
NON_ISO_DATE_FORMATS,
ACCEPTED_DATE_FORMATS,
ACCEPTED_DATE_TIME_FORMATS,
} from './date/dateInputFormats';
export { isDateWithoutTime } from './date/isDateWithoutTime';
export { isPlainDateAfter } from './date/isPlainDateAfter';
export { isPlainDateBefore } from './date/isPlainDateBefore';
@@ -36,6 +41,7 @@ export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';
export { isPlainDateInSameMonth } from './date/isPlainDateInSameMonth';
export { isPlainDateInWeekend } from './date/isPlainDateInWeekend';
export { isSamePlainDate } from './date/isSamePlainDate';
export { parseToInstantOrThrow } from './date/parseToInstantOrThrow';
export { parseToPlainDateOrThrow } from './date/parseToPlainDateOrThrow';
export { sortPlainDate } from './date/sortPlainDate';
export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate';