Dates. Without Pain
In this post, we’ll briefly cover how we handle dates and datetimes in our system.
As most developers know, the key difference between a date and a datetime is that a date does not represent a fixed point in time. Its meaning depends on the timezone — the same date may correspond to different moments around the world.
To avoid ambiguity, we store all dates in our system in the format 03/02/2024T00:00:00.000+0000, which means midnight UTC. We may eventually migrate to a DateOnly type, but that time hasn’t come yet.
We also aim to spare the server from the pain of handling timezones whenever possible. That’s why virtually everything is stored in UTC, and we stick with UTC even on the frontend — until we get to the point of rendering. Only display components are allowed to localize values.
Another important convention in our system is field naming. We follow these rules:
CreatedAt: a specific point in time (date + time) when something was createdCreatedOn: a calendar date (without time) when something was createdCreatedDate: alias forCreatedOn
So, let’s break down what to use and when.
1. I want to add a form with a date and time field.
Let’s say we need to add a Task entity that has a field representing the date and time when it was performed.
Since this is a datetime, we’ll name the field performedAt.
const TASK_INITIAL_FORM_VALUE = {
/**
* Let's say this field should be initialized with the current date and time.
* Using `startOf('minute')` is a good practice, as it prevents invisible seconds and milliseconds
* from being saved into the value.
*/
performedAt: dayjs().tzDefault().startOf('minute').utc(),
};
function TaskForm() {
return (
<Form {...form}>
<Form.Item name="performedAt">
{/*
* We use Form.DatePicker because we need it to store both date and time.
* We don’t need to worry about timezones — it's handled automatically.
*/}
<Form.DatePicker >
</Form.Item>
</Form>
);
}
function TaskTable() {
const columns: TableColumnType<Task> = [
{
key: 'performedAt',
filterable: {
type: 'date-range',
/**
* This is an important detail: we need to tell the filter that the value is a **date-time**
* so it can filter correctly.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should return all records where `performedAt` is within the range
* 19/03/2024T23:00:00Z - 25/03/2024T23:00:00Z (+1 day for a tasks performed 25/03, e.g. 25/03 17:30).
*/
valueType: 'date-time',
by: (item) => item.performedAt,
},
// render as date
render: (_, { performedAt }) => formatters.date(performedAt),
// render as datetime
render: (_, { performedAt }) => formatters.datetime(performedAt),
}
];
return <Table columns={columns} />;
}
// Let's take a look at the implementation of a custom filter form.
function TaskFilterForm() {
return (
<Form {...form}>
<Form.Item name="performedAt">
{/**
* This is an important detail: we need the DateRange to treat the value as a **datetime**.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should be recorded as 19/03/2024T23:00:00Z - 24/03/2024T23:00:00Z.
*/}
<Form.DateRange valueType="date-time" />
</Form.Item>
</Form>
);
}
// Now we can convert this into a FilterRule for server-side filtering.
function useData(filters: TaskFilterFormValue) {
return useTaskQuery({
filterRule: {
operator: 'and',
children: [
{ fieldId: 'performedAt', operator: '>=', value: filters.performedAt[0].toISOString() },
// We add one day because if the user selects 29/03,
// the result should include tasks performed on 29/03 — e.g., 29/03 at 17:30.
{ fieldId: 'performedAt', operator: '<', value: filters.performedAt[1].add(1, 'day').toISOString() },
],
}
})
}
2. I want to add a form with a date field
Let’s say we need to add a Task entity that has a field representing the date when it’s due.
Since this is a date (not a date-time), we’ll name the field dueDate.
const TASK_INITIAL_FORM_VALUE = {
/**
* Let's say this field should be initialized with the current date.
* As you can see, we take the current date in the local timezone,
* then convert it to UTC while preserving the calendar day.
* So if it's 22/03/2024 00:30 in Europe/London,
* we get 22/03/2024T00:00:00Z — even though it's 21/03/2024 23:30 UTC.
*/
dueDate: dayjs().tzDefault().startOf('date').utc(true),
};
function TaskForm() {
return (
<Form {...form}>
<Form.Item name="dueDate">
{/*
* We use `Form.Date` because we need it to store a **date only**,
* completely independent of any timezone. (e.g. 20/03/2024T00:00:00Z)
*/}
<Form.Date />
</Form.Item>
</Form>
);
}
function TaskTable() {
const columns: TableColumnType<Task> = [
{
key: 'dueDate',
filterable: {
type: 'date-range',
/**
* This is an important detail: we need to tell the filter that the value is a **date only**,
* so it can filter correctly.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should return all records where `dueDate` is in the range
* 20/03/2024T00:00:00Z - 25/03/2024T00:00:00Z — with no time conversions applied.
*/
valueType: 'date',
by: (item) => item.dueDate,
},
// formatters.date won't work here because it converts the time to the current timezone,
// which we don't want — it can cause a "date shift".
render: (_, { dueDate }) =>
dayjs(dueDate).tzDefault().format(DAYJS_FORMAT.DATE),
},
];
return <Table columns={columns} />;
}
// Let's take a look at the implementation of a custom filter form.
function TaskFilterForm() {
return (
<Form {...form}>
<Form.Item name="dueDate">
{/**
* This is an important detail: we need the DateRange to interpret the value as a **date**.
* When the user selects 20/03/2024 - 25/03/2024 (in Europe/London),
* it should record exactly 20/03/2024T00:00:00Z - 25/03/2024T00:00:00Z,
* without any timezone shifts.
*/}
<Form.DateRange valueType="date" />
</Form.Item>
</Form>
);
}
// Now we can convert this into a FilterRule for server-side filtering.
function useData(filters: TaskFilterFormValue) {
return useTaskQuery({
filterRule: {
operator: 'and',
children: [
{
fieldId: 'dueDate',
operator: '>=',
value: filters.performedAt[0].toISOString(),
},
// Unlike datetime fields, here we don't add a day — we use `<=` instead of `<`
{
fieldId: 'dueDate',
operator: '<=',
value: filters.performedAt[1].toISOString(),
},
],
},
});
}