Skip to content

Proposal: Rounding method (and rounding for difference and toString) for non-Duration types #827

@justingrant

Description

@justingrant

This proposal is half of the rounding problem focused on non-Duration types. The other half (rounding methods on the Duration type itself) is in #856. Originally both halves were contained in #789 but we split into two proposals because the Duration parts took longer to reach consensus.

Summary

This proposal:

  • Adds a round() method to every non-Duration Temporal type that supports math operations.
  • Adds rounding options to the difference method of the same types, in order to support rounding of the results of difference.
  • Defines rounding options that will be used by the Duration type too (see Proposal: rounding and balancing for Duration type (replaces #789) #856 that depends on this proposal).

Open Issues to Resolve

  • Should we support rounding of months and years units? (see discussion in comments) Note that weeks and era are already not supported (see -- only months/years are in question. NO
  • What should be the default rounding mode? Half up? Half even? HALF UP
  • Should with() have rounding options? (see discussion in comments) NO
  • Should roundingIncrement require an exact divisor of the next-largest unit? YES, EXCEPT: ABSOLUTE TYPES HAVE SPECIAL RULES, and LOCALDATETIME FOR HOURS USES CLOCK HOURS, NOT ABSOLUTE HOURS.
  • Related: how should roundingIncrement handle cases where the next-largest unit has a variable size? (e.g. month length in days, year length in months for non-ISO calendars) N/A
  • Is it OK that toString includes rounding options? MAYBE - MOVED to Why do toString methods truncate? #329
  • Is it OK that toString controls decimal precision of sub-second units (and whether to show seconds at all) based on smallestUnit? MAYBE - MOVED
  • Should toLocaleString accept the same rounding options as toString()? N/A
  • Is difference ready? Or does it need to wait for the rounding in Duration proposal (Proposal: rounding method & total method for Duration type #789) to be fully resolved? FWIW, I'd prefer not to block it if we don't have to. YES
  • Because the default (see below) for smallestUnit is the smallest unit that the type supports, calling round() with no parameters will return a clone of the same instance but won't change the value. Is this OK? NO.. IT'S REQUIRED.
  • difference could theoretically choose either this or other as the reference point for rounding. I'm assuming that it should use this. YES. HAS CONSENSUS.

Sample Use Cases

round(options)

  • Round the current time to the nearest 5 minutes
Temporal.now.time().round({ smallestUnit: 'minute', roundingIncrement: 5 })
  • What's the closest Saturday to this Date? - We removed week-related rounding from the scope of this proposal. The workaround is to calculate using dayOfWeek directly.
  • Remove the seconds and smaller units from this Time
time.round({ smallestUnit: 'minute', roundingMode: 'trunc' })
  • Round this time to the nearest second
time.round({ smallestUnit: 'second' })
  • What was the first day of this calendar quarter? - We chose to remove rounding for weeks and larger units due to their complexity (especially in non-ISO calendars) and their relatively easy workarounds. The workaround would be to do the 3-month rounding math directly using the property values and with and/or from.
  • How many billable hours did a lawyer spend on a phone call, rounded up to the nearest 5 minutes?
endAbsolute.difference(startAbsolute, { smallestUnit: 'minutes', roundingIncrement: 5, roundingMode: 'ceil' })
  • How many days was a rental car rented for, rounded up to the nearest hour?
endLocalDateTime.difference(startLocalDateTime, { smallestUnit: 'hour', roundingMode: 'ceil' })

Details

1. round() for non-Duration types

  • 1.1 Rounding is focused on time. The following types that include time will get a round method: DateTime, Absolute, Time, and LocalDateTime.
    • 1.1.1 Because we'll only be rounding times, not dates, the Date, YearMonth, and MonthDay types won't get a round() method.
  • 1.2 Duration will also likely get a round method, but that's out of scope for this proposal. See Proposal: rounding method & total method for Duration type #789.
  • 1.3 round() will accept only one parameter, options, which is a property bag of options that control rounding. Each option is described below.
    • 1.3.1 options is a required parameter because one of the options, smallestUnit (see below) cannot be omitted.
  • 1.4 We should use the same rounding modes and option names that will be used by Decimal and by Intl.NumberFormat (What rounding modes to include? proposal-intl-numberformat-v3#7). @sffc is driving Intl.NumberFormat so alignment should be do-able. Per @sffc, we don't need to wait for either proposal-- the first of the three out of the gate will define naming used by the others.

2. smallestUnit option

  • 2.1 The smallestUnit property defines where rounding happens. All smaller time units will be zero in the result.
  • 2.2 There is no default. A RangeError will be thrown if smallestUnit is omitted.
  • 2.3 If smallestUnit is larger than largestUnit, then a RangeError should be thrown.
  • 2.4 smallestUnit should accept both plural and non-plural unit names because non-Duration types' fields aren't pluralized. This may be a reason to accept either plural or non-plural names in other places too. See Naming of Temporal.Duration fields #325.
  • 2.5 Even though both plural and non-plural units will be accepted, the docs should show singular units in samples because non-Duration field names are singular.
  • 2.7 era or any non-ISO calendar custom fields will throw a RangeError, because it's not possible to round those units without significant changes to Temporal.Calendar. Also that there's likely minimal demand for rounding these units.
  • 2.8 The Absolute type doesn't have a time zone so we'll limit its smallestUnit to 'minutes' or smaller to support the primary Absolute rounding use case which is to handle underlying storage that has limited precision at the low end.
  • 2.9 For Time, smallestUnit can be any valid unit for this type: 'hours' or smaller.
  • 2.10 For DateTime and LocalDateTime, smallestUnit must be 'days' or smaller. We decided to exclude larger units because the rounding behavior of weeks and months is confusingly calendar-dependent.

5. roundingMode option

  • 5.1 This option defines how the remainder is handled: e.g. round up, round down, round to nearest, etc. There are a surprisingly large number of possible rounding modes (e.g. round down vs. round towards zero for negative numbers, how to handle 0.5 remainders, etc.).
    • 5.1.1 There are two other potential users of rounding modes in JS: the Decimal proposal and Intl.NumberFormat V3. Temporal will align with these proposals for naming and modes available, so that all of JS would have consistent rounding syntax.
    • 5.1.2 Per @sffc, NumberFormat V3 is the furthest ahead of all three proposals so we'll tentatively plan for the naming and rounding-mode decisions to be made there first, although if Temporal makes them first them @sffc will help to align Intl.
  • 5.2 Possible option names include (pending alignment with those other proposals): "roundingMode", "rounding", "roundingType", "roundingMethod". This proposal uses "roundingMode" but the name may change.
  • 5.3 The following rounding modes will be supported. Initial naming is aligned with the Math object, although per above the names may change as we align with Intl.
    • 5.3.1 'ceil' - round up towards +∞
    • 5.3.2 'floor' - round down towards -∞
    • 5.4.3 'trunc' - round towards zero. Note that "zero" implies "the big bang", not UNIX epoch or 1 B.C. This means that 'floor' and 'trunc' act the same for non-Duration types because zero and -∞ are the same from Temporal's point of view. Note that they will not act differently when used in the Duration type (see Proposal: rounding and balancing for Duration type (replaces #789) #856) because durations can be negative.
    • 5.4.4 'nearest' - round to nearest integer, with 0.5 rounded away from zero. This is named differently from round, to differentiate from Math.round that rounds 0.5 remainders towards +∞ which is often unexpected for negative numbers per MDN:

If the fractional portion is exactly 0.5, the argument is rounded to the next integer in the direction of +∞. Note that this differs from many languages' round() functions, which often round this case to the next integer away from zero, instead giving a different result in the case of negative numbers with a fractional part of exactly 0.5.

  • 5.4 The default rounding mode is 'nearest' which means "half away from zero" because it's least surprising for a method called round. Note that the name of this option may change depending on what is decided in What rounding modes to include? proposal-intl-numberformat-v3#7 (comment). @sffc owns coordinating with Intl and finalizing both rounding mode naming and which rounding modes are offered in both places.
  • 5.6 It's possible that Intl may choose to add additional rounding modes, and IMHO we should adopt those if they're added. However, it's a safe assumption that the 4 modes above will be included.

6. roundingIncrement option

  • 6.1 The roundingIncrement allows rounding with a coarser granularity than one unit, e.g. "nearest 15 minutes", "nearest second".
  • 6.2 The default rounding increment is 1
  • 6.3 This option will be limited to exact divisors of the next-largest unit. For example, if smallestUnit is minute, then roundingIncrement could be either 1 (default), 2, 3, 4, 5, 6, 10, 12, 15, 20, or 30. Others must throw a RangeError.
  • 6.4 There's been discussion of wanting to enable future non-ISO calendars that can introduce custom time measurement, which might add the same problem about variable number of minutes/seconds/etc. @sffc believes that this could be resolved, either by using increments relative to the calendar system, or we could reset the increment at each boundary of the next-largest unit. So we won't worry about non-ISO calendars relative to this feature.
  • 6.5 Regardless of rounding mode or increment, the rounding increment will be measured in ascending order starting from zero of smallestUnit.
  • 6.6 When applied to LocalDateTime, roundingIncrement will use clock time, not absolute time, to determine the result. If a DST transition is inside the increment, the absolute length of the increment can be longer or shorter (by the length of the DST transition) than the user-specified roundingIncrement value. Also, if the result's clock time is ambiguous, then it's disambiguated using the default ('compatible') disambiguation option. For example:
ldt = Temporal.LocalDateTime.from('2020-03-08T03:30-07:00[America/Los_Angeles]');
ldt.round({ smallestUnit: 'hour', roundingIncrement: 3, roundingMode: 'ceil' }); 
  // => 2020-03-08T06:00-07:00[America/Los_Angeles] 
  //      (result is 06:00 even though it's only 5 hours past midnight)
ldt.round({ smallestUnit: 'hour', roundingIncrement: 2, roundingMode: 'trunc' }); 
  // => 2020-03-08T03:00-07:00[America/Los_Angeles]
  //      (result is 03:00 because 02:00-08:00 is disambiguated to 03:00-07:00)
  • 6.7 Absolute has a few differences from behavior of other types:
    • 6.7.1 As noted above, rounding on Absolute is limited to minutes or smaller units. If developers want to round an Absolute to an hour, they can just use {roundingIncrement: 60}.
    • 6.7.2 The reference point for rounding increments for Absolute is the UNIX epoch.
    • 6.7.3 All Absolute rounding increments must divide evenly into one 24-hour day (86400 seconds). Unlike the increments in the other types' round() methods, Absolute rounding increments can also be equal (not less than like other types) to 86400 seconds.

7. Rounding options in non-Duration difference methods

  • 7.1 Currently, difference methods throughout Temporal accept a largestUnit option.
  • 7.2 With this proposal, types that get a round method will also enhance the difference method to also accept the same options that round accepts: smallestUnit, roundingMode, and roundingIncrement.
  • 7.3 These options will behave the same as in round, with the only exceptions noted below.
    • 7.3.1 smallestUnit defaults to nanoseconds and is optional.
  • 7.4 If largestUnit is not specified but smallestUnit is larger than its default value, then largestUnit should be adjusted to be the same as smallestUnit.
    • 7.4.1 However, if smallestUnit is larger than an explicitly specified largestUnit, then a RangeError should be thrown.
  • 7.5 Unit names are plural e.g. 'days' in the docs, but both plural and singular units should be accepted.
  • 7.6 All units supported by the existing largestUnit option will be supported in smallestUnit too.
  • 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 largestUnit is 'month' or 'year' and smallestUnit is '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 smallestUnit is 'week', then there is no ambiguity and any remainder will go into week.
    • 7.7.2 If largestUnit is 'day', then there is no ambiguity because weeks are excluded and week will 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions