Last active
April 23, 2024 17:00
-
-
Save arshaw/36d3152c21482bcb78ea2c69591b20e0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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