_This issue is based on what remained in #789 after extracting rounding of non-Duration types into #827. The proposal below focuses only on changes to the Duration type itself. Please review #827 before this one._
Temporal.Duration type with a round() method to perform balancing and/or rounding. Most rounding behavior is already defined in #827, so this proposal focuses on what's different when applied to the Duration type.plus and minus to ensure that duration arithmetic can be done safely even with units that have variable lengths like months, days (if there's DST), and years (for some non-ISO calendars). compare method which takes a subset of the options above.Common Use Cases (should have ergonomic solutions for these)
Less Common Use Cases (mostly not supported)
{largestUnit: 'days'}. The latter is really just a special case of the "Get Remainder" case above. datetime data type are stored internally by the SQL Server 2005 Database Engine as two 4-byte integers. The first 4 bytes store the number of days before or after the base date: January 1, 1900. The base date is the system reference date. The other 4 bytes store the time of day represented as the number of 1/300-second units after midnight.smalldatetime data type stores dates and times of day with less precision than datetime. The Database Engine stores smalldatetime values as two 2-byte integers. The first 2 bytes store the number of days after January 1, 1900. The other 2 bytes store the number of minutes since midnight.PT2H59M55S rounded to the nearest minute without balancing would be PT2H60M. This seems unexpected. Should the default be to balance, and users can opt out of balancing instead of opting in? Or should it always keep balanced durations balanced? _EDIT 2020-10-08_ Answer: Yes. Output durations will always be balanced except for the largest unit and except weeks will usually be zero. See below for more details. plus and minus methods accept a largestUnit of 'months' or 'years' if relativeTo is omitted? If yes, then there won't be any way to know at development time whether it will fail at runtime. If no, then failures will be found at development time, with the downside of disallowing cases which require no balancing like P1Y2M + P2Y3M? My inclination is to disallow these units in this case, to avoid unpredictable runtime behavior. See (4.4) below. _EDIT 2020-10-08_ Answer: we will require a relativeTo for these cases. 1. The Duration type will add a round() method that generally matches the behavior of round() on other types.
round method will have only one optional parameter: an options bag.difference methods of Absolute, DateTime, etc. See #827 for more info on options behavior and naming.largestUnit and smallestUnit will match the behavior defined in #827 for the difference method of DateTime, including behavior affecting weeks as defined in #827 section (7.7) EXCEPT...smallestUnit / largestUnit options depends on the relativeTo option.largestUnit is undefined or 'auto' then the default largestUnit will be the largest nonzero unit in the duration for round or the largest unit in either input for plus and minus. In other words, the default behavior is to limit durations to the largest unit that they started out with, with balancing happening underneath that unit. If the caller wants the equivalent of the now-removed overflow: 'balance' option to expand the duration beyond its current largest unit, then the caller should explicitly set largestUnit (and relativeTo if needed).weeks is nonzero in the input of round (or either input of plus/minus) then weeks will be filled in the output if needed. If weeks is zero in the input(s) then behavior matches behavior of non-Duration types which is that weeks will only be filled in the output if the user opts in via setting largestUnit: 'weeks' or smallestUnit: 'weeks'. roundingMode and roundingIncrement will match the behavior defined in #827, EXCEPT...'floor' and 'trunc rounding modes will behave differently in Duration, but they behave the same in non-Duration types where zero and negative infinity are considered the same.round will add _EDIT 2020-10-08_ a new option: relativeTo. relativeTo and overflow.2. No overflow option _EDIT 2020-10-06: removed this option after discussion with @ptomato_
overflow: 'balance' | 'constrain' option behaves like the same option on from.'balance' then the resulting duration will be balanced.'constrain' (the default) will do no balancing.overflow: 'constrain' option in plus and minus is currently used to control whether the output is fully balanced or minimally balanced. This behavior has a few problems, especially after rounding is in the mix:constrain matches the most common use case for unbalanced durations which is where only the largest unit is unbalanced, e.g. 45 days, 3 hours, and 10 minutes, or @jasonwilliams's case where the BBC measures all program lengths in seconds. It's not clear that "unbalanced in the middle" durations like "45 days and 180 minutes" are a real use case that we need to support in the results of plus, minus, and round.overflow options on plus, minus, or round. Instead, we'll optimize for the "unbalanced largest unit" use case by automatically setting the largestUnit default to prevent durations from growing into larger units by default. See (3.5.1) below for more details.overflow option was already in plus and minus before this proposal. It will be removed from those methods.3. relativeTo option
P40D balances to P1M9D if relativeTo is 2020-01-01. P40D balances to P1M11D if relativeTo is 2020-02-01this so relativeTo isn't needed. That's why it was omitted from #827.undefined, which allows rounding durations with days or smaller units without worrying about DST or starting points. This is the default behavior if no options bag is provided.undefined is used, then all days will be assumed to be 24 hours long. undefined is used, then a RangeError will be thrown if the duration has a non-zero months or years. TS typings should enforce this restriction.undefined is used, then a RangeError will be thrown if largestUnit in the input is 'weeks' or larger.LocalDateTime.from, then construct a new LocalDateTime instance from this value.DateTime.from, then construct a new DateTime instance from this value. relativeTo IS NOT undefined, then the defaults for for largestUnit and smallestUnit will be the full range of units: {largestUnit: 'years', smallestUnit: 'nanoseconds'}.relativeTo IS undefined then defaults will be {largestUnit: 'hours', smallestUnit: 'nanoseconds'}. These defaults avoid variable-length units like months or days in a DST time zone.4. Usage in plus and minus
dur1.minus(dur2) could be replaced by ldt.plus(dur1).minus(dur2).difference(ldt).round (including relativeTo) to plus and minus too. This would solve the DST issue and would also allow duration arithmetic to include all units including months and years.relativeTo option is applied to this, not to other. This allows chaining multiple operations with the same relativeTo, e.g. dur1.plus(dur2, {relativeTo: ldt}).plus(dur3, {relativeTo: ldt}).relativeTo is omitted, then largestUnit must be 'weeks' or smaller, or a RangeError will be thrownrelativeTo is undefined, then:largestUnit will be the largest non-zero unit in either input. (see 3.5.1 for more details)'weeks' or larger units OR if largestUnit is weeks or larger. This matches behavior of round (see 3.3.3.2 and 3.3.3.3) and compare (see 5.2).5. Usage with compare
compare algorithm is simple: round down nanoseconds and compare the results. In order to compare across unbalanced durations and/or to adjust for DST, compare will accept a relativeTo option which will behave identically to relativeTo in other methods above.relativeTo is undefined, then compare will throw if either duration has a non-zero unit in months or years 6. No equals
equals because equality is ambiguous for unbalanced durations (e.g. PT3600S vs PT60M) so we opted to simply support compare which will force users to think a little bit about what the comparison will mean.7. We'll add an 'auto' value for largestUnit in Duration and non-Duration difference methods where it's used. 'auto', like undefined, will select the default unit. _EDIT 2020-10-06: added this section_
'auto' means "the largest nonzero unit in the input(s)"difference of non-Duration types 'auto' means to pick the default unit of the type:'seconds''days''hours''hours''years' smallestUnit then set largestUnit = smallestUnit.8. _EDIT 2020-10-06: added this section_ round will require either smallestUnit or largestUnit to be explicitly provided and not undefined. This is because without one of these two options, the rounding operation would be a no-op. This is analogous to how we require smallestUnit on the round methods of non-duration types. A RangeError should be thrown if neither is provided.
Decisions in meeting 2020-09-11:
compare method to this proposal - see new section 5 aboveequals method - see new section 6 aboverelativeTo- see updated sections 3.3-3.5 above.relativeToIt occurred to me that we don't have to explicitly say that Temporal.Date is accepted as relativeTo. Passing a Temporal.Date is already covered by the fallback rule of "call Temporal.DateTime.from() on the value". (It results in a DateTime at midnight on that day.)
I suggest changing it to:
3.3.1 A Temporal instance of DateTime or LocalDateTime type.
- 3.3.1.1 If it's a DateTime, all days will be assumed to be 24 hours long.
- 3.3.1.2 However, if it's a LocalDateTime, then the time zone will be used to adjust for DST or other offset changes.
3.3.2 A property bag object or string that can be that can be parsed into a DateTime or LocalDateTime
3.4 If the value is an object property bag or string, then the value will be handled as follows:
3.4.1 If the value is valid for use with
LocalDateTime.from, then construct a new LocalDateTime instance from this value.3.4.2 Otherwise, if the value is valid for use with
DateTime.from, then construct a new DateTime instance from this value.3.4.4 Otherwise throw a RangeError
Agreed. Updated.
I've been trying to figure out what the correct balancing behaviour is for the years-months-weeks-days combination, for each value of largestUnit and overflow. For overflow: 'balance' I have something that seems consistent and makes sense, but for overflow: 'constrain' there is a surprising case, because unlike in difference(), you have to deal with the case where months and weeks are both given in the original duration.
It's best expressed in this table:
|
largestUnit ➡ overflow ⬇ |
years | months | weeks | days or lower |
|---|---|---|---|---|
| balance |
weeks down to days months up to years days up to years days up to months |
weeks down to days years down to months days up to months |
years down to days months down to days days up to weeks |
years down to days months down to days weeks down to days |
| constrain | no-op | years down to months |
years down to days months down to days (this one is strange) |
same as above |
The strange case is something like:
d = Temporal.Duration.from({ years: 1, months; 1, weeks: 1, days: 1 });
d = d.round({ largestUnit: 'weeks', overflow: 'constrain', relativeTo: '2020-01-01' });
You get "P1W398D", i.e. one week and 398 days (366 days in 2020 + 31 days in January 2021 + 1 day in the original duration). What's surprising is that you asked for weeks to be the largest unit, i.e. higher units are balanced _down_ into weeks, but the weeks are left untouched and you get a bunch of days.
The other option is "P57W6D", which is maybe less surprising, but then on the other hand violates the principle of overflow: 'constrain' not doing any upward balancing (2.3).
I think the former is correct, despite being surprising.
I admit I haven't fully understood the problem above (will take a closer reading than I have time for tonight) but does this section help?
- 1.2.1
largestUnitandsmallestUnitwill match the behavior defined in #827 for thedifferencemethod of DateTime, including behavior affectingweeksas defined in #827 section (7.7)
The referenced info in #827 section (7.7) is:
7.7 Weeks overlap with days and months in a duration. While there may be some cases where users might want weeks together with months and/or days (e.g. "3 months, 2 weeks, and 4 days") that's an uncommon case relative to the "3 months and 18 days" result that is likely expected. We'll handle these cases by setting weeks to zero when there's potential ambiguity between weeks vs. days/months. Specifically: if
largestUnitis'month'or'year'andsmallestUnitis'day'or smaller, then weeks will always be zero and only days and/or months will be included in the result. Edge cases that are NOT ambiguous are noted below.
- 7.7.1 If
smallestUnitis'week', then there is no ambiguity and any remainder will go intoweek.- 7.7.2 If
largestUnitis'day', then there is no ambiguity because weeks are excluded andweekwill be zero.- 7.7.3 In the degenerate case of
{ largestUnit: 'week', smallestUnit: 'day' }then there is also no ambiguity so both fields will be populated.
Does this help resolve the problem you found?
It doesn't, unfortunately - the difference is that in #827 we are using the options to decide how to express a duration that wasn't previously expressed by the user, so we can simply set weeks to 0 and move on. In Duration.round(), with overflow: 'balance' we can also set weeks to 0, but with overflow: 'constrain' we've set the user's expectation that no upwards balancing takes place.
3.5 If
relativeTois NOTundefined, then the defaults for forlargestUnitandsmallestUnitwill be the full range of units:{largestUnit: 'years', smallestUnit: 'nanoseconds'}.
- 3.5.1 However, if
relativeTois undefinedthen defaults will be{largestUnit: 'hours', smallestUnit: 'nanoseconds'}`. These defaults avoid variable-length units like months or days in a DST time zone.
I think we should reconsider this. If we implement this as described, then the method will attempt to balance any existing days, weeks, months, and years, down to hours, and fail because there is no relativeTo.
I'd propose making the default largestUnit always years, but just skip the balancing between hours, days, weeks, months, and years if relativeTo is not given. That would make the option simpler to explain to users, as well.
Sorry I missed this one. I'm off for the rest of the day (wedding anniversary!) but I'll take a look probably tomorrow.
Sent from my mobile device.
From: Philip Chimento notifications@github.com
Sent: Thursday, September 24, 2020 12:51:54 PM
To: tc39/proposal-temporal proposal-temporal@noreply.github.com
Cc: Justin Grant justin@justingrant.net; Author author@noreply.github.com
Subject: Re: [tc39/proposal-temporal] Proposal: rounding and balancing for Duration type (replaces #789) (#856)
3.5 If relativeTo is NOT undefined, then the defaults for for largestUnit and smallestUnit will be the full range of units: {largestUnit: 'years', smallestUnit: 'nanoseconds'}.
I think we should reconsider this. If we implement this as described, then the method will attempt to balance and existing days, weeks, months, and years, down to hours, and fail because there is no relativeTo.
I'd propose making the default largestUnit always years, but just skip the balancing between hours, days, weeks, months, and years if relativeTo is not given. That would make the option simpler to explain to users, as well.
—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHubhttps://github.com/tc39/proposal-temporal/issues/856#issuecomment-698554379, or unsubscribehttps://github.com/notifications/unsubscribe-auth/AACDVXUUG74B47BH5P4R7SLSHOPNVANCNFSM4QNZ2ITQ.
Happy anniversary! No rush, I am partially off today as well so I definitely won't finish this.
I'd propose making the default largestUnit always years, but just skip the balancing between hours, days, weeks, months, and years if relativeTo is not given. That would make the option simpler to explain to users, as well.
What I suggested above isn't a good solution either, because it brings back the problem where you sometimes get an exception and sometimes don't.
The problem is this:
d = Temporal.Duration.from({ years: 5, minutes: 5 })
d.round({ smallestUnit: 'hours' })
d.round({ largestUnit: 'hours', smallestUnit: 'hours' })
As a user, what I would expect to get out of line 2 is "5 years". What I would expect to get out of line 3 is an exception, because I asked for the largest unit in the result to be hours and that's not possible without a reference point. But according to what we have currently decided on, the result of both lines will be the same (and it's not clear to me which one it will be.)
Unless anyone objects soon, here's what I'll proceed with:
largestUnit is always "years". Since largestUnit: 'years', overflow: 'constrain' is a no-op according to the above table, relativeTo is not required for that combination of options.This is what will be the result of the examples that I gave in previous comments:
d = Temporal.Duration.from({ years: 1, months; 1, weeks: 1, days: 1 });
d.round({ largestUnit: 'weeks', relativeTo: '2020-01-01' }); // => P1W398D
d = Temporal.Duration.from({ years: 5, minutes: 5 })
d.round({ smallestUnit: 'hours' }) // => P5Y
d.round({ largestUnit: 'hours', smallestUnit: 'hours' }) // => exception, relativeTo missing
Quick poll, what do you expect the answer of this to be?
d = Temporal.Duration.from({ months: 1, weeks: 2 })
d.round({ largestUnit: 'days', smallestUnit: 'days', relativeTo: '2020-01-01' }) // => 45 days
d.round({ largestUnit: 'weeks', smallestUnit: 'days', relativeTo: '2020-01-01' }) // => ???
(And do you expect the answer to be different if overflow is balance or constrain?)
Sorry I forgot to follow up on your questions!
For the case above, my expectation was that you'd get P6W3D in that case, because there's no ambiguity about what the user is asking for.
But I admit that I haven't thought through the cases for constrain to the depth that you have. So it might be easiest to resolve via a quick real-time meeting. I'd be happy to jump on a quick Zoom call with you and we could figure it out. Interested?
Yes, let's do that! I'm about to close up shop for the day, but I'll email you about tomorrow.
@ptomato and I chatted about the problems he found above. The core issue is how to deal with the case where there needs to be some balancing due to rounding but where the user has the default 'constrain' option.
In that case, there are three possible bad outcomes we could choose from:
relativeTo is required in all cases. I think this is bad because most durations are likely to be time-only, so this would be adding an ergonomics tax in those cases. Also, often there simply is no relativeTo-- for example when adding together a bunch of stopwatch readings without any associated context.relativeTo would have been required (if largestUnit >= weeks or if duration has a nonzero field >= weeks). IMHO, this is the least bad because it would only happen with very long durations which are already unusual to be doing math with. Also, AFAIK it's already similar to limits that we're already imposing on plus/minus and compare. Here's some specific proposed changes:
Remove the overflow option from all duration methods except from and with. Always balance, because when you're manipulating a duration (as opposed to just setting fields) then it's unintuitive to start with a balanced duration and end up with an unbalanced one. Also, rounding forces at least some balancing anyways (e.g. 23.6 hours=>1 day) and it will be hard for users to understand why some balancing is performed but other balancing is not.
If largestUnit is undefined then the default largestUnit should be the largest nonzero unit in the duration (or the largest in either duration for plus/minus). In other words, the default behavior is to limit durations to the "height" that they started out with. If the caller wants the equivalent of overflow: 'balance' to "grow" the duration beyond its current largest unit, then the caller should explicitly set largestUnit (and relativeTo if needed).
2.1 Like the non-Duration implementation, if smallestUnit is larger than the default largestUnit, then set largestUnit to smallestUnit.
If relativeTo is undefined, then throw a RangeError if largestUnit is 'weeks' or larger. This is the same as what's already specified for plus/minus in (4.4) above.
If relativeTo is undefined, then throw a RangeError if the input duration (either input for plus/minus) has a nonzero value in 'weeks' or larger. This is the same behavior as what's already specified for compare in (5.2) above.
This proposal implies a simplification in the docs for duration balancing. I'd be happy to help with these docs changes! EDIT: see #978.
We'll add an 'auto' value for largestUnit in Duration and non-Duration difference methods where it's used. 'auto' means the same as undefined.
'auto' means "the largest nonzero unit in the input(s)"6.2 On difference of non-Duration types 'auto' means the default unit:
smallestUnit)smallestUnit)smallestUnit)round will require either smallestUnit or largestUnit to be explicitly provided (not undefined). This is because without one of these two options, the rounding operation would be a no-op. This is analogous to how we require smallestUnit on the round methods of non-duration types.
I updated the OP proposal with the changes noted above. Search for "2020-10-06" to find the specific sections that were changed/added.
I've got the new algorithm working and I'm ready to start writing spec text.
The only inconsistency I've found is that the points in #827 about when weeks should end up being 0 in the result of difference() methods, cannot be applied to Duration.round(), because otherwise you end up messing with how the user chose to express days, weeks, and months. Example, if you follow the rules in #827 then you get this, which I think we clearly don't want:
d = Temporal.Duration.from({ months: 1, weeks: 1, days: 1 });
d.round({ smallestUnit: 'days', relativeTo: '2020-01-01' }); // => P1M8D
Otherwise it seems OK.
@ptomato Example, if you follow the rules in #827 then you get this, which I think we clearly don't want:
d = Temporal.Duration.from({ months: 1, weeks: 1, days: 1 }); d.round({ smallestUnit: 'days', relativeTo: '2020-01-01' }); // => P1M8D
If that's an unwanted result, I assume that you'd expect the "wanted" result to be P1M1W1D, correct? If yes, then what would you expect for the code below?
d = Temporal.Duration.from({ months: 1, days: 6, hours: 20 });
d.round({ smallestUnit: 'days', relativeTo: '2020-01-01' }); // => P1M7D? P1M1W?
Problem: it's hard to guess whether the caller wants weeks in the result or not. My guess is that most of the time, if the input didn't already have weeks then it's unexpected to see weeks in the output unless the caller specifically asked for weeks in largestUnit or smallestUnit. So I think the expected result in the case above would be P1M7D.
I could see a few ways to get the outcome of P1M7D for the second one and P1M1W1D for the first:
includeWeeks: boolean smallestUnit or above (truncation only) then don't balance and simply return a clone of the input.smallestUnit or above (truncation only) then don't balance and simply return a clone of the input.Other than a specific opt-in option, I think the other choices above are more unpredictable than the behavior in #827, which is that 'weeks' is zero in the output unless the caller opts in using in smallestUnit: 'weeks' or largestUnit: 'weeks' (or if the largest unit in the input is weeks which defaults largestUnit to 'weeks').
Also, different topic to capture before I forget it: how are you thinking to handle rounding for adding negative durations with a relativeTo? I'm specifically wondering if order of operations is problematic because subtraction is supposed to do a smallest-units-first order of operations. Are you doing the reverse order in the implementation? If not, is that order-of-operations even compatible with rounding? BTW, this is closely related to the problem that I still haven't figured out yet that with difference in ZonedDateTime.
I could see a few ways to get the outcome of
P1M7Dfor the second one andP1M1W1Dfor the first:
- If weeks are nonzero in the input, then balancing should include weeks in the output, otherwise not
This is how I've done it. I haven't come across any surprising results this way.
how are you thinking to handle rounding for adding negative durations with a
relativeTo? I'm specifically wondering if order of operations is problematic because subtraction is supposed to do a smallest-units-first order of operations. Are you doing the reverse order in the implementation? If not, is that order-of-operations even compatible with rounding?
I haven't started working on addition and subtraction yet, so I'll have to keep that in mind.
- If weeks are nonzero in the input, then balancing should include weeks in the output, otherwise not
I'm OK with this. I updated the proposal text:
- 1.2.1.3 _EDIT 2020-10-08_ If
weeksis nonzero in the input ofround(or either input ofplus/minus) then weeks will be filled in the output if needed. Ifweeksis zero in the input(s) then behavior matches behavior of non-Duration types which is thatweekswill only be filled in the output if the user opts in via settinglargestUnit: 'weeks'orsmallestUnit: 'weeks'.
Just to make sure, this behavior requires relativeTo, correct? (Because relativeTo is required whenever largestUnit is weeks or larger per 3.3.3.3)
I was excited to work on the duration balancing docs tonight and then I noticed #974. I figured that should probably wait until that issue is resolved before editing the balancing docs. ;-(
I've had to make one more adjustment: the default for largestUnit is _the larger of_ the largest non-zero unit in the input duration, compared to smallestUnit. (otherwise this throws, unexpectedly: Temporal.Duration.from({ days: 365 }).round({ smallestUnit: 'years', relativeTo: '2020-01-01' }))
I've had to make one more adjustment: the default for
largestUnitis _the larger of_ the largest non-zero unit in the input duration, compared tosmallestUnit. (otherwise this throws, unexpectedly:Temporal.Duration.from({ days: 365 }).round({ smallestUnit: 'years', relativeTo: '2020-01-01' }))
@ptomato - how is this different from (7) ?
7. We'll add an
'auto'value forlargestUnitin Duration and non-Durationdifferencemethods where it's used.'auto', likeundefined, will select the default unit. _EDIT 2020-10-06: added this section_
- 7.1 On Duration methods,
'auto'means "the largest nonzero unit in the input(s)"7.2 On
differenceof non-Duration types'auto'means to pick the default unit of the type:
- 7.2.1 Instant:
'seconds'- 7.2.2 Date / DateTime -
'days'- 7.2.3 ZonedDateTime -
'hours'- 7.2.4 Time -
'hours'- 7.2.5 YearMonth -
'years'- 7.3 If the default chosen above is smaller than the user-provided
smallestUnitthen setlargestUnit = smallestUnit.
Somehow I had missed 7.3. Never mind.
Here's a checklist of what's left to do on this issue:
relativeTo option to Duration.add() and Duration.subtract().relativeTo option in Duration.round(), add(), subtract(), and compare().Opened #1171 for the last item on the checklist as it is the same as the last item on #569's checklist as well. This can close as soon as #1163 lands.