Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~23 min· topic 3 of 7

Topic 4.3

Dates and Time Zones

In one line

A timestamp is one exact instant, but the calendar day it falls on depends on whose clock you read. Parse dates with a stated format, format them on a stated clock, and never let a chart silently shift a ride to the wrong day.

Think of it like this

A phone call at 00:30 in London is still the evening before in New York. It is one call, but two people write two different dates in their diaries. A ride in the data is the call, the time zone is whose diary you write it in.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

instant
One exact moment in time, the same for everyone on Earth.
UTC
Coordinated Universal Time: the reference clock with offset zero. Written with a Z at the end of a timestamp.
time zone
A region's rule for turning an instant into a wall-clock time, including its summer-time changes. Named like Atlantic/Azores.
offset
How far a clock is from UTC, such as −1 hour.
ISO 8601
The standard date text: 2025-06-01 or 2025-06-01T00:00:09Z. Biggest unit first.
summer time (DST)
The yearly shift of clocks by one hour. It makes one local day 23 hours long and another 25.

Step by step

01Ask which day a trip belongs to

The first example loads trips-10k.csv (10,000 trips, all in June 2025, times in UTC) and asks five clocks which day the same trips fall on. For UTC and for Ardenvale the answer is identical: 0 trips move to another day, because Ardenvale is on UTC+0 in summer.

For Los Angeles, the first trip (00:00:09 UTC on 1 June) is on 31 May, and 2,856 of the 10,000 trips are on a different date than their UTC date. For Auckland, 5,057 are. The rides did not change, only whose diary they are written in.

02Find the days that are not 24 hours

The second example walks through every real hour of 2025, writes down which Ardenvale date each hour belongs to, and counts. Two dates stand out: 30 March has 23 hours, and 26 October has 25.

The first and last dates are cut by our UTC range, so the code leaves them out. The same code with America/New_York finds 9 March and 2 November instead, and with Asia/Kolkata finds nothing, because that zone has no summer time.

03Pick the clock once

Name the dashboard's clock in one place (TIME_ZONE = "Atlantic/Azores" in config.js) and write on the chart which day it uses.

04Parse and format with a stated clock

The third example parses a European date (13/03/2025) with d3.utcParse, shows what the browser's own guess gives (Invalid Date), then prints one instant on three clocks, and sorts dates three ways.

Parse and format with a stated clockdiagram
Rendering diagram…

