diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java index f869bac0a..052377d7e 100644 --- a/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java +++ b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java @@ -32,6 +32,7 @@ package li.strolch.utils.time; import static java.time.temporal.ChronoUnit.*; +import static li.strolch.utils.time.PeriodHelper.daysIn; import java.io.Serializable; import java.time.*; @@ -40,612 +41,683 @@ import java.time.chrono.IsoChronology; import java.time.format.DateTimeParseException; import java.time.temporal.*; import java.util.*; +import java.util.concurrent.TimeUnit; /** * An amount of time in the ISO-8601 calendar system that combines a period and a duration. *

- * This class models a quantity or amount of time in terms of a {@code Period} and {@code Duration}. - * A period is a date-based amount of time, consisting of years, months and days. - * A duration is a time-based amount of time, consisting of seconds and nanoseconds. - * See the {@link Period} and {@link Duration} classes for more details. + * This class models a quantity or amount of time in terms of a {@code Period} and {@code Duration}. A period is a + * date-based amount of time, consisting of years, months and days. A duration is a time-based amount of time, + * consisting of seconds and nanoseconds. See the {@link Period} and {@link Duration} classes for more details. *

- * The days in a period take account of daylight saving changes (23 or 25 hour days). - * When performing calculations, the period is added first, then the duration. + * The days in a period take account of daylight saving changes (23 or 25 hour days). When performing calculations, the + * period is added first, then the duration. *

* The model is of a directed amount, meaning that the amount may be negative. * *

Implementation Requirements:

* This class is immutable and thread-safe. *

- * This class must be treated as a value type. Do not synchronize, rely on the - * identity hash code or use the distinction between equals() and ==. + * This class must be treated as a value type. Do not synchronize, rely on the identity hash code or use the distinction + * between equals() and ==. */ -public final class PeriodDuration - implements TemporalAmount, Serializable { +public final class PeriodDuration implements TemporalAmount, Serializable, Comparable { - /** - * A constant for a duration of zero. - */ - public static final PeriodDuration ZERO = new PeriodDuration(Period.ZERO, Duration.ZERO); + /** + * A constant for a duration of zero. + */ + public static final PeriodDuration ZERO = new PeriodDuration(Period.ZERO, Duration.ZERO); - /** - * A serialization identifier for this class. - */ - private static final long serialVersionUID = 8815521625671589L; - /** - * The supported units. - */ - private static final List SUPPORTED_UNITS = - Collections.unmodifiableList(Arrays.asList(YEARS, MONTHS, DAYS, SECONDS, NANOS)); - /** - * The number of seconds per day. - */ - private static final long SECONDS_PER_DAY = 86400; + /** + * A serialization identifier for this class. + */ + private static final long serialVersionUID = 8815521625671589L; + /** + * The supported units. + */ + private static final List SUPPORTED_UNITS = Collections + .unmodifiableList(Arrays.asList(YEARS, MONTHS, DAYS, SECONDS, NANOS)); + /** + * The number of seconds per day. + */ + private static final long SECONDS_PER_DAY = 86400; - /** - * The period. - */ - private final Period period; - /** - * The duration. - */ - private final Duration duration; + /** + * The period. + */ + private final Period period; + /** + * The duration. + */ + private final Duration duration; - //----------------------------------------------------------------------- - /** - * Obtains an instance based on a period and duration. - *

- * The total amount of time of the resulting instance is the period plus the duration. - * - * @param period the period, not null - * @param duration the duration, not null - * @return the combined period-duration, not null - */ - public static PeriodDuration of(Period period, Duration duration) { - Objects.requireNonNull(period, "The period must not be null"); - Objects.requireNonNull(duration, "The duration must not be null"); - return new PeriodDuration(period, duration); - } + //----------------------------------------------------------------------- - /** - * Obtains an instance based on a period. - *

- * The duration will be zero. - * - * @param period the period, not null - * @return the combined period-duration, not null - */ - public static PeriodDuration of(Period period) { - Objects.requireNonNull(period, "The period must not be null"); - return new PeriodDuration(period, Duration.ZERO); - } + /** + * Obtains an instance based on a period and duration. + *

+ * The total amount of time of the resulting instance is the period plus the duration. + * + * @param period + * the period, not null + * @param duration + * the duration, not null + * + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Period period, Duration duration) { + Objects.requireNonNull(period, "The period must not be null"); + Objects.requireNonNull(duration, "The duration must not be null"); + return new PeriodDuration(period, duration); + } - /** - * Obtains an instance based on a duration. - *

- * The period will be zero. - * - * @param duration the duration, not null - * @return the combined period-duration, not null - */ - public static PeriodDuration of(Duration duration) { - Objects.requireNonNull(duration, "The duration must not be null"); - return new PeriodDuration(Period.ZERO, duration); - } + /** + * Obtains an instance based on a period. + *

+ * The duration will be zero. + * + * @param period + * the period, not null + * + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Period period) { + Objects.requireNonNull(period, "The period must not be null"); + return new PeriodDuration(period, Duration.ZERO); + } - //----------------------------------------------------------------------- - /** - * Obtains an instance from a temporal amount. - *

- * This obtains an instance based on the specified amount. - * A {@code TemporalAmount} represents an amount of time which this factory - * extracts to a {@code PeriodDuration}. - *

- * The result is calculated by looping around each unit in the specified amount. - * Any amount that is zero is ignore. - * If a unit has an exact duration, it will be totalled using {@link Duration#plus(Duration)}. - * If the unit is days or weeks, it will be totalled into the days part of the period. - * If the unit is months or quarters, it will be totalled into the months part of the period. - * If the unit is years, decades, centuries or millennia, it will be totalled into the years part of the period. - * - * @param amount the temporal amount to convert, not null - * @return the equivalent duration, not null - * @throws DateTimeException if unable to convert to a {@code Duration} - * @throws ArithmeticException if numeric overflow occurs - */ - public static PeriodDuration from(TemporalAmount amount) { - if (amount instanceof PeriodDuration) { - return (PeriodDuration) amount; - } - if (amount instanceof Period) { - return PeriodDuration.of((Period) amount); - } - if (amount instanceof Duration) { - return PeriodDuration.of((Duration) amount); - } - if (amount instanceof ChronoPeriod) { - if (IsoChronology.INSTANCE.equals(((ChronoPeriod) amount).getChronology()) == false) { - throw new DateTimeException("Period requires ISO chronology: " + amount); - } - } - Objects.requireNonNull(amount, "amount"); - int years = 0; - int months = 0; - int days = 0; - Duration duration = Duration.ZERO; - for (TemporalUnit unit : amount.getUnits()) { - long value = amount.get(unit); - if (value != 0) { - // ignore unless non-zero - if (unit.isDurationEstimated()) { - if (unit == ChronoUnit.DAYS) { - days = Math.addExact(days, Math.toIntExact(value)); - } else if (unit == ChronoUnit.WEEKS) { - days = Math.addExact(days, Math.toIntExact(Math.multiplyExact(value, (long) 7))); - } else if (unit == ChronoUnit.MONTHS) { - months = Math.addExact(months, Math.toIntExact(value)); - } else if (unit == IsoFields.QUARTER_YEARS) { - months = Math.addExact(months, Math.toIntExact(Math.multiplyExact(value, (long) 3))); - } else if (unit == ChronoUnit.YEARS) { - years = Math.addExact(years, Math.toIntExact(value)); - } else if (unit == ChronoUnit.DECADES) { - years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 10))); - } else if (unit == ChronoUnit.CENTURIES) { - years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 100))); - } else if (unit == ChronoUnit.MILLENNIA) { - years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 1000))); - } else { - throw new DateTimeException("Unknown unit: " + unit); - } - } else { - // total of exact durations - duration = duration.plus(amount.get(unit), unit); - } - } - } - return PeriodDuration.of(Period.of(years, months, days), duration); - } + /** + * Obtains an instance based on a duration. + *

+ * The period will be zero. + * + * @param duration + * the duration, not null + * + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Duration duration) { + Objects.requireNonNull(duration, "The duration must not be null"); + return new PeriodDuration(Period.ZERO, duration); + } - //----------------------------------------------------------------------- - /** - * Obtains an instance from a text string such as {@code PnYnMnDTnHnMnS}. - *

- * This will parse the string produced by {@code toString()} which is - * based on the ISO-8601 period formats {@code PnYnMnDTnHnMnS} and {@code PnW}. - *

- * The string starts with an optional sign, denoted by the ASCII negative - * or positive symbol. If negative, the whole amount is negated. - * The ASCII letter "P" is next in upper or lower case. - * There are then a number of sections, each consisting of a number and a suffix. - * At least one of the sections must be present. - * The sections have suffixes in ASCII of "Y" for years, "M" for months, - * "W" for weeks, "D" for days, "H" for hours, "M" for minutes, "S" for seconds, - * accepted in upper or lower case. Note that the ASCII letter "T" separates - * the date and time parts and must be present if any time part is present. - * The suffixes must occur in order. - * The number part of each section must consist of ASCII digits. - * The number may be prefixed by the ASCII negative or positive symbol. - * The number must parse to an {@code int}. - * Any week-based input is multiplied by 7 and treated as a number of days. - *

- * The leading plus/minus sign, and negative values for weeks and days are - * not part of the ISO-8601 standard. - *

- * Note that the date style format {@code PYYYY-MM-DDTHH:MM:SS} is not supported. - *

- * For example, the following are valid inputs: - *

-     *   "P2Y"             -- PeriodDuration.of(Period.ofYears(2))
-     *   "P3M"             -- PeriodDuration.of(Period.ofMonths(3))
-     *   "P4W"             -- PeriodDuration.of(Period.ofWeeks(4))
-     *   "P5D"             -- PeriodDuration.of(Period.ofDays(5))
-     *   "PT6H"            -- PeriodDuration.of(Duration.ofHours(6))
-     *   "P1Y2M3D"         -- PeriodDuration.of(Period.of(1, 2, 3))
-     *   "P1Y2M3W4DT8H"    -- PeriodDuration.of(Period.of(1, 2, 25), Duration.ofHours(8))
-     *   "P-1Y2M"          -- PeriodDuration.of(Period.of(-1, 2, 0))
-     *   "-P1Y2M"          -- PeriodDuration.of(Period.of(-1, -2, 0))
-     * 
- * - * @param text the text to parse, not null - * @return the parsed period, not null - * @throws DateTimeParseException if the text cannot be parsed to a period - */ - public static PeriodDuration parse(CharSequence text) { - Objects.requireNonNull(text, "text"); - String upper = text.toString().toUpperCase(Locale.ENGLISH); - String negate = ""; - if (upper.startsWith("+")) { - upper = upper.substring(1); - } else if (upper.startsWith("-")) { - upper = upper.substring(1); - negate = "-"; - } - // duration only, parse original text so it does negation - if (upper.startsWith("PT")) { - return PeriodDuration.of(Duration.parse(text)); - } - // period only, parse original text so it does negation - int tpos = upper.indexOf('T'); - if (tpos < 0) { - return PeriodDuration.of(Period.parse(text)); - } - // period and duration - Period period = Period.parse(negate + upper.substring(0, tpos)); - Duration duration = Duration.parse(negate + "P" + upper.substring(tpos)); - return PeriodDuration.of(period, duration); - } + //----------------------------------------------------------------------- - //----------------------------------------------------------------------- - /** - * Obtains an instance consisting of the amount of time between two temporals. - *

- * The start is included, but the end is not. - * The result of this method can be negative if the end is before the start. - *

- * The calculation examines the temporals and extracts {@link LocalDate} and {@link LocalTime}. - * If the time is missing, it will be defaulted to midnight. - * If one date is missing, it will be defaulted to the other date. - * It then finds the amount of time between the two dates and between the two times. - * - * @param startInclusive the start, inclusive, not null - * @param endExclusive the end, exclusive, not null - * @return the number of days between this date and the end date, not null - */ - public static PeriodDuration between(Temporal startInclusive, Temporal endExclusive) { - LocalDate startDate = startInclusive.query(TemporalQueries.localDate()); - LocalDate endDate = endExclusive.query(TemporalQueries.localDate()); - Period period = Period.ZERO; - if (startDate != null && endDate != null) { - period = Period.between(startDate, endDate); - } - LocalTime startTime = startInclusive.query(TemporalQueries.localTime()); - LocalTime endTime = endExclusive.query(TemporalQueries.localTime()); - startTime = startTime != null ? startTime : LocalTime.MIDNIGHT; - endTime = endTime != null ? endTime : LocalTime.MIDNIGHT; - Duration duration = Duration.between(startTime, endTime); - return PeriodDuration.of(period, duration); - } + /** + * Obtains an instance from a temporal amount. + *

+ * This obtains an instance based on the specified amount. A {@code TemporalAmount} represents an amount of time + * which this factory extracts to a {@code PeriodDuration}. + *

+ * The result is calculated by looping around each unit in the specified amount. Any amount that is zero is ignore. + * If a unit has an exact duration, it will be totalled using {@link Duration#plus(Duration)}. If the unit is days + * or weeks, it will be totalled into the days part of the period. If the unit is months or quarters, it will be + * totalled into the months part of the period. If the unit is years, decades, centuries or millennia, it will be + * totalled into the years part of the period. + * + * @param amount + * the temporal amount to convert, not null + * + * @return the equivalent duration, not null + * + * @throws DateTimeException + * if unable to convert to a {@code Duration} + * @throws ArithmeticException + * if numeric overflow occurs + */ + public static PeriodDuration from(TemporalAmount amount) { + if (amount instanceof PeriodDuration) { + return (PeriodDuration) amount; + } + if (amount instanceof Period) { + return PeriodDuration.of((Period) amount); + } + if (amount instanceof Duration) { + return PeriodDuration.of((Duration) amount); + } + if (amount instanceof ChronoPeriod) { + if (IsoChronology.INSTANCE.equals(((ChronoPeriod) amount).getChronology()) == false) { + throw new DateTimeException("Period requires ISO chronology: " + amount); + } + } + Objects.requireNonNull(amount, "amount"); + int years = 0; + int months = 0; + int days = 0; + Duration duration = Duration.ZERO; + for (TemporalUnit unit : amount.getUnits()) { + long value = amount.get(unit); + if (value != 0) { + // ignore unless non-zero + if (unit.isDurationEstimated()) { + if (unit == ChronoUnit.DAYS) { + days = Math.addExact(days, Math.toIntExact(value)); + } else if (unit == ChronoUnit.WEEKS) { + days = Math.addExact(days, Math.toIntExact(Math.multiplyExact(value, (long) 7))); + } else if (unit == ChronoUnit.MONTHS) { + months = Math.addExact(months, Math.toIntExact(value)); + } else if (unit == IsoFields.QUARTER_YEARS) { + months = Math.addExact(months, Math.toIntExact(Math.multiplyExact(value, (long) 3))); + } else if (unit == ChronoUnit.YEARS) { + years = Math.addExact(years, Math.toIntExact(value)); + } else if (unit == ChronoUnit.DECADES) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 10))); + } else if (unit == ChronoUnit.CENTURIES) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 100))); + } else if (unit == ChronoUnit.MILLENNIA) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 1000))); + } else { + throw new DateTimeException("Unknown unit: " + unit); + } + } else { + // total of exact durations + duration = duration.plus(amount.get(unit), unit); + } + } + } + return PeriodDuration.of(Period.of(years, months, days), duration); + } - //----------------------------------------------------------------------- - /** - * Constructs an instance. - * - * @param period the period - * @param duration the duration - */ - private PeriodDuration(Period period, Duration duration) { - this.period = period; - this.duration = duration; - } + //----------------------------------------------------------------------- - /** - * Resolves singletons. - * - * @return the singleton instance - */ - private Object readResolve() { - return PeriodDuration.of(period, duration); - } + /** + * Obtains an instance from a text string such as {@code PnYnMnDTnHnMnS}. + *

+ * This will parse the string produced by {@code toString()} which is based on the ISO-8601 period formats {@code + * PnYnMnDTnHnMnS} and {@code PnW}. + *

+ * The string starts with an optional sign, denoted by the ASCII negative or positive symbol. If negative, the whole + * amount is negated. The ASCII letter "P" is next in upper or lower case. There are then a number of sections, each + * consisting of a number and a suffix. At least one of the sections must be present. The sections have suffixes in + * ASCII of "Y" for years, "M" for months, "W" for weeks, "D" for days, "H" for hours, "M" for minutes, "S" for + * seconds, accepted in upper or lower case. Note that the ASCII letter "T" separates the date and time parts and + * must be present if any time part is present. The suffixes must occur in order. The number part of each section + * must consist of ASCII digits. The number may be prefixed by the ASCII negative or positive symbol. The number + * must parse to an {@code int}. Any week-based input is multiplied by 7 and treated as a number of days. + *

+ * The leading plus/minus sign, and negative values for weeks and days are not part of the ISO-8601 standard. + *

+ * Note that the date style format {@code PYYYY-MM-DDTHH:MM:SS} is not supported. + *

+ * For example, the following are valid inputs: + *

+	 *   "P2Y"             -- PeriodDuration.of(Period.ofYears(2))
+	 *   "P3M"             -- PeriodDuration.of(Period.ofMonths(3))
+	 *   "P4W"             -- PeriodDuration.of(Period.ofWeeks(4))
+	 *   "P5D"             -- PeriodDuration.of(Period.ofDays(5))
+	 *   "PT6H"            -- PeriodDuration.of(Duration.ofHours(6))
+	 *   "P1Y2M3D"         -- PeriodDuration.of(Period.of(1, 2, 3))
+	 *   "P1Y2M3W4DT8H"    -- PeriodDuration.of(Period.of(1, 2, 25), Duration.ofHours(8))
+	 *   "P-1Y2M"          -- PeriodDuration.of(Period.of(-1, 2, 0))
+	 *   "-P1Y2M"          -- PeriodDuration.of(Period.of(-1, -2, 0))
+	 * 
+ * + * @param text + * the text to parse, not null + * + * @return the parsed period, not null + * + * @throws DateTimeParseException + * if the text cannot be parsed to a period + */ + public static PeriodDuration parse(CharSequence text) { + Objects.requireNonNull(text, "text"); + String upper = text.toString().toUpperCase(Locale.ENGLISH); + String negate = ""; + if (upper.startsWith("+")) { + upper = upper.substring(1); + } else if (upper.startsWith("-")) { + upper = upper.substring(1); + negate = "-"; + } + // duration only, parse original text so it does negation + if (upper.startsWith("PT")) { + return PeriodDuration.of(Duration.parse(text)); + } + // period only, parse original text so it does negation + int tpos = upper.indexOf('T'); + if (tpos < 0) { + return PeriodDuration.of(Period.parse(text)); + } + // period and duration + Period period = Period.parse(negate + upper.substring(0, tpos)); + Duration duration = Duration.parse(negate + "P" + upper.substring(tpos)); + return PeriodDuration.of(period, duration); + } - //----------------------------------------------------------------------- - /** - * Gets the value of the requested unit. - *

- * This returns a value for the supported units - {@link ChronoUnit#YEARS}, - * {@link ChronoUnit#MONTHS}, {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} - * and {@link ChronoUnit#NANOS}. - * All other units throw an exception. - * Note that hours and minutes throw an exception. - * - * @param unit the {@code TemporalUnit} for which to return the value - * @return the long value of the unit - * @throws UnsupportedTemporalTypeException if the unit is not supported - */ - @Override - public long get(TemporalUnit unit) { - if (unit instanceof ChronoUnit) { - switch ((ChronoUnit) unit) { - case YEARS: - return period.getYears(); - case MONTHS: - return period.getMonths(); - case DAYS: - return period.getDays(); - case SECONDS: - return duration.getSeconds(); - case NANOS: - return duration.getNano(); - default: - break; - } - } - throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit); - } + //----------------------------------------------------------------------- - /** - * Gets the set of units supported by this amount. - *

- * This returns the list {@link ChronoUnit#YEARS}, {@link ChronoUnit#MONTHS}, - * {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} and {@link ChronoUnit#NANOS}. - *

- * This set can be used in conjunction with {@link #get(TemporalUnit)} - * to access the entire state of the amount. - * - * @return a list containing the days unit, not null - */ - @Override - public List getUnits() { - return SUPPORTED_UNITS; - } + /** + * Obtains an instance consisting of the amount of time between two temporals. + *

+ * The start is included, but the end is not. The result of this method can be negative if the end is before the + * start. + *

+ * The calculation examines the temporals and extracts {@link LocalDate} and {@link LocalTime}. If the time is + * missing, it will be defaulted to midnight. If one date is missing, it will be defaulted to the other date. It + * then finds the amount of time between the two dates and between the two times. + * + * @param startInclusive + * the start, inclusive, not null + * @param endExclusive + * the end, exclusive, not null + * + * @return the number of days between this date and the end date, not null + */ + public static PeriodDuration between(Temporal startInclusive, Temporal endExclusive) { + LocalDate startDate = startInclusive.query(TemporalQueries.localDate()); + LocalDate endDate = endExclusive.query(TemporalQueries.localDate()); + Period period = Period.ZERO; + if (startDate != null && endDate != null) { + period = Period.between(startDate, endDate); + } + LocalTime startTime = startInclusive.query(TemporalQueries.localTime()); + LocalTime endTime = endExclusive.query(TemporalQueries.localTime()); + startTime = startTime != null ? startTime : LocalTime.MIDNIGHT; + endTime = endTime != null ? endTime : LocalTime.MIDNIGHT; + Duration duration = Duration.between(startTime, endTime); + return PeriodDuration.of(period, duration); + } - //----------------------------------------------------------------------- - /** - * Gets the period part. - * - * @return the period part - */ - public Period getPeriod() { - return period; - } + //----------------------------------------------------------------------- - /** - * Returns a copy of this period-duration with a different period. - *

- * This instance is immutable and unaffected by this method call. - * - * @param period the new period - * @return the updated period-duration - */ - public PeriodDuration withPeriod(Period period) { - return PeriodDuration.of(period, duration); - } + /** + * Constructs an instance. + * + * @param period + * the period + * @param duration + * the duration + */ + private PeriodDuration(Period period, Duration duration) { + this.period = period; + this.duration = duration; + } - /** - * Gets the duration part. - * - * @return the duration part - */ - public Duration getDuration() { - return duration; - } + /** + * Resolves singletons. + * + * @return the singleton instance + */ + private Object readResolve() { + return PeriodDuration.of(period, duration); + } - /** - * Returns a copy of this period-duration with a different duration. - *

- * This instance is immutable and unaffected by this method call. - * - * @param duration the new duration - * @return the updated period-duration - */ - public PeriodDuration withDuration(Duration duration) { - return PeriodDuration.of(period, duration); - } + //----------------------------------------------------------------------- - //----------------------------------------------------------------------- - /** - * Checks if all parts of this amount are zero. - *

- * This returns true if both {@link Period#isZero()} and {@link Duration#isZero()} - * return true. - * - * @return true if this period is zero-length - */ - public boolean isZero() { - return period.isZero() && duration.isZero(); - } + /** + * Gets the value of the requested unit. + *

+ * This returns a value for the supported units - {@link ChronoUnit#YEARS}, {@link ChronoUnit#MONTHS}, {@link + * ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} and {@link ChronoUnit#NANOS}. All other units throw an exception. + * Note that hours and minutes throw an exception. + * + * @param unit + * the {@code TemporalUnit} for which to return the value + * + * @return the long value of the unit + * + * @throws UnsupportedTemporalTypeException + * if the unit is not supported + */ + @Override + public long get(TemporalUnit unit) { + if (unit instanceof ChronoUnit) { + switch ((ChronoUnit) unit) { + case YEARS: + return period.getYears(); + case MONTHS: + return period.getMonths(); + case DAYS: + return period.getDays(); + case SECONDS: + return duration.getSeconds(); + case NANOS: + return duration.getNano(); + default: + break; + } + } + throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit); + } - //----------------------------------------------------------------------- - /** - * Returns a copy of this amount with the specified amount added. - *

- * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. - * The period and duration are combined separately. - *

- * This instance is immutable and unaffected by this method call. - * - * @param amountToAdd the amount to add, not null - * @return a {@code Days} based on this instance with the requested amount added, not null - * @throws DateTimeException if the specified amount contains an invalid unit - * @throws ArithmeticException if numeric overflow occurs - */ - public PeriodDuration plus(TemporalAmount amountToAdd) { - PeriodDuration other = PeriodDuration.from(amountToAdd); - return of(period.plus(other.period), duration.plus(other.duration)); - } + /** + * Gets the set of units supported by this amount. + *

+ * This returns the list {@link ChronoUnit#YEARS}, {@link ChronoUnit#MONTHS}, {@link ChronoUnit#DAYS}, {@link + * ChronoUnit#SECONDS} and {@link ChronoUnit#NANOS}. + *

+ * This set can be used in conjunction with {@link #get(TemporalUnit)} to access the entire state of the amount. + * + * @return a list containing the days unit, not null + */ + @Override + public List getUnits() { + return SUPPORTED_UNITS; + } - //----------------------------------------------------------------------- - /** - * Returns a copy of this amount with the specified amount subtracted. - *

- * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. - * The period and duration are combined separately. - *

- * This instance is immutable and unaffected by this method call. - * - * @param amountToAdd the amount to add, not null - * @return a {@code Days} based on this instance with the requested amount subtracted, not null - * @throws DateTimeException if the specified amount contains an invalid unit - * @throws ArithmeticException if numeric overflow occurs - */ - public PeriodDuration minus(TemporalAmount amountToAdd) { - PeriodDuration other = PeriodDuration.from(amountToAdd); - return of(period.minus(other.period), duration.minus(other.duration)); - } + //----------------------------------------------------------------------- - //----------------------------------------------------------------------- - /** - * Returns an instance with the amount multiplied by the specified scalar. - *

- * This instance is immutable and unaffected by this method call. - * - * @param scalar the scalar to multiply by, not null - * @return the amount multiplied by the specified scalar, not null - * @throws ArithmeticException if numeric overflow occurs - */ - public PeriodDuration multipliedBy(int scalar) { - if (scalar == 1) { - return this; - } - return of(period.multipliedBy(scalar), duration.multipliedBy(scalar)); - } + /** + * Gets the period part. + * + * @return the period part + */ + public Period getPeriod() { + return period; + } - /** - * Returns an instance with the amount negated. - *

- * This instance is immutable and unaffected by this method call. - * - * @return the negated amount, not null - * @throws ArithmeticException if numeric overflow occurs, which only happens if - * the amount is {@code Long.MIN_VALUE} - */ - public PeriodDuration negated() { - return multipliedBy(-1); - } + /** + * Returns a copy of this period-duration with a different period. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param period + * the new period + * + * @return the updated period-duration + */ + public PeriodDuration withPeriod(Period period) { + return PeriodDuration.of(period, duration); + } - //----------------------------------------------------------------------- - /** - * Returns a copy of this instance with the years and months exactly normalized. - *

- * This normalizes the years and months units, leaving the days unit unchanged. - * The result is exact, always representing the same amount of time. - *

- * The months unit is adjusted to have an absolute value less than 11, - * with the years unit being adjusted to compensate. For example, a period of - * "1 year and 15 months" will be normalized to "2 years and 3 months". - *

- * The sign of the years and months units will be the same after normalization. - * For example, a period of "1 year and -25 months" will be normalized to - * "-1 year and -1 month". - *

- * Note that no normalization is performed on the days or duration. - *

- * This instance is immutable and unaffected by this method call. - * - * @return a {@code PeriodDuration} based on this one with excess months normalized to years, not null - * @throws ArithmeticException if numeric overflow occurs - */ - public PeriodDuration normalizedYears() { - return withPeriod(period.normalized()); - } + /** + * Gets the duration part. + * + * @return the duration part + */ + public Duration getDuration() { + return duration; + } - /** - * Returns a copy of this instance with the days and duration normalized using the standard day of 24 hours. - *

- * This normalizes the days and duration, leaving the years and months unchanged. - * The result uses a standard day length of 24 hours. - *

- * This combines the duration seconds with the number of days and shares the total - * seconds between the two fields. For example, a period of - * "2 days and 86401 seconds" will be normalized to "3 days and 1 second". - *

- * The sign of the days and duration will be the same after normalization. - * For example, a period of "1 day and -172801 seconds" will be normalized to - * "-1 day and -1 second". - *

- * Note that no normalization is performed on the years or months. - *

- * This instance is immutable and unaffected by this method call. - * - * @return a {@code PeriodDuration} based on this one with excess duration normalized to days, not null - * @throws ArithmeticException if numeric overflow occurs - */ - public PeriodDuration normalizedStandardDays() { - long totalSecs = period.getDays() * SECONDS_PER_DAY + duration.getSeconds(); - int splitDays = Math.toIntExact(totalSecs / SECONDS_PER_DAY); - long splitSecs = totalSecs % SECONDS_PER_DAY; - if (splitDays == period.getDays() && splitSecs == duration.getSeconds()) { - return this; - } - return PeriodDuration.of(period.withDays(splitDays), duration.withSeconds(splitSecs)); - } + /** + * Returns a copy of this period-duration with a different duration. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param duration + * the new duration + * + * @return the updated period-duration + */ + public PeriodDuration withDuration(Duration duration) { + return PeriodDuration.of(period, duration); + } - //----------------------------------------------------------------------- - /** - * Adds this amount to the specified temporal object. - *

- * This returns a temporal object of the same observable type as the input - * with this amount added. This simply adds the period and duration to the temporal. - *

- * This instance is immutable and unaffected by this method call. - * - * @param temporal the temporal object to adjust, not null - * @return an object of the same type with the adjustment made, not null - * @throws DateTimeException if unable to add - * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported - * @throws ArithmeticException if numeric overflow occurs - */ - @Override - public Temporal addTo(Temporal temporal) { - return temporal.plus(period).plus(duration); - } + //----------------------------------------------------------------------- - /** - * Subtracts this amount from the specified temporal object. - *

- * This returns a temporal object of the same observable type as the input - * with this amount subtracted. This simply subtracts the period and duration from the temporal. - *

- * This instance is immutable and unaffected by this method call. - * - * @param temporal the temporal object to adjust, not null - * @return an object of the same type with the adjustment made, not null - * @throws DateTimeException if unable to subtract - * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported - * @throws ArithmeticException if numeric overflow occurs - */ - @Override - public Temporal subtractFrom(Temporal temporal) { - return temporal.minus(period).minus(duration); - } + /** + * Checks if all parts of this amount are zero. + *

+ * This returns true if both {@link Period#isZero()} and {@link Duration#isZero()} return true. + * + * @return true if this period is zero-length + */ + public boolean isZero() { + return period.isZero() && duration.isZero(); + } - //----------------------------------------------------------------------- - /** - * Checks if this amount is equal to the specified {@code PeriodDuration}. - *

- * The comparison is based on the underlying period and duration. - * - * @param otherAmount the other amount, null returns false - * @return true if the other amount is equal to this one - */ - @Override - public boolean equals(Object otherAmount) { - if (this == otherAmount) { - return true; - } - if (otherAmount instanceof PeriodDuration) { - PeriodDuration other = (PeriodDuration) otherAmount; - return this.period.equals(other.period) && this.duration.equals(other.duration); - } - return false; - } + //----------------------------------------------------------------------- - /** - * A hash code for this amount. - * - * @return a suitable hash code - */ - @Override - public int hashCode() { - return period.hashCode() ^ duration.hashCode(); - } + /** + * Returns a copy of this amount with the specified amount added. + *

+ * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. The period and duration are + * combined separately. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param amountToAdd + * the amount to add, not null + * + * @return a {@code Days} based on this instance with the requested amount added, not null + * + * @throws DateTimeException + * if the specified amount contains an invalid unit + * @throws ArithmeticException + * if numeric overflow occurs + */ + public PeriodDuration plus(TemporalAmount amountToAdd) { + PeriodDuration other = PeriodDuration.from(amountToAdd); + return of(period.plus(other.period), duration.plus(other.duration)); + } - //----------------------------------------------------------------------- - /** - * Returns a string representation of the amount. - * This will be in the format 'PnYnMnDTnHnMnS', with sections omitted as necessary. - * An empty amount will return "PT0S". - * - * @return the period in ISO-8601 string format - */ - @Override - public String toString() { - if (period.isZero()) { - return duration.toString(); - } - if (duration.isZero()) { - return period.toString(); - } - return period.toString() + duration.toString().substring(1); - } + //----------------------------------------------------------------------- + /** + * Returns a copy of this amount with the specified amount subtracted. + *

+ * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. The period and duration are + * combined separately. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param amountToAdd + * the amount to add, not null + * + * @return a {@code Days} based on this instance with the requested amount subtracted, not null + * + * @throws DateTimeException + * if the specified amount contains an invalid unit + * @throws ArithmeticException + * if numeric overflow occurs + */ + public PeriodDuration minus(TemporalAmount amountToAdd) { + PeriodDuration other = PeriodDuration.from(amountToAdd); + return of(period.minus(other.period), duration.minus(other.duration)); + } + + //----------------------------------------------------------------------- + + /** + * Returns an instance with the amount multiplied by the specified scalar. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param scalar + * the scalar to multiply by, not null + * + * @return the amount multiplied by the specified scalar, not null + * + * @throws ArithmeticException + * if numeric overflow occurs + */ + public PeriodDuration multipliedBy(int scalar) { + if (scalar == 1) { + return this; + } + return of(period.multipliedBy(scalar), duration.multipliedBy(scalar)); + } + + /** + * Returns an instance with the amount negated. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return the negated amount, not null + * + * @throws ArithmeticException + * if numeric overflow occurs, which only happens if the amount is {@code Long.MIN_VALUE} + */ + public PeriodDuration negated() { + return multipliedBy(-1); + } + + //----------------------------------------------------------------------- + + /** + * Returns a copy of this instance with the years and months exactly normalized. + *

+ * This normalizes the years and months units, leaving the days unit unchanged. The result is exact, always + * representing the same amount of time. + *

+ * The months unit is adjusted to have an absolute value less than 11, with the years unit being adjusted to + * compensate. For example, a period of "1 year and 15 months" will be normalized to "2 years and 3 months". + *

+ * The sign of the years and months units will be the same after normalization. For example, a period of "1 year and + * -25 months" will be normalized to "-1 year and -1 month". + *

+ * Note that no normalization is performed on the days or duration. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return a {@code PeriodDuration} based on this one with excess months normalized to years, not null + * + * @throws ArithmeticException + * if numeric overflow occurs + */ + public PeriodDuration normalizedYears() { + return withPeriod(period.normalized()); + } + + /** + * Returns a copy of this instance with the days and duration normalized using the standard day of 24 hours. + *

+ * This normalizes the days and duration, leaving the years and months unchanged. The result uses a standard day + * length of 24 hours. + *

+ * This combines the duration seconds with the number of days and shares the total seconds between the two fields. + * For example, a period of "2 days and 86401 seconds" will be normalized to "3 days and 1 second". + *

+ * The sign of the days and duration will be the same after normalization. For example, a period of "1 day and + * -172801 seconds" will be normalized to "-1 day and -1 second". + *

+ * Note that no normalization is performed on the years or months. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return a {@code PeriodDuration} based on this one with excess duration normalized to days, not null + * + * @throws ArithmeticException + * if numeric overflow occurs + */ + public PeriodDuration normalizedStandardDays() { + long totalSecs = period.getDays() * SECONDS_PER_DAY + duration.getSeconds(); + int splitDays = Math.toIntExact(totalSecs / SECONDS_PER_DAY); + long splitSecs = totalSecs % SECONDS_PER_DAY; + if (splitDays == period.getDays() && splitSecs == duration.getSeconds()) { + return this; + } + return PeriodDuration.of(period.withDays(splitDays), duration.withSeconds(splitSecs)); + } + + //----------------------------------------------------------------------- + + /** + * Adds this amount to the specified temporal object. + *

+ * This returns a temporal object of the same observable type as the input with this amount added. This simply adds + * the period and duration to the temporal. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param temporal + * the temporal object to adjust, not null + * + * @return an object of the same type with the adjustment made, not null + * + * @throws DateTimeException + * if unable to add + * @throws UnsupportedTemporalTypeException + * if the DAYS unit is not supported + * @throws ArithmeticException + * if numeric overflow occurs + */ + @Override + public Temporal addTo(Temporal temporal) { + return temporal.plus(period).plus(duration); + } + + /** + * Subtracts this amount from the specified temporal object. + *

+ * This returns a temporal object of the same observable type as the input with this amount subtracted. This simply + * subtracts the period and duration from the temporal. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param temporal + * the temporal object to adjust, not null + * + * @return an object of the same type with the adjustment made, not null + * + * @throws DateTimeException + * if unable to subtract + * @throws UnsupportedTemporalTypeException + * if the DAYS unit is not supported + * @throws ArithmeticException + * if numeric overflow occurs + */ + @Override + public Temporal subtractFrom(Temporal temporal) { + return temporal.minus(period).minus(duration); + } + + //----------------------------------------------------------------------- + + /** + * Checks if this amount is equal to the specified {@code PeriodDuration}. + *

+ * The comparison is based on the underlying period and duration. + * + * @param otherAmount + * the other amount, null returns false + * + * @return true if the other amount is equal to this one + */ + @Override + public boolean equals(Object otherAmount) { + if (this == otherAmount) { + return true; + } + if (otherAmount instanceof PeriodDuration) { + PeriodDuration other = (PeriodDuration) otherAmount; + return this.period.equals(other.period) && this.duration.equals(other.duration); + } + return false; + } + + /** + * A hash code for this amount. + * + * @return a suitable hash code + */ + @Override + public int hashCode() { + return period.hashCode() ^ duration.hashCode(); + } + + //----------------------------------------------------------------------- + + /** + * Returns a string representation of the amount. This will be in the format 'PnYnMnDTnHnMnS', with sections omitted + * as necessary. An empty amount will return "PT0S". + * + * @return the period in ISO-8601 string format + */ + @Override + public String toString() { + if (period.isZero()) { + return duration.toString(); + } + if (duration.isZero()) { + return period.toString(); + } + return period.toString() + duration.toString().substring(1); + } + + @Override + public int compareTo(PeriodDuration o) { + return Double.compare(daysIn(this), daysIn(o)); + } + + public int periodPartToDays() { + return (getPeriod().getYears() * 365) + (getPeriod().getMonths() * 30) + getPeriod().getDays(); + } + + public long toMillis() { + if (this.period.isZero()) + return this.duration.toMillis(); + return TimeUnit.DAYS.toMillis(periodPartToDays()) + this.duration.toMillis(); + } } diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodHelper.java b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodHelper.java new file mode 100644 index 000000000..f58b9c5bf --- /dev/null +++ b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodHelper.java @@ -0,0 +1,56 @@ +package li.strolch.utils.time; + +import static java.time.Period.between; + +import java.time.Period; +import java.time.ZonedDateTime; + +import li.strolch.utils.dbc.DBC; + +public class PeriodHelper { + + public static double daysIn(PeriodDuration periodDuration) { + return (daysIn(periodDuration.getPeriod()) + (periodDuration.getDuration().toHours() / 24.0)); + } + + public static double daysIn(Period period) { + return (period.getYears() * 365.0) + (period.getMonths() * 30.0) + period.getDays(); + } + + /** + * This special function allows us to shift a date by a multiple of the given {@link PeriodDuration} so that is + * before the given to date. It does multiple tries to get as close as possible, due to the inexactness of 30 days + * being one month, and 365 days being one year. + * + * @param date + * the date to shift + * @param to + * the date before which to stop shifting + * @param periodDuration + * the period shift in multiples by + * + * @return the shifted date + */ + public static ZonedDateTime shiftByMultipleOfPeriod(ZonedDateTime date, ZonedDateTime to, + PeriodDuration periodDuration) { + DBC.PRE.assertTrue("date must be before to!", date.isBefore(to)); + DBC.PRE.assertFalse("period duration may not be null!", periodDuration.isZero()); + Period between = between(date.toLocalDate(), to.toLocalDate()); + double daysInBetween = daysIn(between); + double daysInPeriod = daysIn(periodDuration); + long shifts = (long) (daysInBetween / daysInPeriod); + if (shifts < 0) + return date; + + ZonedDateTime shiftedDate = date.plusDays((long) (shifts * daysInPeriod)); + + // see if we are close enough now + between = between(shiftedDate.toLocalDate(), to.toLocalDate()); + daysInBetween = daysIn(between); + shifts = (long) (daysInBetween / daysInPeriod); + if (shifts < 2) + return shiftedDate; + + return shiftByMultipleOfPeriod(shiftedDate, to, periodDuration); + } +} diff --git a/li.strolch.utils/src/test/java/li/strolch/utils/time/PeriodHelperTest.java b/li.strolch.utils/src/test/java/li/strolch/utils/time/PeriodHelperTest.java new file mode 100644 index 000000000..cf993edb2 --- /dev/null +++ b/li.strolch.utils/src/test/java/li/strolch/utils/time/PeriodHelperTest.java @@ -0,0 +1,116 @@ +package li.strolch.utils.time; + +import static java.time.ZoneId.systemDefault; +import static li.strolch.utils.time.PeriodHelper.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.junit.Test; + +public class PeriodHelperTest { + + @Test + public void shouldCalcHalfADay() { + assertEquals(0.5, daysIn(PeriodDuration.parse("PT12H")), 0.0); + } + + @Test + public void shouldCalc1Day1() { + assertEquals(1.0, daysIn(PeriodDuration.parse("P1D")), 0.0); + } + + @Test + public void shouldCalc1Day2() { + assertEquals(1.0, daysIn(PeriodDuration.parse("PT24H")), 0.0); + } + + @Test + public void shouldCalc1AndAHalfDays() { + assertEquals(1.5, daysIn(PeriodDuration.parse("PT36H")), 0.0); + } + + @Test + public void shouldCalc2Days1() { + assertEquals(2.0, daysIn(PeriodDuration.parse("P2D")), 0.0); + } + + @Test + public void shouldCalc2Days2() { + assertEquals(2.0, daysIn(PeriodDuration.parse("PT48H")), 0.0); + } + + @Test + public void shouldCalc2Days3() { + assertEquals(2.0, daysIn(PeriodDuration.parse("P1DT24H")), 0.0); + } + + @Test + public void shouldCalc2AndAHalfDays0() { + assertEquals(2.5, daysIn(PeriodDuration.parse("P1DT36H")), 0.0); + } + + @Test + public void shouldCalc3Days() { + assertEquals(3.0, daysIn(PeriodDuration.parse("PT71H60M")), 0.0); + } + + @Test + public void shouldCalc2Days23H() { + assertEquals(2.958333333, daysIn(PeriodDuration.parse("PT71H")), 0.00001); + } + + @Test + public void shouldCalcDays() { + assertEquals(30, daysIn(PeriodDuration.parse("P1M")), 0.0); + } + + @Test + public void shouldCalcShiftDays1() { + ZonedDateTime past = ZonedDateTime.now().minusDays(35); + ZonedDateTime now = ZonedDateTime.now(); + PeriodDuration periodDuration = PeriodDuration.parse("P1M"); + ZonedDateTime shiftedDate = shiftByMultipleOfPeriod(past, now, periodDuration); + + assertTrue(shiftedDate.isAfter(now.minusDays(29))); + assertTrue(shiftedDate.isBefore(now)); + } + + @Test + public void shouldCalcShiftDays2() { + ZonedDateTime past = ZonedDateTime + .parse("2007-12-03T10:15:30+01:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(systemDefault())); + ZonedDateTime now = ZonedDateTime.now(); + PeriodDuration periodDuration = PeriodDuration.parse("P1M"); + ZonedDateTime shiftedDate = shiftByMultipleOfPeriod(past, now, periodDuration); + + // since P1M ist = 28 days, we have a rather inexact match, but it must certainly be before now() - P1M + assertTrue(shiftedDate.isAfter(now.minusDays(56))); + assertTrue(shiftedDate.isBefore(now.minusDays(28))); + } + + @Test + public void shouldCalcShiftDays3() { + ZonedDateTime past = ZonedDateTime.now().minusDays(20); + ZonedDateTime now = ZonedDateTime.now(); + PeriodDuration periodDuration = PeriodDuration.parse("P7D"); + ZonedDateTime shiftedDate = shiftByMultipleOfPeriod(past, now, periodDuration); + + assertTrue(shiftedDate.isAfter(now.minusDays(9))); + assertTrue(shiftedDate.isBefore(now)); + } + + @Test + public void shouldCalcShiftDays4() { + ZonedDateTime past = ZonedDateTime + .parse("2007-12-03T10:15:30+01:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(systemDefault())); + ZonedDateTime now = ZonedDateTime.now(); + PeriodDuration periodDuration = PeriodDuration.parse("P7D"); + ZonedDateTime shiftedDate = shiftByMultipleOfPeriod(past, now, periodDuration); + + // since we are many years and months before now, and a year is 356 days and a month is 28 days, we inexact, but at least we must be a more than P7D before now + assertTrue(shiftedDate.isBefore(now)); + } +}