Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

show accumulated overtime on report view #248

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package de.focusshift.zeiterfassung.usermanagement;
package de.focusshift.zeiterfassung.overtime;

import de.focusshift.zeiterfassung.usermanagement.UserLocalId;

import java.math.BigDecimal;
import java.time.Duration;
Expand All @@ -17,11 +19,11 @@ public final class OvertimeAccount {
private final boolean allowed;
private final Duration maxAllowedOvertime;

OvertimeAccount(UserLocalId userLocalId, boolean allowed) {
public OvertimeAccount(UserLocalId userLocalId, boolean allowed) {
this(userLocalId, allowed, null);
}

OvertimeAccount(UserLocalId userLocalId, boolean allowed, Duration maxAllowedOvertime) {
public OvertimeAccount(UserLocalId userLocalId, boolean allowed, Duration maxAllowedOvertime) {
this.userLocalId = userLocalId;
this.allowed = allowed;
this.maxAllowedOvertime = maxAllowedOvertime;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package de.focusshift.zeiterfassung.usermanagement;
package de.focusshift.zeiterfassung.overtime;

import de.focusshift.zeiterfassung.tenancy.tenant.AbstractTenantAwareEntity;
import de.focusshift.zeiterfassung.tenancy.user.TenantUserEntity;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package de.focusshift.zeiterfassung.usermanagement;
package de.focusshift.zeiterfassung.overtime;

import org.springframework.data.jpa.repository.JpaRepository;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package de.focusshift.zeiterfassung.usermanagement;
package de.focusshift.zeiterfassung.overtime;

import de.focusshift.zeiterfassung.usermanagement.UserLocalId;

public interface OvertimeAccountService {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.focusshift.zeiterfassung.usermanagement;
package de.focusshift.zeiterfassung.overtime;

import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import org.springframework.stereotype.Service;

import java.time.Duration;
Expand Down
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