/*
* This file is part of LibrePlan
*
* Copyright (C) 2009-2010 Fundación para o Fomento da Calidade Industrial e
* Desenvolvemento Tecnolóxico de Galicia
* Copyright (C) 2010-2011 Igalia, S.L.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.libreplan.business.workingday;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.math.Fraction;
/**
* <p>
* It represents some amount of effort.
* It's composed by some hours, minutes and seconds.
* Less granularity than a second can't be specified.
* </p>
* <p>
* This object can represent the predicted amount of work that a task takes,
* the scheduled amount of work for a working day, the amount of effort that a worker can work in a given day, etc.
* </p>
*
* @author Óscar González Fernández <ogonzalez@igalia.com>
*/
public class EffortDuration implements Comparable<EffortDuration> {
private static final Pattern lenientEffortDurationSpecification = Pattern.compile("(\\d+)(\\s*:\\s*\\d+\\s*)*");
private static final Pattern contiguousDigitsPattern = Pattern.compile("\\d+");
private final int seconds;
private EffortDuration(int seconds) {
Validate.isTrue(seconds >= 0, "seconds cannot be negative");
this.seconds = seconds;
}
public enum Granularity {
HOURS(3600),
MINUTES(60),
SECONDS(1);
private final int secondsPerUnit;
Granularity(int secondsPerUnit) {
this.secondsPerUnit = secondsPerUnit;
}
static Granularity[] fromMoreCoarseToLessCoarse() {
return Granularity.values();
}
public int toSeconds(int amount) {
return secondsPerUnit * amount;
}
public int convertFromSeconds(int seconds) {
return seconds / secondsPerUnit;
}
}
/**
* If an {@link EffortDuration} can't be parsed <code>null</code> is returned.
* The hours field at least is required, the next fields are the minutes and seconds.
* If there is more than one field, they are separated by colons.
*
* @param string
* @return {@link EffortDuration}
*/
public static EffortDuration parseFromFormattedString(String string) {
Matcher matcher = lenientEffortDurationSpecification.matcher(string);
if (matcher.find()) {
List<String> parts = scan(contiguousDigitsPattern, string);
assert parts.size() >= 1;
return EffortDuration.hours(retrieveNumber(0, parts))
.and(retrieveNumber(1, parts), Granularity.MINUTES)
.and(retrieveNumber(2, parts), Granularity.SECONDS);
}
return null;
}
private static List<String> scan(Pattern pattern, String text) {
List<String> result = new ArrayList<>();
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
result.add(matcher.group());
}
return result;
}
private static int retrieveNumber(int i, List<String> parts) {
return i >= parts.size() ? 0 : Integer.parseInt(parts.get(i));
}
public interface IEffortFrom<T> {
EffortDuration from(T each);
}
public static <T> EffortDuration sum(Iterable<? extends T> collection, IEffortFrom<T> effortFrom) {
EffortDuration result = zero();
for (T each : collection) {
result = result.plus(effortFrom.from(each));
}
return result;
}
public static EffortDuration sum(EffortDuration... summands) {
return sum(Arrays.asList(summands), new IEffortFrom<EffortDuration>() {
@Override
public EffortDuration from(EffortDuration each) {
return each;
}
});
}
public static EffortDuration zero() {
return elapsing(0, Granularity.SECONDS);
}
public static EffortDuration elapsing(int amount, Granularity granularity) {
return new EffortDuration(granularity.toSeconds(amount));
}
public static EffortDuration hours(int amount) {
return elapsing(amount, Granularity.HOURS);
}
public static EffortDuration minutes(int amount) {
return elapsing(amount, Granularity.MINUTES);
}
public static EffortDuration seconds(int amount) {
return elapsing(amount, Granularity.SECONDS);
}
public static EffortDuration fromHoursAsBigDecimal(BigDecimal hours) {
BigDecimal secondsPerHour = new BigDecimal(3600);
return elapsing(hours.multiply(secondsPerHour).intValue(), Granularity.SECONDS);
}
public int getHours() {
return convertTo(Granularity.HOURS);
}
public int getMinutes() {
return convertTo(Granularity.MINUTES);
}
public int getSeconds() {
return convertTo(Granularity.SECONDS);
}
public int convertTo(Granularity granularity) {
return granularity.convertFromSeconds(seconds);
}
public EffortDuration and(int amount, Granularity granularity) {
return new EffortDuration(seconds + granularity.toSeconds(amount));
}
@Override
public boolean equals(Object obj) {
if (obj instanceof EffortDuration) {
EffortDuration other = (EffortDuration) obj;
return getSeconds() == other.getSeconds();
}
return false;
}
@Override
public int hashCode() {
return getSeconds();
}
public EnumMap<Granularity, Integer> decompose() {
EnumMap<Granularity, Integer> result = new EnumMap<>(Granularity.class);
int remainder = seconds;
for (Granularity each : Granularity.fromMoreCoarseToLessCoarse()) {
int value = each.convertFromSeconds(remainder);
remainder -= value * each.toSeconds(1);
result.put(each, value);
}
assert remainder == 0;
return result;
}
@Override
public int compareTo(EffortDuration other) {
Validate.notNull(other);
return seconds - other.seconds;
}
/**
* Multiplies this duration by a scalar.
*
* <br />
* <b>Warning:<b /> This method can cause an integer overflow and the result would be incorrect.
* @param n
* @return a duration that is the multiply of n and <code>this</code>
*/
public EffortDuration multiplyBy(int n) {
return EffortDuration.seconds(this.seconds * n);
}
/**
* Divides this duration by a scalar.
*
* @param n
* a number greater than zero
* @return a new duration that is the result of dividing <code>this</code>
* by n
*/
public EffortDuration divideBy(int n) {
Validate.isTrue(n > 0);
return new EffortDuration(seconds / n);
}
/**
* <p>
* Divides this duration by other returning the quotient.
* </p>
*
* There can be a remainder left.
*
* @see #remainderFor(EffortDuration)
* @param other
* @return
*/
public int divideBy(EffortDuration other) {
return seconds / other.seconds;
}
public Fraction divivedBy(EffortDuration effortAssigned) {
return Fraction.getFraction(this.seconds, effortAssigned.seconds);
}
/**
* <p>
* Divides this duration by other (using total seconds) returning the quotient as BigDecimal.
* </p>
*
* @param other
* @return
*/
public BigDecimal dividedByAndResultAsBigDecimal(EffortDuration other) {
return other.isZero()
? BigDecimal.ZERO
: new BigDecimal(this.getSeconds())
.divide(new BigDecimal(other.getSeconds()), 8, BigDecimal.ROUND_HALF_EVEN);
}
/**
* Calculates the remainder resulting of doing the integer division of both durations.
*
* @see #divideBy(EffortDuration)
* @param other
* @return the remainder
*/
public EffortDuration remainderFor(EffortDuration other) {
int dividend = divideBy(other);
return this.minus(other.multiplyBy(dividend));
}
/**
* Pluses two {@link EffortDuration}.
* <br />
* <b>Warning:<b /> This method can cause an integer overflow and the result would be incorrect.
*
* @param other
* @return a duration that is the sum of <code>this</code>
* {@link EffortDuration} and the other duration
*/
public EffortDuration plus(EffortDuration other) {
return new EffortDuration(seconds + other.seconds);
}
public boolean isZero() {
return seconds == 0;
}
/**
* Substracts two {@link EffortDuration}.
* Because {@link EffortDuration durations} cannot be negative <code>this</code> must be bigger than the
* parameter or the same.
*
* @param duration
* @return the result of substracting the two durations
* @throws IllegalArgumentException
* if the parameter is bigger than <code>this</code>
*/
public EffortDuration minus(EffortDuration duration) {
Validate.isTrue(this.compareTo(duration) >= 0, "minued must not be smaller than subtrahend");
return new EffortDuration(seconds - duration.seconds);
}
public BigDecimal toHoursAsDecimalWithScale(int scale) {
BigDecimal result = BigDecimal.ZERO;
final BigDecimal secondsPerHour = new BigDecimal(3600);
for (Entry<Granularity, Integer> each : decompose().entrySet()) {
BigDecimal seconds = new BigDecimal(each.getKey().toSeconds(each.getValue()));
result = result.add(seconds.divide(secondsPerHour, scale, BigDecimal.ROUND_HALF_UP));
}
return result;
}
/**
* <p>
* Converts this duration in a number of hours.
* Uses a typical half up round, so for example one hour and half is converted to two hours.
* There is an exception though, when the duration is less than one hour and is not zero it's returned one.
* This is handy for avoiding infinite loops in some algorithms;
* when all code is converted to use {@link EffortDuration Effort Durations} this will no longer be necessary.
* </p>
*
* So there are three cases:
* <ul>
* <li>the duration is zero, 0 is returned</li>
* <li>if duration > 0 and duration < 1, 1 is returned</li>
* <li>if duration >= 1, typical half up round is done.</li>
* </ul>
* For example 1 hour and 20 minutes returns 1 hour, 1 hour and 30 minutes 2 hours.
*
* @return an integer number of hours
*/
public int roundToHours() {
return this.isZero() ? 0 : Math.max(1, roundHalfUpToHours(this.decompose()));
}
public static EffortDuration min(EffortDuration... durations) {
return Collections.min(Arrays.asList(durations));
}
public static EffortDuration max(EffortDuration... durations) {
return Collections.max(Arrays.asList(durations));
}
public static EffortDuration average(EffortDuration total, int items) {
return EffortDuration.seconds(total.seconds / items);
}
private static int roundHalfUpToHours(EnumMap<Granularity, Integer> components) {
int seconds = components.get(Granularity.SECONDS);
int minutes = components.get(Granularity.MINUTES) + (seconds < 30 ? 0 : 1);
int hours = components.get(Granularity.HOURS) + (minutes < 30 ? 0 : 1);
return hours;
}
public String toString() {
EnumMap<Granularity, Integer> valuesForEachUnit = decompose();
Integer hours = valuesForEachUnit.get(Granularity.HOURS);
Integer minutes = valuesForEachUnit.get(Granularity.MINUTES);
Integer seconds = valuesForEachUnit.get(Granularity.SECONDS);
return String.format("%d:%02d:%02d", hours, minutes, seconds);
}
public String toFormattedString() {
EnumMap<Granularity, Integer> byGranularity = this.atNearestMinute().decompose();
int hours = byGranularity.get(Granularity.HOURS);
int minutes = byGranularity.get(Granularity.MINUTES);
return minutes == 0 ? String.format("%d", hours) : String.format("%d:%02d", hours, minutes);
}
public EffortDuration atNearestMinute() {
EnumMap<Granularity, Integer> decompose = this.decompose();
int seconds = decompose.get(Granularity.SECONDS);
return seconds >= 30
? this.plus(EffortDuration.seconds(60 - seconds))
: this.minus(EffortDuration.seconds(seconds));
}
}