- JS Date object is magic (magic is bad)
- We prefer to separate data from logic
- Side effects are bad (FP)
- Timezones are not as confusing as we have made them
First, a new mental model for thinking about dates (yes dates include datetimes).
1. Calendar Dates
Think of a physical, pin-on-the-wall calendar. The Platonic idea of 12 months, 30-ish days in a month, 24 hours in a day, etc.
The Calendar Date of "2nd of June 5pm" is just that. It represents the second day of the month of June at 5pm.
The "2nd of June 5pm" can "occur" many times (e.g., in different timezones, planets, etc).
2. Point-in-Time Dates
Think of a linear timeline from the Big Bang to the present moment.
When you want to point (gah pun) to a single point in time on that line, you are referencing a definitive moment in time.
Point-in-Time dates occur ONCE everywhere, all at once (General Relativity ignored).
So the Point-in-Time date of "2nd of June 5pm" needs to be qualified more with a location: the point in time when it was 5pm for me in Kansas.
Point-in-Time dates, therefore, are qualified with another piece of information—that the time is denoted in UTC time unless specified otherwise.
Both types of dates are represented, stored, and used as specially formatted strings always using: ISO-8601 + RFC-3339
Calendar Dates are represented in the non-"timezone" format for ISO-8601: YYYY-MM-DDTHH:MM:SS
Point in Time Dates are represented in the timezone-qualified format: YYYY-MM-DDTHH:MM:SSZ
or YYYY-MM-DDTHH:MM:SS±hh:mm
Note: There are really very few use cases for using the non-UTC (Z) version.
This means when you have a date, you will always know if it is a Calendar Date or a Point in Time Date.
While many of the "gotchas" of managing dates can be mitigated by following the golden rules of "run in UTC, store in UTC, transport in UTC".
JavaScript client-side Date object is particularly nasty, having unpredictable side effects when being constructed from strings.
It also combines data with behavior, which we want to avoid.
Therefore, we NEVER USE the Date object and instead use JavaScript string
with the above formats instead.
In reality, coding in the web browser will require you to use Date objects for interacting with other libraries, components, and doing certain date/time operations.
Below is a set of functional utilities:
const STRIP_TIMEZONE = /(Z)|([+-]\d{2}:\d{2})/g;
const CAL_DATE = /^\d{4}-\d{2}-\d{2}$/;
const POINT_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
export type DirtyDate = Date;
export type ISODate = string;
export type CalDate = string;
export type PointDate = string;
// Gross
export const isDirtyDate = (d: DirtyDate | ISODate) => d instanceof Date;
export const toDirtyDate = (d: ISODate): DirtyDate => new Date(d);
// Convenience alias
export const fromPointDate = toDirtyDate;
export const fromCalDate = toDirtyDate;
// Validates the above rules
export const isCalDate = (d: ISODate) => CAL_DATE.test(d);
export const isPointDate = (d: ISODate) => POINT_DATE.test(d);
export const isDate = (d: ISODate) => isCalDate(d) || isPointDate(d);
const pad = (n: number, length: number = 2) => String(n).padStart(length, "0");
// Timezone agnostic conversion
export const toCalDate = (d: DirtyDate) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(),3)}`;
// Respects timezones and formats in the base Z format
export const toPointDate = (d: DirtyDate) => d.toISOString();
export const convertPointToCalDate = (d: PointDate, method: "strip" | "local"): CalDate =>
method === "strip"
? d.replace(STRIP_TIMEZONE, "")
: toCalDate(toDirtyDate(d));
export const convertCalToPointDate = (d: CalDate, method: "utc" | "local"): PointDate =>
(method === "utc" ? `${d}Z` : toPointDate(toDirtyDate(d)));