Try it yourself

  1. 1

    Try another clock

    In the first example replace Pacific/Auckland with Asia/Kolkata. The last row becomes Asia/Kolkata first trip on 2025-06-01 | trips on 1 June: 267 | trips on another day than UTC: 2266: that zone is 5 hours 30 minutes ahead, and the offset need not be a whole hour.

  2. 2

    Another city's short day

    In the second example change the zone to America/New_York (and the example's 2025-03-30 to 2025-03-09). It finds 9 March with 23 hours and 2 November with 25: every zone with summer time has its own two days.

  3. 3

    Break the date text

    In the third example, parse "2025-02-30" with utcParse("%Y-%m-%d"). It gives 2025-03-02: the parser rolls the day over like the 32 March case. A month of 13 (2025-13-01) rolls over too, to 2026-01-01, and a day of 00 gives the last day of the month before. Only text that does not fit the pattern, such as 2025-03-12 followed by a space, gives null.

Code & diagrams

The same trips on five clocks New tab

A chart that groups by the wrong clock moves thousands of rides to the wrong bar and prints no error. Europe/London shows 386 moved trips because London is one hour ahead of UTC in summer.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

First trip: T07790 starts at 2025-06-01T00:00:09Z
UTC                 first trip on 2025-06-01 | trips on 1 June: 330 | trips on another day than UTC: 0
Atlantic/Azores     first trip on 2025-06-01 | trips on 1 June: 330 | trips on another day than UTC: 0
Europe/London       first trip on 2025-06-01 | trips on 1 June: 317 | trips on another day than UTC: 386
America/Los_Angeles first trip on 2025-05-31 | trips on 1 June: 313 | trips on another day than UTC: 2856
Pacific/Auckland    first trip on 2025-06-01 | trips on 1 June: 179 | trips on another day than UTC: 5057
Find the days that are not 24 hours long New tab

This reads the browser's own time-zone rules, so it needs no data file. The edge case: dividing by 24 is wrong on exactly these two days.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Full local days counted: 364
Days that are not 24 hours long: [ [ '2025-03-30', 23 ], [ '2025-10-26', 25 ] ]
2,118 rides on 2025-03-30 is 92.1 per hour, not 88.3
Parse, format and sort dates New tab

Two silent failures: the impossible date 2025-03-32 becomes 1 April, and the default sort puts 30 March first.

Console

(nothing was printed)

Expected console output

utcParse('13/03/2025'): 2025-03-13T00:00:00.000Z
utcParse('2025-03-32'): 2025-04-01T00:00:00.000Z
utcParse('2025-03-12 '): null
new Date('13/03/2025'): Invalid Date
utcFormat: Wednesday 18 June 2025, 08:15
Ardenvale: Wednesday, 18 June 2025 at 08:15
Auckland:  Wednesday, 18 June 2025 at 20:15
default sort() : 2025-03-30 2025-03-04 2025-03-12
d3.ascending   : 2025-03-04 2025-03-12 2025-03-30
ISO text sorts too: 2025-03-04 2025-03-12 2025-03-30
but this text does not: 04/03/2025 13/03/2025 30/01/2025

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a European date with the browser's guess

In the third example add new Date("13/03/2025").toISOString();.

terminal
$ Console
── what you'll see ──
Uncaught RangeError: Invalid time value

Myth vs fact

Myth

A Date has a time zone.

Fact

A Date is one instant, in milliseconds. The zone only appears when you print it or ask for its day. getDate() uses the viewer's clock, getUTCDate() the UTC clock.

Myth

Every day has 24 hours.

Fact

In zones with summer time, two days a year have 23 and 25 hours. Ardenvale's were 30 March and 26 October 2025.

Myth

UTC is a time zone I can ignore on a dashboard.

Fact

If rides are stored in UTC and the team reads local days, grouping on the UTC date moves rides to other days. In Ardenvale's summer the two clocks agree, which hides the bug until winter.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    new Date("2025-03-12") is UTC midnight but new Date("2025-03-12T00:00") is local midnight, a quirk of the standard. d3.autoType adds a workaround so both read alike in browsers, but the safe rule is to parse with an explicit format.

  • ▸

    Intl.DateTimeFormat objects are expensive to create, cheap to use. Create one per zone and reuse it, as dayIn(zone) does. Formatting 10,000 trips with one formatter is fast; creating a formatter per row is not.

Remember this

  1. 1

    The problem this solves: Lumen's rides are stored with a UTC time, but the operations team thinks in Ardenvale's local days. If a chart groups rides by the UTC date, a ride at 23:30 local time can land on tomorrow's bar and every daily total is slightly wrong, with no error anywhere.

  2. 2

    An instant and a clock reading are different things. 2025-06-01T00:00:09Z names one instant. The trailing Z means UTC ("zero offset"). A JavaScript Date stores only that instant, as milliseconds since 1 January 1970 UTC. It has no zone of its own: the zone appears only when you turn it into text or ask for its day, hours and so on.

  3. 3

    Ardenvale's clock. Ardenvale uses the zone Atlantic/Azores: UTC−1 in winter and UTC+0 in summer, because clocks go forward on the last Sunday of March and back on the last Sunday of October. In 2025 that was 30 March and 26 October (the third example below finds both days by counting real hours). All June 2025 trips fall in summer time, where local time equals UTC, so for June the UTC day and the Ardenvale day match for all 10,000 trips. For a winter file they would not.

  4. 4

    Parsing. new Date("2025-03-12") reads a date-only text as UTC midnight, but new Date("2025-03-12T00:00") (a date and time with no zone) as the viewer's local time. new Date("13/03/2025") is Invalid Date. Do not let the browser guess: d3.utcParse("%d/%m/%Y") states the format and the clock, and returns null (not an Invalid Date) for text that does not fit. d3.timeParse is the same but reads the text as the viewer's local time. Careful: the parser accepts any two-digit day or month and rolls it over, as the third example shows for 2025-03-32 (it becomes 1 April). It returns null only when the text does not fit the pattern. Check the result, or compare the parsed pieces with the text (the cleaner in Topic 4.4 does).

  5. 5

    Formatting. Intl.DateTimeFormat is built into the browser and takes a timeZone: new Intl.DateTimeFormat("en-CA", { timeZone: "Atlantic/Azores", year: "numeric", month: "2-digit", day: "2-digit" }) writes the local date as 2025-06-01. (The en-CA locale is used because it writes year-month-day.) d3.utcFormat("%Y-%m-%d") writes the UTC clock, and d3.timeFormat writes the viewer's clock. Which clock you pick is a decision, not a detail.

  6. 6

    Days are not always 24 hours. On the day clocks go forward, the local day has 23 hours, and on the day they go back it has 25. A chart of hourly rides for 30 March 2025 has no 01:00 on Ardenvale's clock. A "rides per hour" figure computed as total ÷ 24 is wrong on those two days: 2,118 rides on 30 March is 92.1 per hour, not 88.3.

  7. 7

    Sorting. Date objects subtract to numbers, but the default sort() turns them into text first and sorts the text, which puts 30 Mar before 04 Mar as the first line of the third example shows. Use sort((a, b) => a - b) or d3.ascending. ISO text (2025-03-04) sorts correctly as text because the biggest unit comes first. 13/03/2025 does not.

Explain it without notes

01

Why can a trip at 00:30 UTC belong to the previous day for some viewers?

02

How do you get the local day of an instant for Ardenvale without a library?

03

Why is dividing a daily total by 24 wrong on 30 March 2025?

Practice

01

Write localDay(iso, zone) returning YYYY-MM-DD. Use it to count how many of trips-10k.csv's trips start on 15 June on the Pacific/Auckland clock.

02

Write hoursInLocalDay(dateText, zone) that returns 23, 24 or 25 for a YYYY-MM-DD date by counting the hours.

Trade-offs

  • ↔

    Grouping by the local day matches what the team means, but needs a named zone and a formatter. Grouping by the UTC day is simple and wrong for them in winter. Decide once, write it on the chart.

Done when you can

  • I can explain why a Date is an instant and not a clock reading.

  • I can get the local day of a UTC timestamp for a named zone.

  • I can name Ardenvale's two odd-length days in 2025.

  • I parse dates with a stated format and handle null.

  • I sort dates with a comparator.