How to go about computing the number of days between two Dates? I've tried using Temporal.Date.difference(), but the resulting Duration is 'lossy' in that I'm not able to get the true number of days between the dates.
Consider:
(new Temporal.Date(2020, 1, 1)).difference(new Temporal.Date(2020, 2, 1)).toString()
-> P1M
How to get 31 days instead?
(new Temporal.Date(2020, 2, 1)).difference(new Temporal.Date(2020, 3, 1)).toString()
-> P1M
How to get 29 days instead?
Of course, these are overly simplified examples, but hopefully they convey the point. I just want to know the number of days between two Dates, with the library handling the calendar stuff like days-in-month and leap years.
I think the current spec would return P31D / P29D, but the polyfill hasn't been updated to match.
This happened in response to the review comment at https://github.com/tc39/proposal-temporal/pull/161#discussion_r333620610; we should avoid putting months in the Duration object at all. This means the code in DifferenceDate should be simplified quite a bit. Some tests in test/date.mjs will need to be updated as well.
Per #120, difference is supposed to have a parameter for capping the largest element in its output... did that get dropped?
@gibson042 No, it just has a default value now.
@pdunkel removed that from the polyfill in https://github.com/tc39/proposal-temporal/commit/742b666964813f2f35bc75e8ccbbf2cf950fce62; it's currently unused in the spec.
Huh, was there justification for this documented anywhere?
I've started looking at this. After updating the polyfill to match the current spec I found a similar issue with year lengths (due to leap years).
> (new Temporal.Date(2019, 1, 1)).difference(new Temporal.Date(2020, 1, 1)).toString()
'P1Y'
> (new Temporal.Date(2020, 1, 1)).difference(new Temporal.Date(2021, 1, 1)).toString()
'P1Y'
> (new Temporal.Date(2019, 6, 1)).difference(new Temporal.Date(2020, 6, 1)).toString()
'P1Y1D'
> (new Temporal.Date(2020, 6, 1)).difference(new Temporal.Date(2021, 6, 1)).toString()
'P364D'
I could see making a case for having days be the highest unit returned by difference(), since years have the same problem as months in that they sometimes have different numbers of days, depending on when your starting point is. However, it seems to me that would be pretty awful when dealing with dates many years apart. For example if I want to find out my age then it seems that myBirthDate.difference(now) would be a pretty useless value if given in days.
In any case it seems to me that the third and fourth examples above are wrong.
@ptomato This is absolutely correct! Once 2 dates are converted to a duration, it is inherently lossy. You cannot continue to perform time duration conversions (eg days->months, months->days, months->years, years->months) once the original 2 dates are no longer available. That's because any such conversions must take into account the 2 dates.
Bottom line, is that if Duration must continue to support further conversions after initial construction, it must retain the 2 dates used originally in the call to .difference.
Alternatively, the Duration (or some related new type) could be 'locked' after construction, so that it represents the duration in units originally conveyed to .difference(), and that's it. If you want something in different units, you have to call difference() again the desired unit.
I have a work-in-progress branch that makes the polyfill work like this, with the cutoff parameter:
> (new Temporal.Date(2019, 1, 1)).difference(new Temporal.Date(2020, 1, 1), 'days').toString()
'P365D'
> (new Temporal.Date(2020, 1, 1)).difference(new Temporal.Date(2021, 1, 1), 'days').toString()
'P366D'
> (new Temporal.Date(2019, 6, 1)).difference(new Temporal.Date(2020, 6, 1), 'days').toString()
'P366D'
> (new Temporal.Date(2020, 6, 1)).difference(new Temporal.Date(2021, 6, 1), 'days').toString()
'P365D'
> (new Temporal.Date(2019, 1, 1)).difference(new Temporal.Date(2020, 1, 1), 'months').toString()
'P12M'
> (new Temporal.Date(2020, 1, 1)).difference(new Temporal.Date(2021, 1, 1), 'months').toString()
'P12M'
> (new Temporal.Date(2019, 6, 1)).difference(new Temporal.Date(2020, 6, 1), 'months').toString()
'P12M'
> (new Temporal.Date(2020, 6, 1)).difference(new Temporal.Date(2021, 6, 1), 'months').toString()
'P12M'
> (new Temporal.Date(2019, 1, 1)).difference(new Temporal.Date(2020, 1, 1), 'years').toString()
'P1Y'
> (new Temporal.Date(2020, 1, 1)).difference(new Temporal.Date(2021, 1, 1), 'years').toString()
'P1Y'
> (new Temporal.Date(2019, 6, 1)).difference(new Temporal.Date(2020, 6, 1), 'years').toString()
'P1Y'
> (new Temporal.Date(2020, 6, 1)).difference(new Temporal.Date(2021, 6, 1), 'years').toString()
'P1Y'
@BrandonLWhite Duration already doesn't have any methods to do any further conversions, so I think your concern is addressed.
I implemented the same for Temporal.DateTime.difference() on my work-in-progress branch. I experimented a bit with allowing smaller units than days in the cutoff parameter, e.g. myBirthTime.difference(now, 'seconds') to get the number of seconds I've been alive. But that seems a bit excessive as I can easily get that number by passing days as the cutoff which always gives a non-lossy duration.
I checked Temporal.Absolute.difference() and according to the current spec it will never return a lossy duration with years or months in it. I wonder if we should change it to have a cutoff parameter as well?
Supporting fine-granularity cutoffs is not necessary, but might lead to a more friendly API. Consider
hoursToGo = (function(duration) {
let { days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds } = duration;
return Temporal.Duration.from({
hours: days * 24 + hours,
minutes, seconds, milliseconds, microseconds, nanoseconds
});
})(Temporal.now.absolute().difference(deadline));
vs.
hoursToGo = deadline.difference(Temporal.now.absolute(), "hours");
The current idea is to have difference return a Duration d between a and b where a before b such that a.plus(d) equals b and b.minus(d) results in a.
Nothing further. So difference in specific units or the like wasn't included. I have no problem with extending difference to be more capable though.
The current idea is to have
differencereturn aDurationdbetweenaandbwhereabeforebsuch thata.plus(d)equalsbandb.minus(d)results ina.
I think the OP's point was that although that may still hold for a duration with units of months or years, the resulting duration is less useful for other purposes. (Like, for four dates a, b, c, d, figuring out whether a.difference(b) is a longer or shorter duration than c.difference(d).)
So it makes sense to me that we should default to returning the "maximally precise" duration in days, and allow opting into the "lossy" (although useful for other use cases) months/years.
Supporting fine-granularity cutoffs is not necessary, but might lead to a more friendly API.
@gibson042 If we do that, then I'd maybe consider only supporting the cutoff down to the unit of seconds; otherwise if you have nanosecondsToGo = deadline.difference(now, 'nanoseconds') then I believe nanosecondsToGo.nanoseconds would have to be a BigInt whereas currently it's spec'ed to be an integer 0–999.
It should definitely be possible to do things like Temporal.Duration.from({seconds: 86400}), but I don't think the result should be P1D or even PT24H rather than PT86400S to match input. As for Number vs. BigInt, I have made the same point myself in #120 ("_Even if the ranges do match, we may want Duration properties to be BigInts. new Duration({ nanoseconds: Number.MAX_SAFE_INTEGER }) is only 104 days._"), but I think that is an API design consideration rather than a decisive constraint.
@gibson042 That starts to get into the problem of "we need to spec CreateDuration()", since we have the balance/constrain/reject parameter in new Duration() but not in Duration.from() and it's not clear what behaviour should be in Duration.from().
What would be a good way to move forward on this? I see several options ranked from most to least conservative:
Date.difference() and DateTime.difference(). (This fixes the problem with months, but still retains the problem with leap years)Date.difference() and DateTime.difference(), supporting values of years, months, and days. (This gets rid of all "lossy" Durations returned from a difference method.)hours and lower, for the cutoff parameter of DateTime.difference(). Also consider adding a cutoff parameter for the other classes' difference methods. (This would add convenience for people wanting to calculate durations in smaller units.)Duration.from() is.My feeling is that number 4 needs to be done, but I'm not sure it makes sense to postpone fixing the original problem until after it's sorted out. 1 through 3 seem to me like equally valid resolutions to this issue.
@ptomato Thanks for the easy to understand summary. As an application developer, I would want at least #2.
I don't see why difference methods need to be prohibited from returning nonzero months and years values if their use is subject to author control.
Temporal.Date.from("2019-01-01").difference(Temporal.Date.from("2020-01-01")) == "P1Y"
Temporal.Date.from("2019-01-01").difference(Temporal.Date.from("2020-01-01"), "days") == "P365D"
Temporal.Date.from("2019-06-01").difference(Temporal.Date.from("2020-06-01")) == "P1Y"
Temporal.Date.from("2019-06-01").difference(Temporal.Date.from("2020-06-01"), "days") == "P366D"
Temporal.Date.from("2020-01-01").difference(Temporal.Date.from("2021-01-01")) == "P1Y"
Temporal.Date.from("2020-01-01").difference(Temporal.Date.from("2021-01-01"), "days") == "P366D"
Temporal.Date.from("2020-06-01").difference(Temporal.Date.from("2021-06-01")) == "P1Y"
Temporal.Date.from("2020-06-01").difference(Temporal.Date.from("2021-06-01"), "days") == "P365D"
They would need to return only days and lower if we didn't have the cutoff parameter; that is, if we chose to do only item 1 from the list above. Once we add back the cutoff parameter (items 2 and higher) the example code you wrote above is exactly what I had in mind. (Only with days as a non-lossy default for the cutoff parameter)
Meeting Jan. 27: We'll do items 1 through 3 (with the understanding that the cutoff parameter will only go down to seconds at this time, since allowing smaller units would possibly require changing the data model to use BigInts) and I'll open another issue for point 4.
It's worth noting that the actual math arithmetic is performed by the Calendar object. A cutoff (largest field) parameter is good. It will be up to Calendar implementations to support it properly.
I'd like to get the ball rolling on this one again. The pull request (#333) has been up for review for a while but looking at it again with fresh eyes I think it needs more work. The open question in the pull request was whether the cutoff parameter should be added to all the other types' difference methods. I believe it makes sense to do that. Here's what I'd propose for each type:
| type | current polyfill behaviour | current spec behaviour | proposed behaviour |
| ---- | ---- | ---- | ---- |
| Absolute | cutoff at days | cutoff at days | default cutoff days, calculate years/months from start date in UTC? |
| DateTime | no cutoff, calculate years/months from start date | not specified | default cutoff days, calculate years/months from start date |
| Date | no cutoff, calculate years/months from start date | no cutoff, months always 0 | default cutoff days, calculate years/months from start date |
| Time | cutoff at days, but omit days from result | cutoff at days | result always <12 hours, see #330; default cutoff can be days, doesn't matter as long as it's hours or higher |
| YearMonth | no cutoff, days and lower always 0 | no cutoff, days and lower always 0 | default cutoff of days doesn't make sense here, and years/months without days aren't ambiguous, so default cutoff of years? (calculate days from first of month?) |
(Note that arithmetic was recently removed from MonthDay, per #261)
I'd also like to bikeshed largestElementInResult into largestUnitInResult since I think "unit" (of time) is more specific than "element" which could also be confused with "array element".
I like the proposed behavior for DateTime and Date and Time, but I don't think Absolute difference should return anything larger than days (because variable-length units don't exist in its calendar-free model) or YearMonth difference should return anything _smaller_ than months (because that precision just doesn't exist in its model). The corresponding recipes for dealing with those gaps would be something like absolute1.inTimeZone("UTC").difference(absolute2.inTimeZone("UTC"), { largestUnit: "months" }) and yearMonth1.withDay(1).difference(yearMonth2.withDay(1)).
I'm not sure I get your meaning, do you mean that passing "years" or "months" to Absolute.difference, and "days", "hours", etc. to YearMonth.difference, should be prohibited? Or do you mean that those recipes you mentioned are used when those values are passed in? (If the latter, I think that's the same as what I was saying.)
I could see a potential use for asking for years in Absolute.difference: it seems sensible to answer "How old am I exactly?" with Temporal.Absolute.from('<my birthdate and time><time zone where I was born>').difference(Temporal.Absolute.now(), largestUnitInResult: 'years').
I'm not sure I get your meaning, do you mean that passing "years" or "months" to Absolute.difference, and "days", "hours", etc. to YearMonth.difference, should be prohibited?
Yes. Definitely for the latter because yearMonth1.difference(yearMonth2, {largestUnit: "days"}) cannot possibly be respected in general, but also for the former because absolute1.difference(absolute2, {largestUnit: "months"}) won't ever produce months so it's misleading to accept that input.
Or do you mean that those recipes you mentioned are used when those values are passed in? (If the latter, I think that's the same as what I was saying.)
No, those recipes are for authors to use when they want a difference in months from Temporal.Absolute instances or a difference in days from Temporal.YearMonth instances, and only if the assumptions implicit in those recipes are acceptable to them.
I could see a potential use for asking for years in Absolute.difference: it seems sensible to answer "How old am I exactly?" with
Temporal.Absolute.from('<my birthdate and time><time zone where I was born>').difference(Temporal.Absolute.now(), largestUnitInResult: 'years').
It _seems_ sensible, but gets tripped up by e.g. leap days. Temporal.Absolute.from("2021-02-01T00:00Z").difference(Temporal.Absolute.from("2020-02-01T00:00Z")).toString() is "P366D", if we had specified a largest unit of "years" would that be "P1Y" or "P1Y1D"? Temporal.Absolute instances are intended to be free of such ambiguity.
OK, got it, here's an updated table:
| type | current polyfill behaviour | current spec behaviour | proposed behaviour |
| ---- | ---- | ---- | ---- |
| Absolute | cutoff at days | cutoff at days | default cutoff days seconds, years/months forbidden |
| DateTime | no cutoff, calculate years/months from start date | not specified | default cutoff days, calculate years/months from start date |
| Date | no cutoff, calculate years/months from start date | no cutoff, months always 0 | default cutoff days, calculate years/months from start date, _edit: hours and lower forbidden_ |
| Time | cutoff at days, but omit days from result | cutoff at days | result always <12 hours, see #330; default cutoff days (doesn't matter as long as it's hours or higher) |
| YearMonth | no cutoff, days and lower always 0 | no cutoff, days and lower always 0 | default cutoff years, days and lower forbidden |
It _seems_ sensible, but gets tripped up by e.g. leap days.
Temporal.Absolute.from("2021-02-01T00:00Z").difference(Temporal.Absolute.from("2020-02-01T00:00Z")).toString()is "P366D", if we had specified a largest unit of "years" would that be "P1Y" or "P1Y1D"? Temporal.Absolute instances are intended to be free of such ambiguity.
I'd consider it expected to calculate the years/months using the this value in UTC as the start date, but I see how that could be confusing if the programmer were not thinking of their Absolute value as UTC. I guess the way to implement my use case would be to use your recipe from above:
const birthDate = Temporal.Absolute.from('<my birthdate and time><time zone where I was born>');
birthDate.inTimeZone('UTC').difference(Temporal.Absolute.now().inTimeZone('UTC'), 'years');
Looks pretty good, but I would also forbid a cutoff of "hours" or lower in Temporal.Date difference for the same reason that YearMonth difference forbids a cutoff of "days" or lower (lack of that precision in the data model).
Sketch of some cutoff-related test cases
// Temporal.Absolute.prototype.difference defaults to returning days and smaller units
// and rejects larger-unit cutoffs.
assert.strictEqual(
Temporal.Absolute.from("2021-02-01T00:00Z").difference(
Temporal.Absolute.from("2020-02-01T00:00Z")).toString(),
"P366D");
assert.strictEqual(
Temporal.Absolute.from("2021-02-01T00:00Z").difference(
Temporal.Absolute.from("2020-02-01T00:00Z"), {largestUnit: "hours"}).toString(),
"PT8784H");
assert.throws(
() => Temporal.Absolute.from("2021-02-01T00:00Z").difference(
Temporal.Absolute.from("2020-02-01T00:00Z"), {largestUnit: "months"}));
assert.throws(
() => Temporal.Absolute.from("2021-02-01T00:00Z").difference(
Temporal.Absolute.from("2020-02-01T00:00Z"), {largestUnit: "years"}));
assert.strictEqual(
Temporal.Absolute.from("2021-02-01T00:00:00.000000001Z").difference(
Temporal.Absolute.from("2020-02-01T00:00Z")).toString(),
"P366DT0.000000001S");
assert.strictEqual(
Temporal.Absolute.from("2021-02-01T00:00Z").difference(
Temporal.Absolute.from("2020-02-01T00:00:00.000000001Z")).toString(),
"P365DT23H59M59.999999999S");
// Temporal.DateTime.prototype.difference defaults to returning days and smaller units
// and does not include units that are smaller than necessary.
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z")).toString(),
"P366D");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z"), {largestUnit: "hours"}).toString(),
"PT8784H");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z"), {largestUnit: "months"}).toString(),
"P12M");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z"), {largestUnit: "years"}).toString(),
"P1Y");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00:00.000000001Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z")).toString(),
"P366DT0.000000001S");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00:00.000000001Z")).toString(),
"P365DT23H59M59.999999999S");
assert.strictEqual(
Temporal.DateTime.from("2021-02-28T00:00Z").difference(
Temporal.DateTime.from("2020-02-29T00:00Z"), {largestUnit: "days"}).toString(),
"P365D");
assert.strictEqual(
Temporal.DateTime.from("2021-02-28T00:00Z").difference(
Temporal.DateTime.from("2020-02-29T00:00Z"), {largestUnit: "months"}).toString(),
"P11M30D");
assert.strictEqual(
Temporal.DateTime.from("2021-02-28T00:00Z").difference(
Temporal.DateTime.from("2020-02-29T00:00Z"), {largestUnit: "years"}).toString(),
"P11M30D");
// Temporal.Date.prototype.difference defaults to returning days
// and rejects smaller-unit cutoffs
// and does not include units that are smaller than necessary.
assert.strictEqual(
Temporal.Date.from("2021-02-01T00:00Z").difference(
Temporal.Date.from("2020-02-01T00:00Z")).toString(),
"P366D");
assert.throws(
() => Temporal.Date.from("2021-02-01T00:00Z").difference(
Temporal.Date.from("2020-02-01T00:00Z"), {largestUnit: "hours"}));
assert.strictEqual(
Temporal.Date.from("2021-02-01T00:00Z").difference(
Temporal.Date.from("2020-02-01T00:00Z"), {largestUnit: "months"}).toString(),
"P12M");
assert.strictEqual(
Temporal.DateTime.from("2021-02-01T00:00Z").difference(
Temporal.DateTime.from("2020-02-01T00:00Z"), {largestUnit: "years"}).toString(),
"P1Y");
assert.strictEqual(
Temporal.Date.from("2021-02-28T00:00Z").difference(
Temporal.Date.from("2020-02-29T00:00Z"), {largestUnit: "days"}).toString(),
"P365D");
assert.strictEqual(
Temporal.Date.from("2021-02-28T00:00Z").difference(
Temporal.Date.from("2020-02-29T00:00Z"), {largestUnit: "months"}).toString(),
"P11M30D");
assert.strictEqual(
Temporal.Date.from("2021-02-28T00:00Z").difference(
Temporal.Date.from("2020-02-29T00:00Z"), {largestUnit: "years"}).toString(),
"P11M30D");
// Temporal.YearMonth.prototype.difference defaults to returning years
// and rejects cutoffs of days or smaller.
assert.strictEqual(
Temporal.YearMonth.from("2021-02-01T00:00Z").difference(
Temporal.YearMonth.from("2020-02-01T00:00Z")).toString(),
"P1Y");
assert.throws(
() => Temporal.YearMonth.from("2021-02-01T00:00Z").difference(
Temporal.YearMonth.from("2020-02-01T00:00Z"), {largestUnit: "hours"}));
assert.throws(
() => Temporal.YearMonth.from("2021-02-01T00:00Z").difference(
Temporal.YearMonth.from("2020-02-01T00:00Z"), {largestUnit: "days"}));
assert.strictEqual(
Temporal.YearMonth.from("2021-02-01T00:00Z").difference(
Temporal.YearMonth.from("2020-02-01T00:00Z"), {largestUnit: "months"}).toString(),
"P12M");
assert.strictEqual(
Temporal.YearMonth.from("2021-02-01T00:00Z").difference(
Temporal.YearMonth.from("2020-02-01T00:00Z"), {largestUnit: "years"}).toString(),
"P1Y");
If we define a day as being 24 hours as I suggested in #389, then we could allow a cutoff of hours in Temporal.Date differences. Otherwise, if we want to leave the concept of "day" calendar-dependent, then I would suggest that we consider making seconds the default cutoff for Absolute differences and disallow anything higher than hours.
I disagree that Temporal.Date difference could support a largest unit of "hours", because the type by definition does not include that level of precision and there's no good basis for assuming that the same time of day should apply to two instances. An author that is comfortable with that assumption should make it explicit by mapping to Temporal.DateTime like date1.withTime("00:00").difference(date2.withTime("00:00"), {largestUnit: "hours"}).
Let's suppose we define a day as being equal to 24 hours (#389). Then, taking the difference between two dates and expressing it in hours is like taking the difference between 5 feet and 3 feet and expressing it in inches. You assume 5 feet means the inch component is zero, and likewise, the time component of a Temporal.Date is zero. So, I think there is a well-defined mechanism here.
That being said, we can consider this issue an edge case for which we don't care as much about API ergonomics, in which case the workaround @gibson042 proposed is reasonable.
You assume 5 feet means the inch component is zero, and likewise, the time component of a Temporal.Date is zero
That directly contradicts the documentation:
A
Temporal.Dateobject represents a calendar date. This means there is no way to convert this to an absolute point in time, however combining with aTemporal.TimeaTemporal.DateTimecan be obtained
Assuming time of day to be 00:00 is well-defined, it's just not supported by the data model.
OK. I get it now. I think that basically answers #389 also.
In that case, I would like to go with what I suggested above for Absolute: defaulting the cutoff to seconds (or milliseconds), and forbid units greater than hours for Absolute difference.
I'm not including support for cutoffs of milliseconds and lower in this pull request, because that has other effects like losing precision in the fields of Duration, and how they are represented in toString() without becoming lossy. I'll open a follow up issue for that ("no. 4" in https://github.com/tc39/proposal-temporal/issues/307#issuecomment-577440493) when this one is settled.
If we consider days to be lossy for Absolutes because of their calendar-independence; I'm not convinced but that's probably because I haven't properly thought about the problem space yet, so I'll think about it some more. My first thought is that it decreases the usefulness of Absolute.difference in the ISO calendar case, and has implications for plus() and minus() that make them less useful in the ISO calendar case as well. I think in particular this depends on the resolution of #357.
I'll attach a commit to the pull request implementing this suggestion, in any case, so we can still go either way.
That said: why a default of seconds and not the highest non-lossy unit as in the other types? In this case that would be hours.
Meeting Feb. 27: We don't want to forbid taking Absolute.difference in days after all, since there are use cases ("days/hours/minutes/seconds countdown until World Cup starts"), but it shouldn't be the default. We'll open a follow up issue to discuss removing DateTime arithmetic, since any result you get from it is potentially wrong due to DST changes, but that needs more discussion.
Action: Open the follow up issue, change the PR to allow Absolute.difference in days, and then merge this.
Most helpful comment
I have a work-in-progress branch that makes the polyfill work like this, with the cutoff parameter:
@BrandonLWhite Duration already doesn't have any methods to do any further conversions, so I think your concern is addressed.