Skip to content

Commit

Permalink
Fix rounding in Duration formatting. Stolen from @Rollczi <3
Browse files Browse the repository at this point in the history
  • Loading branch information
vLuckyyy committed Feb 14, 2025
1 parent 4a52d73 commit d3ea6de
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 68 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.eternalcode.commons;

import java.util.function.Supplier;

public class Lazy<T> implements Supplier<T> {

private Supplier<T> supplier;
private boolean initialized;
private T value;
private Exception exception;

public Lazy(T value) {
this.initialized = true;
this.value = value;
}

public Lazy(Supplier<T> supplier) {
this.supplier = supplier;
}

public static Lazy<Void> ofRunnable(Runnable runnable) {
return new Lazy<>(() -> {
runnable.run();
return null;
});
}

@Override
public synchronized T get() {
if (exception != null) {
throw new RuntimeException("Lazy value has been already initialized with exception", exception);
}

if (initialized) {
return value;
}

this.initialized = true;

try {
return this.value = supplier.get();
}
catch (Exception exception) {
this.exception = exception;
throw new RuntimeException("Cannot initialize lazy value", exception);
}
}

public boolean isInitialized() {
return initialized;
}

public boolean hasFailed() {
return exception != null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;

public class DurationParser extends TemporalAmountParser<Duration> {

Expand All @@ -26,20 +27,24 @@ public class DurationParser extends TemporalAmountParser<Duration> {
.withUnit("y", ChronoUnit.YEARS);

public DurationParser() {
super(LocalDateTimeProvider.now());
super(ChronoUnit.SECONDS, LocalDateTimeProvider.now());
}

public DurationParser(LocalDateTimeProvider localDateTimeProvider) {
super(localDateTimeProvider);
public DurationParser(ChronoUnit defaultZero) {
super(defaultZero, LocalDateTimeProvider.now());
}

private DurationParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
super(units, baseForTimeEstimation);
public DurationParser(ChronoUnit defaultZero, LocalDateTimeProvider localDateTimeProvider) {
super(defaultZero, localDateTimeProvider);
}

private DurationParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
super(defaultZero, units, modifiers, baseForTimeEstimation);
}

@Override
protected TemporalAmountParser<Duration> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
return new DurationParser(units, baseForTimeEstimation);
protected TemporalAmountParser<Duration> clone(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
return new DurationParser(defaultZero, units, modifiers, baseForTimeEstimation);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;

public class PeriodParser extends TemporalAmountParser<Period> {

Expand All @@ -15,20 +16,29 @@ public class PeriodParser extends TemporalAmountParser<Period> {
.withUnit("y", ChronoUnit.YEARS);

public PeriodParser() {
super(LocalDateTimeProvider.now());
super(ChronoUnit.DAYS, LocalDateTimeProvider.now());
}

public PeriodParser(ChronoUnit defaultZero) {
super(defaultZero, LocalDateTimeProvider.now());
}

public PeriodParser(LocalDateTimeProvider baseForTimeEstimation) {
super(baseForTimeEstimation);
super(ChronoUnit.DAYS, baseForTimeEstimation);
}


public PeriodParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) {
super(defaultZero, baseForTimeEstimation);
}

private PeriodParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
super(units, baseForTimeEstimation);
private PeriodParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
super(defaultZero, units, modifiers, baseForTimeEstimation);
}

@Override
protected TemporalAmountParser<Period> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
return new PeriodParser(units, baseForTimeEstimation);
protected TemporalAmountParser<Period> clone(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
return new PeriodParser(defaultZero, units, modifiers, baseForTimeEstimation);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.eternalcode.commons.time;

import com.eternalcode.commons.Lazy;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
Expand Down Expand Up @@ -40,51 +42,44 @@
*/
public abstract class TemporalAmountParser<T extends TemporalAmount> {

private static final Map<ChronoUnit, Long> UNIT_TO_NANO = new LinkedHashMap<>();
private static final Map<ChronoUnit, Integer> PART_TIME_UNITS = new LinkedHashMap<>();

private Set<ChronoUnit> roundedUnits = new HashSet<>();

public TemporalAmountParser<T> roundOff(ChronoUnit unit) {
this.roundedUnits.add(unit);
return this;
}
private static final Map<ChronoUnit, BigInteger> UNIT_TO_NANO = new LinkedHashMap<>();

static {
UNIT_TO_NANO.put(ChronoUnit.NANOS, 1L);
UNIT_TO_NANO.put(ChronoUnit.MICROS, 1_000L);
UNIT_TO_NANO.put(ChronoUnit.MILLIS, 1_000_000L);
UNIT_TO_NANO.put(ChronoUnit.SECONDS, 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.MINUTES, 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.HOURS, 60 * 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.DAYS, 24 * 60 * 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.WEEKS, 7 * 24 * 60 * 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.MONTHS, 30 * 24 * 60 * 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.YEARS, 365 * 24 * 60 * 60 * 1_000_000_000L);
UNIT_TO_NANO.put(ChronoUnit.DECADES, 10 * 365 * 24 * 60 * 60 * 1_000_000_000L);

PART_TIME_UNITS.put(ChronoUnit.NANOS, 1000);
PART_TIME_UNITS.put(ChronoUnit.MICROS, 1000);
PART_TIME_UNITS.put(ChronoUnit.MILLIS, 1000);
PART_TIME_UNITS.put(ChronoUnit.SECONDS, 60);
PART_TIME_UNITS.put(ChronoUnit.MINUTES, 60);
PART_TIME_UNITS.put(ChronoUnit.HOURS, 24);
PART_TIME_UNITS.put(ChronoUnit.DAYS, 7);
PART_TIME_UNITS.put(ChronoUnit.WEEKS, 4);
PART_TIME_UNITS.put(ChronoUnit.MONTHS, 12);
PART_TIME_UNITS.put(ChronoUnit.YEARS, Integer.MAX_VALUE);
UNIT_TO_NANO.put(ChronoUnit.NANOS, BigInteger.valueOf(1L));
UNIT_TO_NANO.put(ChronoUnit.MICROS, BigInteger.valueOf(1_000L));
UNIT_TO_NANO.put(ChronoUnit.MILLIS, BigInteger.valueOf(1_000_000L));
UNIT_TO_NANO.put(ChronoUnit.SECONDS, BigInteger.valueOf(1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.MINUTES, BigInteger.valueOf(60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.HOURS, BigInteger.valueOf(60 * 60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.DAYS, BigInteger.valueOf(24 * 60 * 60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.WEEKS, BigInteger.valueOf(7 * 24 * 60 * 60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.MONTHS, BigInteger.valueOf(30 * 24 * 60 * 60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.YEARS, BigInteger.valueOf(365 * 24 * 60 * 60 * 1_000_000_000L));
UNIT_TO_NANO.put(ChronoUnit.DECADES, BigInteger.valueOf(10 * 365 * 24 * 60 * 60 * 1_000_000_000L));
}

private final ChronoUnit defaultZero;
private final Lazy<String> defaultZeroSymbol;
private final Map<String, ChronoUnit> units = new LinkedHashMap<>();
private final Set<TimeModifier> modifiers = new HashSet<>();
private final LocalDateTimeProvider baseForTimeEstimation;

protected TemporalAmountParser(LocalDateTimeProvider baseForTimeEstimation) {
this.baseForTimeEstimation = baseForTimeEstimation;
protected TemporalAmountParser(ChronoUnit defaultZero, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation) {
this(defaultZero, baseForTimeEstimation);
this.units.putAll(units);
this.modifiers.addAll(modifiers);
}

protected TemporalAmountParser(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation) {
protected TemporalAmountParser(ChronoUnit defaultZero, LocalDateTimeProvider baseForTimeEstimation) {
this.defaultZero = defaultZero;
this.baseForTimeEstimation = baseForTimeEstimation;
this.units.putAll(units);
this.defaultZeroSymbol = new Lazy<>(() -> this.units.entrySet()
.stream()
.filter(entry -> entry.getValue() == defaultZero)
.map(entry -> entry.getKey())
.findFirst()
.orElseThrow(() -> new IllegalStateException("Can not find default zero symbol for " + defaultZero))
);
}

public TemporalAmountParser<T> withUnit(String symbol, ChronoUnit chronoUnit) {
Expand All @@ -98,14 +93,61 @@ public TemporalAmountParser<T> withUnit(String symbol, ChronoUnit chronoUnit) {

Map<String, ChronoUnit> newUnits = new LinkedHashMap<>(this.units);
newUnits.put(symbol, chronoUnit);
return clone(newUnits, this.baseForTimeEstimation);
return clone(this.defaultZero, newUnits, this.modifiers, this.baseForTimeEstimation);
}

public TemporalAmountParser<T> withLocalDateTimeProvider(LocalDateTimeProvider baseForTimeEstimation) {
return clone(this.units, baseForTimeEstimation);
return clone(this.defaultZero, this.units, this.modifiers, baseForTimeEstimation);
}

public TemporalAmountParser<T> withDefaultZero(ChronoUnit defaultZero) {
return clone(defaultZero, this.units, this.modifiers, this.baseForTimeEstimation);
}

protected abstract TemporalAmountParser<T> clone(Map<String, ChronoUnit> units, LocalDateTimeProvider baseForTimeEstimation);
public TemporalAmountParser<T> withRounded(ChronoUnit unit, RoundingMode roundingMode) {
return withTimeModifier(duration -> {
BigInteger nanosInUnit = UNIT_TO_NANO.get(unit);
BigInteger nanos = durationToNano(duration);
BigInteger rounded = round(roundingMode, nanos, nanosInUnit);

return Duration.ofNanos(rounded.longValue());
});
}

private static BigInteger round(RoundingMode roundingMode, BigInteger nanos, BigInteger nanosInUnit) {
BigInteger remainder = nanos.remainder(nanosInUnit);
BigInteger subtract = nanos.subtract(remainder);
BigInteger add = subtract.add(nanosInUnit);

BigInteger roundedUp = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? add : subtract.subtract(nanosInUnit));
BigInteger roundedDown = remainder.equals(BigInteger.ZERO) ? nanos : (nanos.signum() > 0 ? subtract : add.subtract(nanosInUnit));

int compare = remainder.abs().multiply(BigInteger.valueOf(2)).compareTo(nanosInUnit);
switch (roundingMode) {
case UP:
return roundedUp;
case DOWN:
return roundedDown;
case CEILING:
return nanos.signum() >= 0 ? roundedUp : roundedDown;
case FLOOR:
return nanos.signum() >= 0 ? roundedDown : roundedUp;
case HALF_UP:
return compare >= 0 ? roundedUp : roundedDown;
case HALF_DOWN:
return (compare > 0) ? roundedUp : roundedDown;
default: throw new IllegalArgumentException("Unsupported rounding mode " + roundingMode);
}
}


private TemporalAmountParser<T> withTimeModifier(TimeModifier modifier) {
Set<TimeModifier> newRoundedUnits = new HashSet<>(this.modifiers);
newRoundedUnits.add(modifier);
return clone(this.defaultZero, this.units, newRoundedUnits, this.baseForTimeEstimation);
}

protected abstract TemporalAmountParser<T> clone(ChronoUnit defaultZeroUnit, Map<String, ChronoUnit> units, Set<TimeModifier> modifiers, LocalDateTimeProvider baseForTimeEstimation);

private boolean validCharacters(String content, Predicate<Character> predicate) {
for (int i = 0; i < content.length(); i++) {
Expand Down Expand Up @@ -274,6 +316,9 @@ public ChronoUnit getUnit() {
public String format(T temporalAmount) {
StringBuilder builder = new StringBuilder();
Duration duration = this.toDuration(this.baseForTimeEstimation, temporalAmount);
for (TimeModifier modifier : this.modifiers) {
duration = modifier.modify(duration);
}

if (duration.isNegative()) {
builder.append('-');
Expand All @@ -284,31 +329,34 @@ public String format(T temporalAmount) {
Collections.reverse(keys);

for (String key : keys) {
ChronoUnit chronoUnit = this.units.get(key);

if (roundedUnits.contains(chronoUnit)) {
continue;
}

Long part = UNIT_TO_NANO.get(chronoUnit);
ChronoUnit unit = this.units.get(key);
BigInteger nanosInOneUnit = UNIT_TO_NANO.get(unit);

if (part == null) {
throw new IllegalArgumentException("Unsupported unit " + chronoUnit);
if (nanosInOneUnit == null) {
throw new IllegalArgumentException("Unsupported unit " + unit);
}

BigInteger currentCount = this.durationToNano(duration).divide(BigInteger.valueOf(part));
BigInteger maxCount = BigInteger.valueOf(PART_TIME_UNITS.get(chronoUnit));
BigInteger count = currentCount.equals(maxCount) ? BigInteger.ONE : currentCount.mod(maxCount);
BigInteger nanosCount = this.durationToNano(duration);
BigInteger count = nanosCount.divide(nanosInOneUnit);

if (count.equals(BigInteger.ZERO)) {
continue;
}

BigInteger nanosCountCleared = count.multiply(nanosInOneUnit);

builder.append(count).append(key);
duration = duration.minusNanos(count.longValue() * part);
duration = duration.minusNanos(nanosCountCleared.longValue());
}

String result = builder.toString();

if (result.isEmpty()) {
String defaultSymbol = this.defaultZeroSymbol.get();
return "0" + defaultSymbol;
}

return builder.toString();
return result;
}

protected abstract Duration toDuration(LocalDateTimeProvider baseForTimeEstimation, T temporalAmount);
Expand Down Expand Up @@ -340,11 +388,14 @@ static LocalDateTimeProvider of(LocalDate localDate) {

}

BigInteger durationToNano(Duration duration) {
protected interface TimeModifier {
Duration modify(Duration duration);
}

private BigInteger durationToNano(Duration duration) {
return BigInteger.valueOf(duration.getSeconds())
.multiply(BigInteger.valueOf(1_000_000_000))
.add(BigInteger.valueOf(duration.getNano()));
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;

import com.eternalcode.commons.time.TemporalAmountParser.LocalDateTimeProvider;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.Month;
Expand Down Expand Up @@ -199,7 +200,7 @@ void testRoundOff() {
.withUnit("m", ChronoUnit.MINUTES)
.withUnit("h", ChronoUnit.HOURS)
.withUnit("d", ChronoUnit.DAYS)
.roundOff(ChronoUnit.MILLIS);
.withRounded(ChronoUnit.MILLIS, RoundingMode.UP);

Duration temporalAmount = Duration.ofDays(1)
.plus(Duration.ofHours(1))
Expand Down

0 comments on commit d3ea6de

Please sign in to comment.