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

Summary of planned / worked and delta hours #874

Merged
merged 23 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f8acad6
WIP: Summary of planned / worked and delta hours
honnel Oct 2, 2024
f492f4c
use definition list to render report summary
bseber Oct 4, 2024
e2c3942
move report summary styling into css
bseber Oct 4, 2024
277bcbf
move report summary block below the chart
bseber Oct 4, 2024
0cbfc4c
align report summary with chart text
bseber Oct 4, 2024
9e17c08
responsive styling of report summary
bseber Oct 4, 2024
cbc05ad
add i18n messages
bseber Oct 4, 2024
70c6bc4
implement Report summary for week and month
bseber Oct 4, 2024
1d23974
remove report graph average line
bseber Oct 4, 2024
aedb320
remove rounded border of report graphs
bseber Oct 4, 2024
2d19a9d
add date-range-text to report week graph
bseber Oct 4, 2024
4658798
add ratio bar and use should- and actual worked hours
bseber Oct 9, 2024
b759802
add numbers/graph-bar to month report
bseber Oct 11, 2024
4003351
introduce interface for workedHoursRatio
bseber Oct 11, 2024
ce69195
add worked/shouldWorked to details on report view
bseber Oct 11, 2024
ac11564
only render delta on details when not 00:00
bseber Oct 11, 2024
90f1705
little fine tuning of detail list on report page
bseber Oct 11, 2024
ba15209
hide worked and shouldWorked info when both is zero
bseber Oct 11, 2024
c5631f8
align time and duration in report detail time-entries in same column
bseber Oct 11, 2024
b1f40c9
sticky day header in report detail view
bseber Oct 11, 2024
9493d77
align delta on the right in report graph
bseber Oct 11, 2024
6de96d6
remove obsolete dto attribute
bseber Oct 11, 2024
b1bb848
remove rounded variable from report svg charts
bseber Oct 11, 2024
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
24 changes: 24 additions & 0 deletions src/main/css/reports.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,27 @@
height: 3rem;
@apply rounded-lg;
}

