Skip to content

Commit 1d5de99

Browse files
committed
show weekly overtime on report view
1 parent e6ef635 commit 1d5de99

21 files changed

+513
-180
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package de.focusshift.zeiterfassung.overtime;
2+
3+
import de.focusshift.zeiterfassung.timeentry.TimeEntryDuration;
4+
5+
import java.math.BigDecimal;
6+
import java.math.RoundingMode;
7+
import java.time.Duration;
8+
import java.util.Objects;
9+
10+
public final class OvertimeDuration implements TimeEntryDuration {
11+
12+
public static OvertimeDuration ZERO = new OvertimeDuration(Duration.ZERO);
13+
14+
private final Duration value;
15+
16+
public OvertimeDuration(Duration value) {
17+
this.value = value;
18+
}
19+
20+
@Override
21+
public Duration value() {
22+
return value;
23+
}
24+
25+
@Override
26+
public Duration minutes() {
27+
final long seconds = value.toSeconds();
28+
29+
return seconds % 60 == 0
30+
? value
31+
: Duration.ofMinutes(value.toMinutes() + 1);
32+
}
33+
34+
@Override
35+
public double hoursDoubleValue() {
36+
final long minutes = minutes().toMinutes();
37+
return minutesToHours(minutes);
38+
}
39+
40+
public OvertimeDuration plus(Duration duration) {
41+
return new OvertimeDuration(value.plus(duration));
42+
}
43+
44+
public OvertimeDuration plus(OvertimeDuration overtimeDuration) {
45+
return new OvertimeDuration(value.plus(overtimeDuration.value));
46+
}
47+
48+
@Override
49+
public boolean equals(Object o) {
50+
if (this == o) return true;
51+
if (o == null || getClass() != o.getClass()) return false;
52+
OvertimeDuration that = (OvertimeDuration) o;
53+
return Objects.equals(value, that.value);
54+
}
55+
56+
@Override
57+
public int hashCode() {
58+
return Objects.hash(value);
59+
}
60+
61+
@Override
62+
public String toString() {
63+
return "OvertimeDuration{" +
64+
"value=" + value +
65+
'}';
66+
}
67+
68+
private static double minutesToHours(long minutes) {
69+
return BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 2, RoundingMode.CEILING).doubleValue();
70+
}
71+
}

src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package de.focusshift.zeiterfassung.report;
22

3+
import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
4+
import de.focusshift.zeiterfassung.timeentry.PlannedWorkingHours;
35
import de.focusshift.zeiterfassung.user.DateFormatter;
46
import de.focusshift.zeiterfassung.user.UserId;
57
import de.focusshift.zeiterfassung.usermanagement.User;
68
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
9+
import org.apache.commons.collections4.SetUtils;
710
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
811
import org.springframework.stereotype.Component;
912
import org.springframework.ui.Model;
@@ -14,10 +17,22 @@
1417
import java.time.ZoneId;
1518
import java.time.ZonedDateTime;
1619
import java.time.temporal.ChronoField;
20+
import java.util.ArrayList;
1721
import java.util.Date;
22+
import java.util.HashMap;
23+
import java.util.HashSet;
1824
import java.util.List;
25+
import java.util.Map;
26+
import java.util.Optional;
27+
import java.util.Set;
28+
import java.util.function.Function;
1929

30+
import static java.util.Comparator.comparing;
31+
import static java.util.function.Function.identity;
2032
import static java.util.stream.Collectors.joining;
33+
import static java.util.stream.Collectors.toList;
34+
import static java.util.stream.Collectors.toMap;
35+
import static java.util.stream.Collectors.toSet;
2136

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

