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:
@@ -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}"`,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user