Skip to content

Proposal: rounding and balancing for Duration type (replaces #789) #856

@justingrant

Description

@justingrant

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.

Proposal Summary

  • Extend the Temporal.Duration type with a round() method to perform balancing and/or rounding. Most rounding behavior is already defined in Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827, so this proposal focuses on what's different when applied to the Duration type.
  • Add rounding options to 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).
  • Add a compare method which takes a subset of the options above.

Use Cases

Common Use Cases (should have ergonomic solutions for these)

  • Normalization of existing durations, e.g. PT130M => PT2H10M
  • Rounding to nearest, e.g. PT10M52S => PT11M
  • Rounding up, e.g. PT10M52S => PT11M
  • Truncation (rounding towards zero), e.g. PT10M52S => PT10M
  • Format a duration in a normalized text format
  • Calculating totals (e.g. how many total seconds in PT2H34M18S)
  • Normalization that takes DST into account, e.g. convert PT1756H into days, assuming starting at midnight on a day that DST ends.
  • Normalization that ignores DST and treats every day as 24 hours.
  • Normalize days into months/years, e.g. P190D -> P6M12D (requires knowing the starting point and, optionally, the calendar)
  • Rounding to multiples of a unit, e.g. round to nearest 15 minutes or truncate to integer number of 3-month quarters
  • Non-ISO calendar-aware normalization of days into months, or months into years

Less Common Use Cases (mostly not supported)

  • (not supported, but could add later via option?) Fractional remainders, e.g. "How many days are in 36 hours?" => 1.5 days.
  • (not supported) "Get Remainder" / Top-end truncation, e.g. get time-only portion of a Duration, or fetch fractional seconds units.
  • (partially supported) Get a date-only or time-only portion of a duration. The former is easy today via {largestUnit: 'days'}. The latter is really just a special case of the "Get Remainder" case above.
  • (not supported) "Disjoint units" - some platforms store date/time values as a tuple of a "large" and "small" unit, where "large" is typically a day and "small" is usually the smallest unit of supported precision. For example, SQL Server's legacy date/time types:

(not supported) Values with the 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.

The 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.

Open Issues

  • How to handle the case where a duration is currently balanced, and then it's rounded away from zero? For example, 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.
  • Should Duration's 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.

Proposal

1. The Duration type will add a round() method that generally matches the behavior of round() on other types.

  • 1.1 The round method will have only one optional parameter: an options bag.
  • 1.2 Options are a superset of options used in the difference methods of Absolute, DateTime, etc. See Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827 for more info on options behavior and naming.
    • 1.2.1 largestUnit and smallestUnit will match the behavior defined in Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827 for the difference method of DateTime, including behavior affecting weeks as defined in Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827 section (7.7) EXCEPT...
      • 1.2.1.1 EDIT 2020-10-06 ...the default for smallestUnit / largestUnit options depends on the relativeTo option.
      • 1.2.1.2 EDIT 2020-10-06 If 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).
    • 1.2.1.3 EDIT 2020-10-08 If 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'.
    • 1.2.2 roundingMode and roundingIncrement will match the behavior defined in Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827, EXCEPT...
      • 1.2.2.1 The '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.
    • 1.2.3 As discussed in Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827, the behavior of these options will align with Intl.NumberFormatV3 for decisions around option naming, which rounding modes are supported, etc.
  • 1.3 Duration's round will add EDIT 2020-10-08 a new option: relativeTo. two additional options: relativeTo and overflow.

2. No overflow option EDIT 2020-10-06: removed this option after discussion with @ptomato

  • 2.1 The overflow: 'balance' | 'constrain' option behaves like the same option on from.
  • 2.2 If 'balance' then the resulting duration will be balanced.
  • 2.3 If 'constrain' (the default) will do no balancing.
  • 2.4 Duration's 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:
    • 2.4.1 Rounding forces at least some balancing anyways (e.g. 23.6 hours=>1 day) and it will be hard for users to understand why balancing due to rounding is performed but balancing down from larger units is not performed.
    • 2.4.2 It's not clear that 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.
    • 2.4.3 Even before adding rounding, the "balance constrain" algorithm was one of the hardest concepts in all of Temporal to understand and explain.
  • 2.5 For these reasons, there will be no 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.
    • 2.5.1 Note that the overflow option was already in plus and minus before this proposal. It will be removed from those methods.
  • 2.6 This change implies simplification in the docs for the duration balancing page. A PR is coming soon for that page.

3. relativeTo option

  • 3.1 This option defines the reference point used when balancing across a boundary where the number of smaller units per next-larger unit can vary. These boundaries include hours/days (because DST), days/months (month lengths vary), and months/years (non-ISO-calendar years may have 12 or 13 months).
    • 3.1.1 Example 2: P40D balances to P1M9D if relativeTo is 2020-01-01.
    • 3.1.2 Example 1: P40D balances to P1M11D if relativeTo is 2020-02-01
  • 3.2 In non-Duration types, the starting point is this so relativeTo isn't needed. That's why it was omitted from Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827.
  • 3.3 This option can be:
    • 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.3.3 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.
      • 3.3.3.1 If undefined is used, then all days will be assumed to be 24 hours long.
      • 3.3.3.2 If 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.
      • 3.3.3.3 EDIT 2020-10-06 If undefined is used, then a RangeError will be thrown if largestUnit in the input is 'weeks' or larger.
    • 3.3.4 Any other types will result in a RangeError
  • 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
  • 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'}.
    • 3.5.1 However, if 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. EDIT 2020-10-06: defaults are no longer controlled by relativeTo. See (1.2.1.2).

4. Usage in plus and minus

  • 4.1 Currently, duration arithmetic is limited to weeks or smaller units, and days are always assumed to be 24 hours which won't be accurate in a DST context. To resolve these limitations, one option would be to remove Duration arithmetic methods. Developers could use other types instead. For example, dur1.minus(dur2) could be replaced by ldt.plus(dur1).minus(dur2).difference(ldt).
  • 4.2 Another option, which is proposed below, is to add the same options available on 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.
  • 4.3 The 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}).
  • 4.4 If relativeTo is omitted, then largestUnit must be 'weeks' or smaller, or a RangeError will be thrown
  • 4.5 EDIT 2020-10-06 If relativeTo is undefined, then:
    • 4.5.1 The default largestUnit will be the largest non-zero unit in either input. (see 3.5.1 for more details)
    • 4.5.2 Throw a RangeError if either input has a nonzero value in '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

  • 5.1 The 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.
  • 5.2 If relativeTo is undefined, then compare will throw if either duration has a non-zero unit in months or years

6. No equals

  • 6.1 We decided not to offer 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

  • 7.1 On Duration methods, 'auto' means "the largest nonzero unit in the input(s)"
  • 7.2 On difference of 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 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.

Metadata

Metadata

Assignees

Labels

documentationAdditions to documentationnon-prod-polyfillTHIS POLYFILL IS NOT FOR PRODUCTION USE!spec-textSpecification text involved

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions