Skip to content

Instantly share code, notes, and snippets.

@arshaw
Last active April 23, 2024 17:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arshaw/36d3152c21482bcb78ea2c69591b20e0 to your computer and use it in GitHub Desktop.
Save arshaw/36d3152c21482bcb78ea2c69591b20e0 to your computer and use it in GitHub Desktop.
/*
Called towards the end of Duration::round when either:
- "relativeTo" is a ZonedDateTime
- "relativeTo" is a PlainDate
Also called towards the end of PlainDateTime/PlainDate::since/until when largestUnit > day
Also called towards the end of ZonedDateTime::since/until when largestUnit >= day
Avoids concepts of balancing/unbalancing/normalizing that the original algorithm uses,
a lot of which seems repetitive. Instead, leverages epoch-nanosecond comparisons.
Both more performant and smaller code size.
*/
export function roundRelativeDuration(
// Must already be balanced. This should be achieved by calling one of the non-rounding
// since/until internal methods prior. It's okay to have a bottom-heavy weeks
// because weeks don't bubble-up into months. It's okay to have >24 hour day
// assuming the final day of relativeTo+duration has >24 hours in its timezone.
// (should automatically end up like this if using non-rounding since/until internal methods prior)
duration,
// The "relativeTo" argument (for Duration::round)
// or the starting-date for any since/until method
originTimeZone, // only populated if ZonedDateTime
originPlainDateTime, // ZonedDateTime/PlainDate coerced to PlainDateTime
// The "relativeTo + duration" (for Duration::round),
// which is needed for rebalancing anyway,
// or the ending-date for any since/until method
// If a ZonedDateTime, use its epochNanoseconds
// If a Plain type, use its epochNanoseconds in UTC
destEpochNs,
// Typical rounding options
largestUnit,
smallestUnit,
roundingInc,
roundingMode,
) {
// The duration after expanding/contracting, though NOT yet rebalanced
let nudgedDuration
// The destEpochNs after expanding/contracting
let nudgedEpochNs
// Did nudging cause the duration to expand to the next day or larger?
let didExpandCalendarUnit
// Rounding an irregular-length unit? Use epoch-nanosecond-bounding technique
if (
(!originTimeZone && smallestUnit > Unit.Day) || // Plain
(originTimeZone && smallestUnit >= Unit.Day) // Zoned
) {
[nudgedDuration, nudgedEpochNs, didExpandCalendarUnit] = nudgeToCalendarUnit(
duration,
originTimeZone,
originPlainDateTime,
destEpochNs,
smallestUnit,
roundingInc,
roundingMode,
)
// Special-case for rounding time units within a zoned day
} else if (originTimeZone && smallestUnit < Unit.Day) {
[nudgedDuration, nudgedEpochNs, didExpandCalendarUnit] = nudgeToZonedTime(
duration,
originTimeZone,
originPlainDateTime,
smallestUnit,
roundingInc,
roundingMode,
)
// Rounding uniform-length days/hours/minutes/etc units. Simple nanosecond math
} else {
[nudgedDuration, epochNsDelta, didExpandCalendarUnit] = nudgeToDayOrTime(
duration,
largestUnit,
smallestUnit,
roundingInc,
roundingMode,
)
nudgedEpochNs = destEpochNs + epochNsDelta
}
// Bubble-up smaller units into higher ones,
// except for weeks, which don't balance up into months
if (didExpandCalendarUnit && smallestUnit !== Unit.Week) {
nudgedDuration = bubbleRelativeDuration(
nudgedDuration,
nudgedEpochNs,
originTimeZone,
originPlainDateTime,
largestUnit, // where to STOP bubbling
Math.max(smallestUnit, Unit.Day), // where to START bubbling-up from
)
}
return nudgedDuration
}
// Part I: Nudging Functions
// -----------------------------------------------------------------------------
// (Expands or contracts a duration, but does not rebalance it)
/*
Epoch-nanosecond bounding technique where the start/end of the calendar-unit
interval are converted to epoch-nanosecond times and destEpochNs is nudged to either one.
*/
function nudgeToCalendarUnit(
duration,
originTimeZone, // not guaranteed
originPlainDateTime,
destEpochNs,
smallestUnit, // >= day
roundingInc,
roundingMode,
) {
// Create a duration with smallestUnit trunc'd towards zero
let startDuration = clearDurationUnitsSmallerThan(duration, smallestUnit)
startDuration[smallestUnit] = Math.trunc(duration[smallestUnit] / roundingInc) * roundingInc
// Create a separate duration that incorporates roundingInc
let endDuration = cloneDuration(startDuration)
endDuration[smallestUnit] += roundingInc * duration.sign
// Apply to origin, output PlainDateTimes
let startDateTime = originPlainDateTime.add(startDuration)
let endDatetime = originPlainDateTime.add(endDuration)
// Convert to epoch-nanoseconds
let startEpochNs = plainDateTimeToEpochNs(startDateTime, originTimeZone)
let endEpochNs = plainDateTimeToEpochNs(endDatetime, originTimeZone)
// Round the smallestUnit within the epcoh-nanosecond span
let progress = (destEpochNs - startEpochNs) / (endEpochNs - startEpochNs)
let unroundedUnit = startDuration[smallestUnit] + progress * duration.sign
let roundedUnit = roundByMode(unroundedUnit / roundingInc, roundingMode) * roundingInc
startDuration[smallestUnit] = roundedUnit
// Determine whether expanded or contractred
let didExpand = Math.sign(roundedUnit - unroundedUnit) === duration.sign
let nudgedEpochNs = didExpand ? endEpochNs : startEpochNs
let nudgedDuration = didExpand ? endDuration : startDuration
return [nudgedDuration, nudgedEpochNs, didExpand]
}
/*
Attempts rounding of time units within a time zone's day, but if the rounding
causes time to exceed the total time within the day, rerun rounding in next day
For original implementation, see AdjustRoundedDurationDays:
https://github.com/tc39/proposal-temporal/blob/e78869aa86dffe0d05287d21484cb002ad00968d/polyfill/lib/ecmascript.mjs#L5305-L5312
*/
function nudgeToZonedTime(
duration,
originTimeZone, // guaranteed
originPlainDateTime,
smallestUnit, // < day
roundingInc,
roundingMode,
) {
// Frame a day with durations
let startDuration = clearDurationUnitsSmallerThan(duration, Unit.Day)
let endDuration = cloneDuration(startDuration)
endDuration[Unit.Day] += duration.sign
// Apply to origin, output start/end of the day as PlainDateTimes
let startDateTime = originPlainDateTime.add(startDuration)
let endDateTime = originPlainDateTime.add(endDuration)
// Compute the epoch-nanosecond start/end of the final whole-day interval
// If duration has negative sign, startEpochNs will be after endEpochNs
let startEpochNs = plainDateTimeToEpochNs(startDateTime, originTimeZone)
let endEpochNs = plainDateTimeToEpochNs(endDateTime, originTimeZone)
// The signed amount of time from the start of the whole-day interval to the end
let daySpanNs = endEpochNs - startEpochNs
// Compute time parts of the duration to nanoseconds and round
// Result could be negative
let timeNs = durationTimeFieldsToNs(duration)
let roundedTimeNs = roundByMode(timeNs / nsInUnit[smallestUnit] / roundingInc, roundingMode) * roundingInc
// Does the rounded time exceed the time-in-day?
let beyondDayNs = roundedTimeNs - daySpanNs
let didRoundBeyondDay = Math.sign(beyondDayNs) !== -duration.sign
let dayDelta
let nudgedEpochNs
// If time not rounded beyond day, use the day-start as the local origin
if (!didRoundBeyondDay) {
dayDelta = 0
nudgedEpochNs = startEpochNs + roundedTimeNs
// Otherwise, if rounded into next day, use the day-end as the local origin
// and rerun the rounding
} else {
dayDelta = 1
roundedTimeNs = roundByMode(beyondDayNs / nsInUnit[smallestUnit] / roundingInc, roundingMode) * roundingInc
nudgedEpochNs = endEpochNs + roundedTimeNs
}
let nudgedDuration = Temporal.Duration.from({
...duration,
days: duration.days + dayDelta,
...nsToDurationTimeFields(roundedTimeNs),
})
return [nudgedDuration, nudgedEpochNs, didRoundBeyondDay]
}
/*
Assumes duration only has day and time values.
Converts all fields to nanoseconds and does integer rounding.
*/
function nudgeToDayOrTime(
duration,
largestUnit,
smallestUnit, // <= day
roundingInc,
roundingMode,
) {
// Convert to nanoseconds and round
let ns = durationDayAndTimeFieldsToNs(duration)
let roundedNs = roundByMode(ns / nsInUnit[smallestUnit] / roundingInc, roundingMode) * roundInc
let diffNs = roundedNs - ns
// Determine if whole days expanded
let wholeDays = Math.trunc(ns / nsInUnit[Unit.Day])
let roundedWholeDays = Math.trunc(roundedNs / nsInUnit[Unit.Day])
let didExpandDays = Math.sign(roundedWholeDays - wholeDays) === sign
let nudgedDuration = Temporal.Duration.from({
...duration,
...nsToDurationDayOrTimeFields(roundedTimeNs, largestUnit),
})
return [nudgedDuration, diffNs, didExpandDays]
}
// Part II: Bubble Function
// -----------------------------------------------------------------------------
/*
Given a potentially bottom-heavy duration, bubble up smaller units to larger units.
Any units smaller than smallestUnit are already zeroed-out.
*/
function bubbleRelativeDuration(
nudgedDuration,
nudgedEpochNs,
originTimeZone,
originPlainDateTime,
largestUnit,
smallestUnit,
) {
let { sign } = nudgedDuration
let balancedDuration = nudgedDuration // tentative result
// Check to see if nudgedEpochNs has hit the boundary of any units higher than
// smallestUnit, in which case increment the higher unit and clear smaller units.
for (let unit = smallestUnit + 1; unit <= largestUnit; unit++) {
// The only situation where days and smaller bubble-up into weeks is when largestUnit:'week'
// (Not be be confused with the situation where smallestUnit:'week', in which case days and smaller
// are ROUNDED-up into weeks, but that has already happened by the time this function executes)
// So, if days and smaller are NOT bubbled-up into weeks, and the current unit is weeks, skip.
if (unit === Unit.Week && largestUnit !== Unit.Week) {
continue
}
let startDuration = clearDurationUnitsSmallerThan(balancedDuration, smallestUnit)
let endDuration = cloneDuration(startDuration)
endDuration[unit] += sign
// Compute end-of-unit in epoch-nanoseconds
let endDateTime = originPlainDateTime.add(endDuration)
let endEpochNs = plainDateTimeToEpochNs(endDateTime, originTimeZone)
let beyondEndNs = nudgedEpochNs - endEpochNs
let didExpandToEnd = Math.sign(beyondEndNs) !== -sign
// Is nudgedEpochNs at the end-of-unit?
// This means it should bubble-up to the next highest unit (and possibly further...)
if (didExpandToEnd) {
balancedDuration = endDuration
// NOT at end-of-unit. Stop looking for bubbling
} else {
break
}
}
return balancedDuration
}
// Utils
// -----------------------------------------------------------------------------
// Many of these utilities already exist in some form in the original code
function plainDateTimeToEpochNs(
plainDateTime,
timeZone, // if not supplied, consider UTC
) {
if (timeZone) {
return timeZone.getInstantFor(plainDateTime).epochNanoseconds
}
return computeUTCEpochNs(plainDateTime)
}
function durationTimeFieldsToNs(duration) {
// convert hours/minutes/seconds/etc to total nanoseconds
}
function nsToDurationTimeFields(ns) {
// convert nanoseconds to an object with {hours,minutes,seconds,etc...}
}
function nsToDurationDayOrTimeFields(ns, unit) {
// `unit` can be days/hours/minutes/seconds/etc
// convert nanoseconds to an object with highest-unit `unit`
}
function clearDurationUnitsSmallerThan(duration, unit) {
// what you'd expect
}
function cloneDuration(duration) {
// what you'd expect
}
function computeUTCEpochNs(plainDateTime) {
// what you'd expect
}
function roundByMode(fractionalNumber, roundingMode) {
// what you'd expect
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment