watch, time, clock, duration, stopwatch, minute, alarm, hour, second, time clock, o'clock face, clock, stopwatch, stopwatch, stopwatch, stopwatch, stopwatch. Days between dates API: signal versus noise
Photo by AlamaCreative on Pixabay

Maintenance

Part of Days between dates: six honest answers to one simple-looking pair

Days between dates API: signal versus noise

Designing a days between dates API: plain dates against moments, the request and response fields worth echoing, and vectors that catch a merged type.

An interface that takes two dates and returns a number is answering one of two different questions, and the request has to say which. The difference between two dates is an integer. The difference between two moments is a duration, and dividing it by 24 does not turn it back into the integer.

This page sets out the two types, the clock-change arithmetic that separates them, a request and response shape that keeps them apart, and the test vectors that expose an implementation that merged them. No service is named.

What to take away

  • A plain date has no zone and no time. A moment has both. Keep them as separate types on the wire and in the code.
  • Between two local midnights on a clock-change day there are 23 or 25 hours, not 24. Any "days between" derived from moments is wrong by an hour somewhere, and by a day at the boundary.
  • Working-day counts need a calendar with a version, and the response should echo it.

Two types, two answers

Type

2027-03-05 and 2027-03-08
Plain dates
2027-03-05T15:00-05:00 and 2027-03-08T11:00-05:00
Moments

Difference is

2027-03-05 and 2027-03-08
An integer number of days
2027-03-05T15:00-05:00 and 2027-03-08T11:00-05:00
A duration

Example

2027-03-05 and 2027-03-08
3
2027-03-05T15:00-05:00 and 2027-03-08T11:00-05:00
68 hours

The first answer is exact and has no zone. The second is exact and has one. What is never exact is converting the second into the first by dividing hours by 24, because the local day is not always 24 hours long.

Plain dates vs moments

Plain dates

Input
2027-03-05
Difference is
Integer days
Example
3
Zone
None
Divide by 24
Never needed

Moments

Input
2027-03-05T15:00-05:00
Difference is
A duration
Example
68 hours
Zone
Required
Divide by 24
Never exact

Modern date libraries make the distinction a type: a plain date object that cannot hold a time, and an instant object that cannot be read as a date without a zone. The Temporal proposal for JavaScript is one published design of that separation, and whatever language the service is in, the same split is the one to copy.

What clock changes do to a moment-based count

In a zone that moves its clocks, in 2027, with each day measured from local midnight to the next local midnight:

Hours per local day in 2027

  • 13-14 March24
  • 14-15 March23
  • 6-7 November24
  • 7-8 November25
  • 1 March-1 April743
FromToHours
13 March14 March24
14 March15 March23
6 November7 November24
7 November8 November25
1 March1 April743

The last row is a whole month one hour short of 31 times 24.

A service that computes "days between" as hours divided by 24 returns 30.96 for March, then rounds or truncates, deciding something the caller did not ask.

A service that subtracts plain dates returns 31 and has no rounding to do.

The figures above are for a zone with a spring-forward in March and a fall-back in November; the pattern is the same in any zone that changes, with the dates shifted to that zone's own rules.

Zones move the date itself

2027-03-05T23:30:00-05:00 is 04:30 on 6 March in UTC. 2027-03-06T08:30:00+09:00 is 23:30 on 5 March in UTC. A service that receives a moment, converts it to UTC, and reads off the date part has moved one of those from Friday to Saturday and the other from Saturday to Friday.

For a between-dates operation the caller must either send plain dates, already read off in the governing zone, or send moments and name the zone the dates should be read in. A moment with no instruction about the zone is a date with two possible values.

The request

operation:      between
mode:           dates | moments
start:          2027-03-05                (mode dates)
end:            2027-03-08
zone:           "America/Chicago"         (mode moments only: the zone to read dates in)
unit:           calendar | working | hours
start_counts:   false
end_counts:     true
weekend:        "0000011"                 (working only, Monday to Sunday)
calendar:       { id, version }           (working only)
window:         { open: "09:00", close: "17:00" }   (hours only)

Date and moment text should follow the ISO 8601 profile, YYYY-MM-DD for dates and YYYY-MM-DDThh:mm:ss with an offset for moments; the W3C note on date and time formats sets out the profile most interfaces already accept. Reject any moment without an offset. Reject any plain date that carries a time.

The response

result:         3
unit:           calendar
applied:        { mode, zone, start_counts, end_counts, weekend, calendar }
skipped:        [ ...for working mode, the rest days and closures inside the span... ]

The applied block is the answer's provenance. The skipped list is the working shown, and it is where a wrong closure list becomes visible without anyone recomputing anything.

Test vectors

All March 2027, Saturday-Sunday rest days, nothing closed unless stated.

ModeStartEndUnit and conventionExpect
datesFriday 5thMonday 8thcalendar, exclusive3
datesFriday 5thMonday 8thcalendar, both ends in4
datesMonday 1stMonday 8thworking, both ends in6
datesMonday 1stMonday 15thworking, both ends in, Wednesday 10th closed10
datesSaturday 6thSunday 7thworking, both ends in0
momentsFriday 5th 15:00Monday 8th 11:00hours, 09:00 to 17:00 window4
momentsFriday 5th 15:00Monday 8th 11:00elapsed68
moments14 March 00:00 local15 March 00:00 local, changing zoneelapsed23

An implementation that returns 24 on the last row is computing in a fixed offset rather than a zone. One that returns 2.83 or 3 for the first row from moments has merged the two types.

Where this sits

The six readings a pair of dates can carry, before any interface is involved, are under counting between two dates. What changes when the two dates are in two countries, the working week and the closure list and the zone, is under counting across borders. The hour operation, and the spreadsheet formula that reproduces it, is under working hours.

The serial-versus-text contract when a sheet is one end of the call is under spreadsheet and API tools. The calendar the working mode needs, with its publisher and as-of date, is under how country calendars are made.

Common questions

Should the service default to a zone when none is sent?

No. A default zone is a silent assumption that produces a different date for callers in different places. Reject the request.

Can one call return both the date count and the hour count?

It can, and the two should be labeled as different quantities with different units rather than presented as one number and a conversion.

How should the service handle a start later than the end?

Return a negative count or an error, and say which in the documentation. Silently swapping the arguments hides a caller's mistake.

Is the skipped list needed for calendar mode?

No; nothing is skipped. It is needed for working mode, where it is the only part of the answer a reader can check against the calendar by eye.

More in Maintenance

Latest from Review Desk