Skip to content

Commit

Permalink
show weekly overtime on report view
Browse files Browse the repository at this point in the history
  • Loading branch information
bseber committed Jun 22, 2023
1 parent e6ef635 commit df2fa69
Show file tree
Hide file tree
Showing 23 changed files with 524 additions and 180 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package de.focusshift.zeiterfassung.overtime;

import de.focusshift.zeiterfassung.timeentry.TimeEntryDuration;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Objects;

public final class OvertimeDuration implements TimeEntryDuration {

public static OvertimeDuration ZERO = new OvertimeDuration(Duration.ZERO);

private final Duration value;

public OvertimeDuration(Duration value) {
this.value = value;
}

@Override
public Duration value() {
return value;
}

@Override
public Duration minutes() {
final long seconds = value.toSeconds();

return seconds % 60 == 0
? value
: Duration.ofMinutes(value.toMinutes() + 1);
}

@Override
public double hoursDoubleValue() {
final long minutes = minutes().toMinutes();
return minutesToHours(minutes);
}

public OvertimeDuration plus(Duration duration) {
return new OvertimeDuration(value.plus(duration));
}

public OvertimeDuration plus(OvertimeDuration overtimeDuration) {
return new OvertimeDuration(value.plus(overtimeDuration.value));
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
OvertimeDuration that = (OvertimeDuration) o;
return Objects.equals(value, that.value);
}

@Override
public int hashCode() {
return Objects.hash(value);
}

@Override
public String toString() {
return "OvertimeDuration{" +
"value=" + value +
'}';
}

private static double minutesToHours(long minutes) {
return BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.CEILING).doubleValue();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
import de.focusshift.zeiterfassung.timeentry.PlannedWorkingHours;
import de.focusshift.zeiterfassung.user.DateFormatter;
import de.focusshift.zeiterfassung.user.UserId;
import de.focusshift.zeiterfassung.usermanagement.User;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import org.apache.commons.collections4.SetUtils;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
Expand All @@ -14,10 +17,22 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;

import static java.util.Comparator.comparing;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;

@Component
class ReportControllerHelper {
Expand Down Expand Up @@ -101,6 +116,75 @@ DetailWeekDto toDetailWeekDto(ReportWeek reportWeek, Month monthPivot) {
return new DetailWeekDto(Date.from(firstOfWeek.toInstant()), Date.from(lastOfWeek.toInstant()), calendarWeek, dayReports);
}

ReportOvertimesDto reportOvertimesDto(ReportWeek reportWeek) {
// person | M | T | W | T | F | S | S |
// -----------------------------------
// john | 1 | 2 | 2 | 3 | 4 | 4 | 4 | <- `ReportOvertimeDto ( personName, overtimes )`
// jane | 0 | 0 | 2 | 3 | 4 | 4 | 4 | entries in the middle of the week
// jack | 0 | 0 | 0 | 0 | 0 | 0 | 0 | no entries this week
//
// note that the first overtime won't be empty actually, but the `accumulatedOvertimeToDate`.

// build up `users` peace by peace. one person could have the first working day in the middle of the week (jane).
final Set<User> users = new HashSet<>();

// {john} -> [1, 2, 2, 3, 4, 4, 4]
// {jane} -> [empty, empty, 2, 3, 4, 4, 4]
// {jack} -> [empty, empty, empty, empty, empty, empty, empty] (has no entries this week)
final Map<User, List<Optional<OvertimeDuration>>> overtimeDurationsByUser = new HashMap<>();

// used to initiate the persons list of overtimes.
// jane will be seen first on the third reportDay. she initially needs a list of `[null, null]`.
int nrOfHandledDays = 0;

for (ReportDay reportDay : reportWeek.reportDays()) {

// planned working hours contains all users. even users without time entries at this day
final Map<User, PlannedWorkingHours> plannedByUser = reportDay.plannedWorkingHoursByUser();
users.addAll(plannedByUser.keySet());

for (User user : users) {
final var durations = overtimeDurationsByUser.computeIfAbsent(user, prepareOvertimeDurationList(nrOfHandledDays));
durations.add(reportDay.accumulatedOvertimeToDateEndOfBusinessByUser(user.localId()));
}

nrOfHandledDays++;
}

final Set<UserLocalId> userIdsWithDayEntries = users.stream().map(User::localId).collect(toSet());
final Map<User, List<PlannedWorkingHours>> usersWithPlannedWorkingHours = reportWeek.plannedWorkingHoursByUser();
final Map<UserLocalId, User> usersWithPlannedWorkingHoursById = usersWithPlannedWorkingHours.keySet().stream().collect(toMap(User::localId, identity()));
final Set<UserLocalId> userIdsWithPlannedWorkingHours = usersWithPlannedWorkingHours.keySet().stream().map(User::localId).collect(toSet());
final SetUtils.SetView<UserLocalId> userIdsWithoutDayEntries = SetUtils.difference(userIdsWithPlannedWorkingHours, userIdsWithDayEntries);
for (UserLocalId userLocalId : userIdsWithoutDayEntries) {
overtimeDurationsByUser.computeIfAbsent(usersWithPlannedWorkingHoursById.get(userLocalId), prepareOvertimeDurationList(nrOfHandledDays));
}

final List<ReportOvertimeDto> overtimeDtos = overtimeDurationsByUser.entrySet().stream()
.map(entry -> new ReportOvertimeDto(entry.getKey().fullName(), overtimeDurationToDouble(entry.getValue())))
.sorted(comparing(ReportOvertimeDto::personName))
.collect(toList());

return new ReportOvertimesDto(reportWeek.dateOfWeeks(), overtimeDtos);
}

private static Function<User, List<Optional<OvertimeDuration>>> prepareOvertimeDurationList(int nrOfHandledDays) {
return (unused) -> {
final List<Optional<OvertimeDuration>> objects = new ArrayList<>();
for (int i = 0; i < nrOfHandledDays; i++) {
objects.add(Optional.empty());
}
return objects;
};
}

private static List<Double> overtimeDurationToDouble(List<Optional<OvertimeDuration>> overtimeDurations) {
return overtimeDurations.stream()
.map(maybe -> maybe.orElse(null))
.map(overtimeDuration -> overtimeDuration == null ? null : overtimeDuration.hoursDoubleValue())
.collect(toList());
}

String createUrl(String prefix, boolean allUsersSelected, List<UserLocalId> selectedUserLocalIds) {
String url = prefix;

Expand Down
69 changes: 56 additions & 13 deletions src/main/java/de/focusshift/zeiterfassung/report/ReportDay.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
import de.focusshift.zeiterfassung.timeentry.PlannedWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.usermanagement.User;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;

import java.time.Duration;
Expand All @@ -10,12 +12,18 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;

import static java.util.function.Function.identity;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toMap;

record ReportDay(
LocalDate date,
Map<UserLocalId, PlannedWorkingHours> plannedWorkingHoursByUser,
Map<User, PlannedWorkingHours> plannedWorkingHoursByUser,
Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateByUser,
Map<UserLocalId, List<ReportDayEntry>> reportDayEntriesByUser
) {

Expand All @@ -27,26 +35,57 @@ public PlannedWorkingHours plannedWorkingHours() {
return plannedWorkingHoursByUser.values().stream().reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
}

public PlannedWorkingHours plannedWorkingHoursByUser(UserLocalId userLocalId) {
return findValueByFirstKeyMatch(plannedWorkingHoursByUser, userLocalId::equals).orElse(PlannedWorkingHours.ZERO);
public Optional<OvertimeDuration> accumulatedOvertimeToDateByUser(UserLocalId userLocalId) {
return findValueByFirstKeyMatch(accumulatedOvertimeToDateByUser, userLocalId::equals);
}

public WorkDuration workDuration() {
public Optional<OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser(UserLocalId userLocalId) {

final Stream<ReportDayEntry> allReportDayEntries = reportDayEntriesByUser.values()
final Optional<PlannedWorkingHours> plannedWorkingHours = plannedWorkingHoursByUser.entrySet()
.stream()
.flatMap(Collection::stream);
.filter(entry -> entry.getKey().localId().equals(userLocalId))
.findFirst()
.map(Map.Entry::getValue);

return calculateWorkDurationFrom(allReportDayEntries);
final Optional<OvertimeDuration> overtimeStartOfBusiness = accumulatedOvertimeToDateByUser(userLocalId);

if (plannedWorkingHours.isEmpty()) {
// TODO how to handle `plannedWorkingHours=null`? it should be `plannedWorkingHours=ZERO` when everything is ok. `null` should only the case for an unknown `userLocalId` i think.
return overtimeStartOfBusiness;
}

// calculate working time duration of this day
// to add it to `overtimeStartOfBusiness`

final WorkDuration workDurationThisDay = reportDayEntriesByUser.getOrDefault(userLocalId, List.of())
.stream()
.filter(not(ReportDayEntry::isBreak))
.map(ReportDayEntry::workDuration)
.reduce(WorkDuration.ZERO, WorkDuration::plus);

final Duration overtimeDurationThisDay = plannedWorkingHours.get().value().negated().plus(workDurationThisDay.value());
final OvertimeDuration overtimeEndOfBusiness = overtimeStartOfBusiness.orElse(OvertimeDuration.ZERO).plus(new OvertimeDuration(overtimeDurationThisDay));
return Optional.of(overtimeEndOfBusiness);
}

public WorkDuration workDurationByUser(UserLocalId userLocalId) {
return workDurationByUserPredicate(userLocalId::equals);
public Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser() {
// `accumulatedOvertimeToDateByUser` could not contain persons with timeEntries at this day.
// we need to iterate ALL persons that should have worked this day.
final Map<UserLocalId, OvertimeDuration> collect = plannedWorkingHoursByUser.keySet()
.stream()
.map(user -> Map.entry(user.localId(), accumulatedOvertimeToDateEndOfBusinessByUser(user.localId()).orElse(OvertimeDuration.ZERO)))
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));

return collect;
}

private WorkDuration workDurationByUserPredicate(Predicate<UserLocalId> predicate) {
final List<ReportDayEntry> reportDayEntries = findValueByFirstKeyMatch(reportDayEntriesByUser, predicate).orElse(List.of());
return calculateWorkDurationFrom(reportDayEntries.stream());
public WorkDuration workDuration() {

final Stream<ReportDayEntry> allReportDayEntries = reportDayEntriesByUser.values()
.stream()
.flatMap(Collection::stream);

return calculateWorkDurationFrom(allReportDayEntries);
}

private WorkDuration calculateWorkDurationFrom(Stream<ReportDayEntry> reportDayEntries) {
Expand All @@ -60,9 +99,13 @@ private WorkDuration calculateWorkDurationFrom(Stream<ReportDayEntry> reportDayE
}

private <K, T> Optional<T> findValueByFirstKeyMatch(Map<K, T> map, Predicate<K> predicate) {
return findValueByFirstKeyMatch(map, predicate, identity());
}

private <K, T, M> Optional<T> findValueByFirstKeyMatch(Map<K, T> map, Predicate<M> predicate, Function<K, M> keyMapper) {
return map.entrySet()
.stream()
.filter(entry -> predicate.test(entry.getKey()))
.filter(entry -> predicate.test(keyMapper.apply(entry.getKey())))
.findFirst()
.map(Map.Entry::getValue);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.focusshift.zeiterfassung.report;

import java.util.List;

record ReportOvertimeDto(String personName, List<Double> overtimes) {

public Double overtimeSum() {
return overtimes.stream().reduce(0d, Double::sum);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.focusshift.zeiterfassung.report;

import java.time.LocalDate;
import java.util.List;

record ReportOvertimesDto(List<LocalDate> dayOfWeeks, List<ReportOvertimeDto> overtimes) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ private ReportWeek emptyReportWeek(Year year, int week) {

private ReportWeek emptyReportWeek(LocalDate startOfWeekDate) {
final List<ReportDay> reportDays = IntStream.rangeClosed(0, 6)
.mapToObj(daysToAdd -> new ReportDay(startOfWeekDate.plusDays(daysToAdd), Map.of(), Map.of()))
.mapToObj(daysToAdd -> new ReportDay(startOfWeekDate.plusDays(daysToAdd), Map.of(), Map.of(), Map.of()))
.toList();

return new ReportWeek(startOfWeekDate, reportDays);
Expand Down
Loading

0 comments on commit df2fa69

Please sign in to comment.