119+
ReportOvertimesDto reportOvertimesDto(ReportWeek reportWeek) {
120+
// person | M | T | W | T | F | S | S |
121+
// -----------------------------------
122+
// john | 1 | 2 | 2 | 3 | 4 | 4 | 4 | <- `ReportOvertimeDto ( personName, overtimes )`
123+
// jane | 0 | 0 | 2 | 3 | 4 | 4 | 4 | entries in the middle of the week
124+
// jack | 0 | 0 | 0 | 0 | 0 | 0 | 0 | no entries this week
125+
//
126+
// note that the first overtime won't be empty actually, but the `accumulatedOvertimeToDate`.
127+
128+
// build up `users` peace by peace. one person could have the first working day in the middle of the week (jane).
129+
final Set<User> users = new HashSet<>();
130+
131+
// {john} -> [1, 2, 2, 3, 4, 4, 4]
132+
// {jane} -> [empty, empty, 2, 3, 4, 4, 4]
133+
// {jack} -> [empty, empty, empty, empty, empty, empty, empty] (has no entries this week)
134+
final Map<User, List<Optional<OvertimeDuration>>> overtimeDurationsByUser = new HashMap<>();
135+
136+
// used to initiate the persons list of overtimes.
137+
// jane will be seen first on the third reportDay. she initially needs a list of `[null, null]`.
138+
int nrOfHandledDays = 0;
139+
140+
for (ReportDay reportDay : reportWeek.reportDays()) {
141+
142+
// planned working hours contains all users. even users without time entries at this day
143+
final Map<User, PlannedWorkingHours> plannedByUser = reportDay.plannedWorkingHoursByUser();
144+
users.addAll(plannedByUser.keySet());
145+
146+
for (User user : users) {
147+
final var durations = overtimeDurationsByUser.computeIfAbsent(user, prepareOvertimeDurationList(nrOfHandledDays));
148+
durations.add(reportDay.accumulatedOvertimeToDateEndOfBusinessByUser(user.localId()));
149+
}
150+
151+
nrOfHandledDays++;
152+
}
153+
154+
final Set<UserLocalId> userIdsWithDayEntries = users.stream().map(User::localId).collect(toSet());
155+
final Map<User, List<PlannedWorkingHours>> usersWithPlannedWorkingHours = reportWeek.plannedWorkingHoursByUser();
156+
final Map<UserLocalId, User> usersWithPlannedWorkingHoursById = usersWithPlannedWorkingHours.keySet().stream().collect(toMap(User::localId, identity()));
157+
final Set<UserLocalId> userIdsWithPlannedWorkingHours = usersWithPlannedWorkingHours.keySet().stream().map(User::localId).collect(toSet());
158+
final SetUtils.SetView<UserLocalId> userIdsWithoutDayEntries = SetUtils.difference(userIdsWithPlannedWorkingHours, userIdsWithDayEntries);
159+
for (UserLocalId userLocalId : userIdsWithoutDayEntries) {
160+
overtimeDurationsByUser.computeIfAbsent(usersWithPlannedWorkingHoursById.get(userLocalId), prepareOvertimeDurationList(nrOfHandledDays));
161+
}
162+
163+
final List<ReportOvertimeDto> overtimeDtos = overtimeDurationsByUser.entrySet().stream()
164+
.map(entry -> new ReportOvertimeDto(entry.getKey().fullName(), overtimeDurationToDouble(entry.getValue())))
165+
.sorted(comparing(ReportOvertimeDto::personName))
166+
.collect(toList());
167+
168+
return new ReportOvertimesDto(reportWeek.dateOfWeeks(), overtimeDtos);
169+
}
170+
171+
private static Function<User, List<Optional<OvertimeDuration>>> prepareOvertimeDurationList(int nrOfHandledDays) {
172+
return (unused) -> {
173+
final List<Optional<OvertimeDuration>> objects = new ArrayList<>();
174+
for (int i = 0; i < nrOfHandledDays; i++) {
175+
objects.add(Optional.empty());
176+
}
177+
return objects;
178+
};
179+
}
180+
181+
private static List<Double> overtimeDurationToDouble(List<Optional<OvertimeDuration>> overtimeDurations) {
182+
return overtimeDurations.stream()
183+
.map(maybe -> maybe.orElse(null))
184+
.map(overtimeDuration -> overtimeDuration == null ? null : overtimeDuration.hoursDoubleValue())
185+
.collect(toList());
186+
}
187+
104188
String createUrl(String prefix, boolean allUsersSelected, List<UserLocalId> selectedUserLocalIds) {
105189
String url = prefix;
106190

src/main/java/de/focusshift/zeiterfassung/report/ReportDay.java

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package de.focusshift.zeiterfassung.report;
22

3+
import de.focusshift.zeiterfassung.overtime.OvertimeDuration;
34
import de.focusshift.zeiterfassung.timeentry.PlannedWorkingHours;
45
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
6+
import de.focusshift.zeiterfassung.usermanagement.User;
57
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
68

79
import java.time.Duration;
@@ -10,12 +12,18 @@
1012
import java.util.List;
1113
import java.util.Map;
1214
import java.util.Optional;
15+
import java.util.function.Function;
1316
import java.util.function.Predicate;
1417
import java.util.stream.Stream;
1518

19+
import static java.util.function.Function.identity;
20+
import static java.util.function.Predicate.not;
21+
import static java.util.stream.Collectors.toMap;
22+
1623
record ReportDay(
1724
LocalDate date,
18-
Map<UserLocalId, PlannedWorkingHours> plannedWorkingHoursByUser,
25+
Map<User, PlannedWorkingHours> plannedWorkingHoursByUser,
26+
Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateByUser,
1927
Map<UserLocalId, List<ReportDayEntry>> reportDayEntriesByUser
2028
) {
2129

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

30-
public PlannedWorkingHours plannedWorkingHoursByUser(UserLocalId userLocalId) {
31-
return findValueByFirstKeyMatch(plannedWorkingHoursByUser, userLocalId::equals).orElse(PlannedWorkingHours.ZERO);
38+
public Optional<OvertimeDuration> accumulatedOvertimeToDateByUser(UserLocalId userLocalId) {
39+
return findValueByFirstKeyMatch(accumulatedOvertimeToDateByUser, userLocalId::equals);
3240
}
3341

34-
public WorkDuration workDuration() {
42+
public Optional<OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser(UserLocalId userLocalId) {
3543

36-
final Stream<ReportDayEntry> allReportDayEntries = reportDayEntriesByUser.values()
44+
final Optional<PlannedWorkingHours> plannedWorkingHours = plannedWorkingHoursByUser.entrySet()
3745
.stream()
38-
.flatMap(Collection::stream);
46+
.filter(entry -> entry.getKey().localId().equals(userLocalId))
47+
.findFirst()
48+
.map(Map.Entry::getValue);
3949

40-
return calculateWorkDurationFrom(allReportDayEntries);
50+
final Optional<OvertimeDuration> overtimeStartOfBusiness = accumulatedOvertimeToDateByUser(userLocalId);
51+
52+
if (plannedWorkingHours.isEmpty()) {
53+
// 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.
54+
return overtimeStartOfBusiness;
55+
}
56+
57+
// calculate working time duration of this day
58+
// to add it to `overtimeStartOfBusiness`
59+
60+
final WorkDuration workDurationThisDay = reportDayEntriesByUser.getOrDefault(userLocalId, List.of())
61+
.stream()
62+
.filter(not(ReportDayEntry::isBreak))
63+
.map(ReportDayEntry::workDuration)
64+
.reduce(WorkDuration.ZERO, WorkDuration::plus);
65+
66+
final Duration overtimeDurationThisDay = plannedWorkingHours.get().value().negated().plus(workDurationThisDay.value());
67+
final OvertimeDuration overtimeEndOfBusiness = overtimeStartOfBusiness.orElse(OvertimeDuration.ZERO).plus(new OvertimeDuration(overtimeDurationThisDay));
68+
return Optional.of(overtimeEndOfBusiness);
4169
}
4270

43-
public WorkDuration workDurationByUser(UserLocalId userLocalId) {
44-
return workDurationByUserPredicate(userLocalId::equals);
71+
public Map<UserLocalId, OvertimeDuration> accumulatedOvertimeToDateEndOfBusinessByUser() {
72+
// `accumulatedOvertimeToDateByUser` could not contain persons with timeEntries at this day.
73+
// we need to iterate ALL persons that should have worked this day.
74+
final Map<UserLocalId, OvertimeDuration> collect = plannedWorkingHoursByUser.keySet()
75+
.stream()
76+
.map(user -> Map.entry(user.localId(), accumulatedOvertimeToDateEndOfBusinessByUser(user.localId()).orElse(OvertimeDuration.ZERO)))
77+
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
78+
79+
return collect;
4580
}
4681

47-
private WorkDuration workDurationByUserPredicate(Predicate<UserLocalId> predicate) {
48-
final List<ReportDayEntry> reportDayEntries = findValueByFirstKeyMatch(reportDayEntriesByUser, predicate).orElse(List.of());
49-
return calculateWorkDurationFrom(reportDayEntries.stream());
82+
public WorkDuration workDuration() {
83+
84+
final Stream<ReportDayEntry> allReportDayEntries = reportDayEntriesByUser.values()
85+
.stream()
86+
.flatMap(Collection::stream);
87+
88+
return calculateWorkDurationFrom(allReportDayEntries);
5089
}
5190

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

62101
private <K, T> Optional<T> findValueByFirstKeyMatch(Map<K, T> map, Predicate<K> predicate) {
102+
return findValueByFirstKeyMatch(map, predicate, identity());
103+
}
104+
105+
private <K, T, M> Optional<T> findValueByFirstKeyMatch(Map<K, T> map, Predicate<M> predicate, Function<K, M> keyMapper) {
63106
return map.entrySet()
64107
.stream()
65-
.filter(entry -> predicate.test(entry.getKey()))
108+
.filter(entry -> predicate.test(keyMapper.apply(entry.getKey())))
66109
.findFirst()
67110
.map(Map.Entry::getValue);
68111
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package de.focusshift.zeiterfassung.report;
2+
3+
import java.util.List;
4+
5+
record ReportOvertimeDto(String personName, List<Double> overtimes) {
6+
7+
public Double overtimeSum() {
8+
return overtimes.stream().reduce(0d, Double::sum);
9+
}
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package de.focusshift.zeiterfassung.report;
2+
3+
import java.time.LocalDate;
4+
import java.util.List;
5+
6+
record ReportOvertimesDto(List<LocalDate> dayOfWeeks, List<ReportOvertimeDto> overtimes) {
7+
}

src/main/java/de/focusshift/zeiterfassung/report/ReportServicePermissionAware.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ private ReportWeek emptyReportWeek(Year year, int week) {
111111

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

117117
return new ReportWeek(startOfWeekDate, reportDays);

0 commit comments

Comments
 (0)