.report-summary-grid {
dt:not(:first-of-type) {
margin-top: 1rem;
}

dd {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}

@screen sm {
.report-summary-grid {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1rem;

dt:not(:first-of-type) {
margin-top: 0;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package de.focusshift.zeiterfassung.report;

import java.time.Duration;
import java.util.List;

record DetailDayDto(
boolean differentMonth,
String dayOfWeek,
String dayOfWeekFull,
String date,
Duration hoursWorked,
String workedWorkingHours,
String shouldWorkingHours,
String hoursDelta,
boolean hoursDeltaNegative,
List<DetailDayEntryDto> dayEntries,
List<DetailDayAbsenceDto> absences
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

import java.util.List;

record GraphMonthDto(String yearMonth, List<GraphWeekDto> weekReports, Double maxHoursWorked,
Double averageHoursWorked) {
record GraphMonthDto(
String yearMonth,
List<GraphWeekDto> weekReports,
Double maxHoursWorked,
String workedWorkingHours,
String shouldWorkingHours,
String hoursDelta,
boolean hoursDeltaNegative,
double hoursWorkedRatio
) {

public Double graphLegendMaxHour() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import java.util.List;

record GraphWeekDto(
String yearMonthWeek,
int calendarWeek,
String dateRangeString,
List<GraphDayDto> dayReports,
Double maxHoursWorked,
Double averageHoursWorked
String workedWorkingHours,
String shouldWorkingHours,
String hoursDelta,
boolean hoursDeltaNegative,
double hoursWorkedRatio
) {

public Double graphLegendMaxHour() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.absence.Absence;
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.user.DateFormatter;
import de.focusshift.zeiterfassung.user.DateRangeFormatter;
import de.focusshift.zeiterfassung.user.UserId;
import de.focusshift.zeiterfassung.usermanagement.User;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;

import java.math.BigDecimal;
import java.math.MathContext;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;
Expand All @@ -28,10 +33,12 @@ class ReportControllerHelper {

private final ReportPermissionService reportPermissionService;
private final DateFormatter dateFormatter;
private final DateRangeFormatter dateRangeFormatter;

ReportControllerHelper(ReportPermissionService reportPermissionService, DateFormatter dateFormatter) {
ReportControllerHelper(ReportPermissionService reportPermissionService, DateFormatter dateFormatter, DateRangeFormatter dateRangeFormatter) {
this.reportPermissionService = reportPermissionService;
this.dateFormatter = dateFormatter;
this.dateRangeFormatter = dateRangeFormatter;
}

UserId principalToUserId(OidcUser principal) {
Expand Down Expand Up @@ -65,16 +72,31 @@ GraphWeekDto toGraphWeekDto(ReportWeek reportWeek, Month monthPivot) {
.map(reportDay -> toUserReportDayReportDto(reportDay, !reportDay.date().getMonth().equals(monthPivot)))
.toList();

final String yearMonthWeek = dateFormatter.formatYearMonthWeek(reportWeek.firstDateOfWeek());
final int calendarWeek = reportWeek.firstDateOfWeek().get(ChronoField.ALIGNED_WEEK_OF_YEAR);
final String dateRangeString = dateRangeFormatter.toDateRangeString(reportWeek.firstDateOfWeek(), reportWeek.lastDateOfWeek());

final double maxHoursWorked = dayReports.stream()
.map(GraphDayDto::hoursWorked)
.mapToDouble(value -> value)
.max().orElse(0.0);

final double hoursWorkedAverageADay = reportWeek.averageDayWorkDuration().hoursDoubleValue();
final WorkDuration workDuration = reportWeek.workDuration();
final ShouldWorkingHours shouldWorkingHours = reportWeek.shouldWorkingHours();
final String shouldWorkingHoursString = durationToTimeString(shouldWorkingHours.duration());
final String workedWorkingHoursString = durationToTimeString(workDuration.duration());

return new GraphWeekDto(yearMonthWeek, dayReports, maxHoursWorked, hoursWorkedAverageADay);
final Duration deltaDuration = workDuration.duration().minus(shouldWorkingHours.duration());
final String deltaHours = durationToTimeString(deltaDuration);

final double weekRatio = reportWeek.workedHoursRatio().multiply(BigDecimal.valueOf(100), new MathContext(2)).doubleValue();

return new GraphWeekDto(calendarWeek, dateRangeString, dayReports, maxHoursWorked, workedWorkingHoursString, shouldWorkingHoursString, deltaHours, deltaDuration.isNegative(), weekRatio);
}

private static String durationToTimeString(Duration duration) {
// use positive values to format duration string
// negative value is handled in template
return String.format("%02d:%02d", Math.abs(duration.toHours()), Math.abs(duration.toMinutesPart()));
}

private GraphDayDto toUserReportDayReportDto(ReportDay reportDay, boolean differentMonth) {
Expand Down Expand Up @@ -136,15 +158,22 @@ private DetailDayDto toDetailDayReportDto(ReportDay reportDay, boolean different
final String dayOfWeekNarrow = dateFormatter.formatDayOfWeekNarrow(reportDay.date().getDayOfWeek());
final String dayOfWeekFull = dateFormatter.formatDayOfWeekFull(reportDay.date().getDayOfWeek());
final String dateString = dateFormatter.formatDate(reportDay.date());
final Duration hoursWorked = reportDay.workDuration().duration();
final List<DetailDayEntryDto> dayEntryDtos = reportDay.reportDayEntries().stream().map(this::toDetailDayEntryDto).toList();

final List<DetailDayAbsenceDto> detailDayAbsenceDto = reportDay.detailDayAbsencesByUser().values().stream()
.flatMap(Collection::stream)
.map(reportDayAbsence -> toDetailDayAbsenceDto(reportDayAbsence, locale))
.toList();

return new DetailDayDto(differentMonth, dayOfWeekNarrow, dayOfWeekFull, dateString, hoursWorked, dayEntryDtos, detailDayAbsenceDto);
final WorkDuration workDuration = reportDay.workDuration();
final ShouldWorkingHours shouldWorkingHours = reportDay.shouldWorkingHours();
final String shouldWorkingHoursString = durationToTimeString(shouldWorkingHours.duration());
final String workedWorkingHoursString = durationToTimeString(workDuration.duration());

final Duration deltaDuration = workDuration.duration().minus(shouldWorkingHours.duration());
final String deltaHours = durationToTimeString(deltaDuration);

return new DetailDayDto(differentMonth, dayOfWeekNarrow, dayOfWeekFull, dateString, workedWorkingHoursString, shouldWorkingHoursString, deltaHours, deltaDuration.isNegative(), dayEntryDtos, detailDayAbsenceDto);
}

private DetailDayEntryDto toDetailDayEntryDto(ReportDayEntry reportDayEntry) {
Expand Down
26 changes: 25 additions & 1 deletion src/main/java/de/focusshift/zeiterfassung/report/ReportDay.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.absence.Absence;
import de.focusshift.zeiterfassung.absence.DayLength;
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.user.UserIdComposite;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
Expand All @@ -18,7 +21,6 @@ record ReportDay(
Map<UserIdComposite, PlannedWorkingHours> plannedWorkingHoursByUser,
Map<UserIdComposite, List<ReportDayEntry>> reportDayEntriesByUser,
Map<UserIdComposite, List<ReportDayAbsence>> detailDayAbsencesByUser

) {

public List<ReportDayEntry> reportDayEntries() {
Expand All @@ -29,6 +31,28 @@ public PlannedWorkingHours plannedWorkingHours() {
return plannedWorkingHoursByUser.values().stream().reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
}

public ShouldWorkingHours shouldWorkingHours() {

final double absenceDayLengthValue = detailDayAbsencesByUser.values().stream()
.flatMap(Collection::stream)
.map(ReportDayAbsence::absence)
.map(Absence::dayLength)
.map(DayLength::getValue)
.reduce(0.0, Double::sum);

if (absenceDayLengthValue >= 1.0) {
return ShouldWorkingHours.ZERO;
}

final PlannedWorkingHours plannedWorkingHours = plannedWorkingHours();

if (absenceDayLengthValue == 0.5) {
return new ShouldWorkingHours(plannedWorkingHours.duration().dividedBy(2));
}

return new ShouldWorkingHours(plannedWorkingHours.duration());
}

public PlannedWorkingHours plannedWorkingHoursByUser(UserLocalId userLocalId) {
return findValueByFirstKeyMatch(plannedWorkingHoursByUser, userIdComposite -> userLocalId.equals(userIdComposite.localId()))
.orElse(PlannedWorkingHours.ZERO);
Expand Down
25 changes: 20 additions & 5 deletions src/main/java/de/focusshift/zeiterfassung/report/ReportMonth.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.timeentry.HasWorkedHoursRatio;
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.workingtime.PlannedWorkingHours;

Expand All @@ -10,7 +12,19 @@

import static java.util.function.Predicate.not;

record ReportMonth(YearMonth yearMonth, List<ReportWeek> weeks) {
record ReportMonth(YearMonth yearMonth, List<ReportWeek> weeks) implements HasWorkedHoursRatio {

public PlannedWorkingHours plannedWorkingHours() {
return weeks.stream()
.map(ReportWeek::plannedWorkingHours)
.reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
}

public ShouldWorkingHours shouldWorkingHours() {
return weeks.stream()
.map(ReportWeek::shouldWorkingHours)
.reduce(ShouldWorkingHours.ZERO, ShouldWorkingHours::plus);
}

public WorkDuration averageDayWorkDuration() {

Expand All @@ -29,9 +43,10 @@ public WorkDuration averageDayWorkDuration() {
return new WorkDuration(duration);
}

public PlannedWorkingHours plannedWorkingHours() {
return weeks.stream()
.map(ReportWeek::plannedWorkingHours)
.reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
public WorkDuration workDuration() {
return weeks
.stream()
.map(ReportWeek::workDuration)
.reduce(WorkDuration.ZERO, WorkDuration::plus);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import de.focus_shift.launchpad.api.HasLaunchpad;
import de.focusshift.zeiterfassung.timeclock.HasTimeClock;
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.user.DateFormatter;
import de.focusshift.zeiterfassung.usermanagement.UserLocalId;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -18,8 +20,11 @@
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.math.BigDecimal;
import java.math.MathContext;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.YearMonth;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -142,9 +147,17 @@ private GraphMonthDto toGraphMonthDto(ReportMonth reportMonth) {
.mapToDouble(value -> value)
.max().orElse(0.0);

final double hoursWorkedAverageADay = reportMonth.averageDayWorkDuration().hoursDoubleValue();
final WorkDuration workDuration = reportMonth.workDuration();
final ShouldWorkingHours shouldWorkingHours = reportMonth.shouldWorkingHours();
final String shouldWorkingHoursString = durationToTimeString(shouldWorkingHours.duration());
final String workedWorkingHoursString = durationToTimeString(workDuration.duration());

return new GraphMonthDto(yearMonth, graphWeekDtos, maxHoursWorked, hoursWorkedAverageADay);
final Duration deltaDuration = workDuration.duration().minus(shouldWorkingHours.duration());
final String deltaHours = durationToTimeString(deltaDuration);

final double weekRatio = reportMonth.workedHoursRatio().multiply(BigDecimal.valueOf(100), new MathContext(2)).doubleValue();

return new GraphMonthDto(yearMonth, graphWeekDtos, maxHoursWorked, workedWorkingHoursString, shouldWorkingHoursString, deltaHours, deltaDuration.isNegative(), weekRatio);
}

private DetailMonthDto toDetailMonthDto(ReportMonth reportMonth, Locale locale) {
Expand All @@ -167,4 +180,10 @@ private static Optional<YearMonth> yearMonth(int year, int month) {
return Optional.empty();
}
}

private static String durationToTimeString(Duration duration) {
// use positive values to format duration string
// negative value is handled in template
return String.format("%02d:%02d", Math.abs(duration.toHours()), Math.abs(duration.toMinutesPart()));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.focusshift.zeiterfassung.report;

import de.focusshift.zeiterfassung.timeentry.HasWorkedHoursRatio;
import de.focusshift.zeiterfassung.timeentry.ShouldWorkingHours;
import de.focusshift.zeiterfassung.timeentry.WorkDuration;
import de.focusshift.zeiterfassung.workingtime.PlannedWorkingHours;

Expand All @@ -9,14 +11,20 @@

import static java.util.function.Predicate.not;

record ReportWeek(LocalDate firstDateOfWeek, List<ReportDay> reportDays) {
record ReportWeek(LocalDate firstDateOfWeek, List<ReportDay> reportDays) implements HasWorkedHoursRatio {

public PlannedWorkingHours plannedWorkingHours() {
return reportDays.stream()
.map(ReportDay::plannedWorkingHours)
.reduce(PlannedWorkingHours.ZERO, PlannedWorkingHours::plus);
}

public ShouldWorkingHours shouldWorkingHours() {
return reportDays.stream()
.map(ReportDay::shouldWorkingHours)
.reduce(ShouldWorkingHours.ZERO, ShouldWorkingHours::plus);
}

public WorkDuration averageDayWorkDuration() {

final double averageMinutes = reportDays().stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package de.focusshift.zeiterfassung.timeentry;

import java.math.BigDecimal;

import static java.math.RoundingMode.CEILING;

public interface HasWorkedHoursRatio {

WorkDuration workDuration();

ShouldWorkingHours shouldWorkingHours();

default BigDecimal workedHoursRatio() {

final double worked = workDuration().durationInMinutes().toMinutes();
if (worked == 0) {
return BigDecimal.ZERO;
}

final double should = shouldWorkingHours().durationInMinutes().toMinutes();
if (should == 0) {
return BigDecimal.ONE;
}

final BigDecimal ratio = BigDecimal.valueOf(worked).divide(BigDecimal.valueOf(should), 2, CEILING);
return ratio.compareTo(BigDecimal.ONE) > 0 ? BigDecimal.ONE : ratio;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package de.focusshift.zeiterfassung.timeentry;

import de.focusshift.zeiterfassung.absence.Absence;
import de.focusshift.zeiterfassung.workingtime.PlannedWorkingHours;
import de.focusshift.zeiterfassung.workingtime.ZeitDuration;

import java.time.Duration;

/**
* Hours that should be worked. (e.g. PlannedWorkingHours 40h - Absence 8h = ShouldWorkingHours 32h)
* Hours that should be worked.
*
* <p>
* (e.g. {@linkplain PlannedWorkingHours} 40h - {@linkplain Absence} 8h = ShouldWorkingHours 32h)
*
* @param duration the exact duration. not rounded up to minutes.
*/
Expand Down
Loading
Loading