From 33e335dbd1a0ac463e5d9973be180833965ec789 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Tue, 28 Nov 2023 14:43:06 -0800 Subject: [PATCH] time_zone_util: Add zoned date/time utility functions. Signed-off-by: Anders Kaseorg --- web/src/time_zone_util.ts | 54 +++++++++++++++++++ web/tests/time_zone_util.test.js | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 web/src/time_zone_util.ts create mode 100644 web/tests/time_zone_util.test.js diff --git a/web/src/time_zone_util.ts b/web/src/time_zone_util.ts new file mode 100644 index 0000000000..70f4901fb6 --- /dev/null +++ b/web/src/time_zone_util.ts @@ -0,0 +1,54 @@ +import assert from "minimalistic-assert"; + +const offset_formats = new Map(); + +function get_offset_format(time_zone: string): Intl.DateTimeFormat { + let format = offset_formats.get(time_zone); + if (format === undefined) { + format = new Intl.DateTimeFormat("en-US", { + timeZoneName: "longOffset", + timeZone: time_zone, + }); + offset_formats.set(time_zone, format); + } + return format; +} + +/** Get the given time zone's offset in milliseconds at the given date. */ +export function get_offset(date: number | Date, time_zone: string): number { + const offset_string = get_offset_format(time_zone) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")!.value; + if (offset_string === "GMT") { + return 0; + } + const m = /^GMT(([+-])\d\d):(\d\d)$/.exec(offset_string); + assert(m !== null, offset_string); + return (Number(m[1]) * 60 + Number(m[2] + m[3])) * 60000; +} + +/** Get the start of the day for the given date in the given time zone. */ +export function start_of_day(date: number | Date, time_zone: string): Date { + const offset = get_offset(date, time_zone); + let t = Number(date) + offset; + t -= t % 86400000; + return new Date(t - get_offset(new Date(t - offset), time_zone)); +} + +/** Get the number of calendar days between the given dates (ignoring times) in + * the given time zone. */ +export function difference_in_calendar_days( + left: number | Date, + right: number | Date, + time_zone: string, +): number { + return Math.round( + (start_of_day(left, time_zone).getTime() - start_of_day(right, time_zone).getTime()) / + 86400000, + ); +} + +/** Are the given dates in the same day in the given time zone? */ +export function is_same_day(left: number | Date, right: number | Date, time_zone: string): boolean { + return start_of_day(left, time_zone).getTime() === start_of_day(right, time_zone).getTime(); +} diff --git a/web/tests/time_zone_util.test.js b/web/tests/time_zone_util.test.js new file mode 100644 index 0000000000..7f5c7b3ab4 --- /dev/null +++ b/web/tests/time_zone_util.test.js @@ -0,0 +1,91 @@ +"use strict"; + +const {strict: assert} = require("assert"); + +const {zrequire} = require("./lib/namespace"); +const {run_test} = require("./lib/test"); + +const {get_offset, start_of_day, is_same_day, difference_in_calendar_days} = + zrequire("time_zone_util"); + +function pre(date) { + return new Date(date.getTime() - 1); +} + +const ny = "America/New_York"; +const ny_new_year = new Date("2023-01-01T05:00Z"); +const ny_new_year_eve = new Date("2022-12-31T05:00Z"); +const st_johns = "America/St_Johns"; +const st_johns_dst_begin = new Date("2023-03-12T05:30Z"); +const st_johns_dst_end = new Date("2023-11-05T04:30Z"); +const chatham = "Pacific/Chatham"; +const chatham_dst_begin = new Date("2022-09-24T14:00Z"); +const chatham_dst_end = new Date("2023-04-01T14:00Z"); +const kiritimati = "Pacific/Kiritimati"; +const kiritimati_date_skip = new Date("1994-12-31T10:00Z"); + +run_test("get_offset", () => { + assert.equal(get_offset(ny_new_year, "UTC"), 0); + assert.equal(get_offset(ny_new_year, ny), -5 * 60 * 60000); + assert.equal(get_offset(pre(st_johns_dst_begin), st_johns), -(3 * 60 + 30) * 60000); + assert.equal(get_offset(st_johns_dst_begin, st_johns), -(2 * 60 + 30) * 60000); + assert.equal(get_offset(pre(st_johns_dst_end), st_johns), -(2 * 60 + 30) * 60000); + assert.equal(get_offset(st_johns_dst_end, st_johns), -(3 * 60 + 30) * 60000); + assert.equal(get_offset(pre(chatham_dst_begin), chatham), (12 * 60 + 45) * 60000); + assert.equal(get_offset(chatham_dst_begin, chatham), (13 * 60 + 45) * 60000); + assert.equal(get_offset(pre(chatham_dst_end), chatham), (13 * 60 + 45) * 60000); + assert.equal(get_offset(chatham_dst_end, chatham), (12 * 60 + 45) * 60000); + assert.equal(get_offset(pre(kiritimati_date_skip), kiritimati), -10 * 60 * 60000); + assert.equal(get_offset(kiritimati_date_skip, kiritimati), 14 * 60 * 60000); +}); + +run_test("start_of_day", () => { + for (const [date, time_zone] of [ + [pre(ny_new_year), "UTC"], + [ny_new_year, "UTC"], + [pre(ny_new_year), ny], + [ny_new_year, ny], + [pre(st_johns_dst_begin), st_johns], + [st_johns_dst_end, st_johns], + [pre(st_johns_dst_end), st_johns], + [st_johns_dst_end, st_johns], + [pre(chatham_dst_begin), chatham], + [chatham_dst_end, chatham], + [pre(chatham_dst_end), chatham], + [chatham_dst_end, chatham], + [pre(kiritimati_date_skip), kiritimati], + [kiritimati_date_skip, kiritimati], + ]) { + const start = start_of_day(date, time_zone); + assert.equal( + start.toLocaleDateString("en-US", {timeZone: time_zone}), + date.toLocaleDateString("en-US", {timeZone: time_zone}), + ); + assert.equal(start.toLocaleTimeString("en-US", {timeZone: time_zone}), "12:00:00 AM"); + } +}); + +run_test("is_same_day", () => { + assert.ok(is_same_day(pre(ny_new_year), ny_new_year_eve, ny)); + assert.ok(!is_same_day(pre(ny_new_year), ny_new_year, ny)); + assert.ok(is_same_day(pre(st_johns_dst_begin), st_johns_dst_begin, st_johns)); + assert.ok(is_same_day(pre(st_johns_dst_end), st_johns_dst_end, st_johns)); + assert.ok(is_same_day(pre(chatham_dst_begin), chatham_dst_begin, chatham)); + assert.ok(is_same_day(pre(chatham_dst_end), chatham_dst_end, chatham)); + assert.ok(!is_same_day(pre(kiritimati_date_skip), kiritimati_date_skip, kiritimati)); +}); + +run_test("difference_in_calendar_days", () => { + assert.equal(difference_in_calendar_days(pre(ny_new_year), ny_new_year, ny), -1); + assert.equal(difference_in_calendar_days(pre(ny_new_year), ny_new_year_eve, ny), 0); + assert.equal(difference_in_calendar_days(ny_new_year, ny_new_year_eve, ny), 1); + assert.equal(difference_in_calendar_days(ny_new_year, pre(ny_new_year_eve), ny), 2); + assert.equal(difference_in_calendar_days(st_johns_dst_end, st_johns_dst_begin, st_johns), 238); + assert.equal(difference_in_calendar_days(chatham_dst_begin, chatham_dst_end, chatham), -189); + + // date-fns gives 2, but 1 seems more correct + assert.equal( + difference_in_calendar_days(kiritimati_date_skip, pre(kiritimati_date_skip), kiritimati), + 1, + ); +});