From 10526f05757ade712d8b99452782068842eeb6c1 Mon Sep 17 00:00:00 2001 From: Nicolas Couvert Date: Mon, 29 Jun 2026 16:52:14 +0200 Subject: [PATCH] [25] Add holiday adjustment Issue: https://github.com/ObeoNetwork/pepper/issues/25 Bug: Tasks with dependencies may incorrectly extend indefinitely. If a task is repeatedly moved backward by less than two days and this causes the end date of one of its dependencies to fall on a weekend, the dependent task may keep growing on each recalculation. --- CHANGELOG.adoc | 15 ++ .../services/view/PepperMMJavaService.java | 166 +++++++++++++----- .../view/PepperMMJavaServiceTests.java | 17 +- 3 files changed, 142 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index fb6dc6d..c8899be 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,5 +1,20 @@ = Changelog +== v2026.X.X + +=== Dependency update + +=== New features + + +=== Improvements + + +=== Bug fixes + +- https://github.com/ObeoNetwork/pepper/issues/25[#25] Add holiday adjustment to prevent tasks from starting or ending on weekends. +Bug: Tasks with dependencies may incorrectly extend indefinitely. If a task is repeatedly moved backward by less than two days and this causes the end date of one of its dependencies to fall on a weekend, the dependent task may keep growing on each recalculation. + == v2026.6.1 === Dependency update diff --git a/backend/pepper-starter/src/main/java/pepper/starter/services/view/PepperMMJavaService.java b/backend/pepper-starter/src/main/java/pepper/starter/services/view/PepperMMJavaService.java index ac5dd8b..e39ce9e 100644 --- a/backend/pepper-starter/src/main/java/pepper/starter/services/view/PepperMMJavaService.java +++ b/backend/pepper-starter/src/main/java/pepper/starter/services/view/PepperMMJavaService.java @@ -12,6 +12,7 @@ ******************************************************************************/ package pepper.starter.services.view; +import graphql.util.Pair; import pepper.peppermm.AbstractTask; import pepper.peppermm.DependencyLink; import pepper.peppermm.DependencyRelatedObject; @@ -25,11 +26,13 @@ import pepper.peppermm.TaskTag; import pepper.peppermm.Workpackage; +import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashSet; @@ -131,17 +134,16 @@ else if (zonedDateTimeEnd.getHour() <= 18) { if (startTimeControlledByDependency && !endTimeControlledByDependency) { this.setTaskDuration(task, newStartTime, newEndTime); newEndTime = newEndTime.plus(differenceStart, ChronoUnit.SECONDS); - task.setEndTime(newEndTime); + adjustTaskTimesHoliday(task, task.getStartTime(), newEndTime, false); } else if (!startTimeControlledByDependency && endTimeControlledByDependency) { this.setTaskDuration(task, newStartTime, newEndTime); newStartTime = newStartTime.plus(differenceEnd, ChronoUnit.SECONDS); - task.setStartTime(newStartTime); + adjustTaskTimesHoliday(task, newStartTime, task.getEndTime(), true); } else if (!startTimeControlledByDependency && !endTimeControlledByDependency) { if (!keepDuration) { this.setTaskDuration(task, newStartTime, newEndTime); } - task.setStartTime(newStartTime); - task.setEndTime(newEndTime); + adjustTaskTimesHoliday(task, newStartTime, newEndTime, false); } if (!startTimeControlledByDependency || !endTimeControlledByDependency) { followMoveDependency(task); @@ -165,13 +167,13 @@ public void createTask(EObject context) { if (context instanceof AbstractTask abstractTask) { // The new task follows the context task and has the same duration as the context task. if (abstractTask.getEndTime() != null && abstractTask.getStartTime() != null) { + Instant start = abstractTask.getEndTime(); + Instant end = Instant.ofEpochSecond(2 * abstractTask.getEndTime().getEpochSecond() - abstractTask.getStartTime().getEpochSecond()); if (abstractTask.getEndTime().equals(abstractTask.getStartTime())) { // If the task is a Milestone - task.setStartTime(abstractTask.getEndTime()); - task.setEndTime(Instant.ofEpochSecond(2 * abstractTask.getEndTime().getEpochSecond() - abstractTask.getStartTime().getEpochSecond())); + adjustTaskTimesHoliday(task, start, end, false); } else { - task.setStartTime(abstractTask.getEndTime().plus(1, ChronoUnit.MINUTES)); - task.setEndTime(Instant.ofEpochSecond(2 * abstractTask.getEndTime().getEpochSecond() - abstractTask.getStartTime().getEpochSecond()).plus(1, ChronoUnit.MINUTES)); + adjustTaskTimesHoliday(task, start.plus(1, ChronoUnit.MINUTES), end.plus(1, ChronoUnit.MINUTES), false); } } @@ -183,12 +185,70 @@ public void createTask(EObject context) { int index = parentTask.getSubTasks().indexOf(context); parentTask.getSubTasks().add(index + 1, task); } - } else if (context instanceof Workpackage workpackage) { - long epochSecondStartTime = Instant.now().getEpochSecond(); - task.setStartTime(Instant.ofEpochMilli(epochSecondStartTime)); - task.setEndTime(Instant.ofEpochMilli(epochSecondStartTime + 3600 * 4)); + } + } - workpackage.getOwnedTasks().add(task); + private void adjustTaskTimesHoliday(Task task, Instant start, Instant end, boolean isTaskEndPointed) { + Pair startEnd = new Pair<>(start, end); + if (isInstantHoliday(start, true) || isInstantHoliday(end, false)) { + startEnd = adjustTaskHoliday(start, end, isTaskEndPointed); + } + setTaskDuration(task, startEnd.first, startEnd.second); + task.setStartTime(startEnd.first); + task.setEndTime(startEnd.second); + } + + private Pair adjustTaskHoliday(Instant start, Instant end, boolean isTaskEndPointed) { + Instant newStart = start; + Instant newEnd = end; + + if (isTaskEndPointed) { + while (isInstantHoliday(newEnd, false)) { + newEnd = newEnd.minus(12, ChronoUnit.HOURS); //TEMPORAIRE + } + long durationChange = newEnd.getEpochSecond() - end.getEpochSecond(); + newStart = newStart.plusSeconds(durationChange); + while (isInstantHoliday(newStart, true)) { + newStart = newStart.minus(12, ChronoUnit.HOURS); //TEMPORAIRE + } + boolean test = false; + } else { + while (isInstantHoliday(newStart, true)) { + newStart = newStart.plus(12, ChronoUnit.HOURS); //TEMPORAIRE + } + long durationChange = newStart.getEpochSecond() - start.getEpochSecond(); + newEnd = newEnd.plusSeconds(durationChange); + while (isInstantHoliday(newEnd, false)) { + newEnd = newEnd.plus(12, ChronoUnit.HOURS); //TEMPORAIRE + } + } + + return new Pair<>(newStart, newEnd); + } + + private boolean isNonWorkingDay(Temporal temporal) { + LocalDate date = null; + if (temporal instanceof Instant instant) { + date = LocalDate.ofInstant(instant, ZoneId.systemDefault()); + } else if (temporal instanceof LocalDate localDate) { + date = localDate; + } + return date != null && (date.getDayOfWeek().equals(DayOfWeek.SATURDAY) || date.getDayOfWeek().equals(DayOfWeek.SUNDAY)); + } + + private boolean isInstantHoliday(Instant instant, boolean isStartOfTask) { + boolean isNonWorkingDay = isNonWorkingDay(instant); + boolean isPreviousNonWorkingDay = isNonWorkingDay(instant.minus(1, ChronoUnit.DAYS)); + if (isStartOfTask) { + return isNonWorkingDay; + } else { + int hours = instant.atZone(ZoneId.systemDefault()).getHour(); + int minutes = instant.atZone(ZoneId.systemDefault()).getMinute(); + boolean isDayStart = hours == 0 && minutes == 0; + boolean cond1 = isNonWorkingDay && !isPreviousNonWorkingDay && !isDayStart; + boolean cond2 = isNonWorkingDay && isPreviousNonWorkingDay; + boolean cond3 = !isNonWorkingDay && isPreviousNonWorkingDay && isDayStart; + return cond1 || cond2 || cond3; } } @@ -587,27 +647,23 @@ private void setTaskNewDates(Task task, DependencyLink dep) { Instant newTaskStart = sourceEnd.plus(delay, ChronoUnit.HOURS) .plus(startAdjustmentMinutes(bestSourceTask), ChronoUnit.MINUTES); Instant newTaskEnd = Instant.ofEpochSecond(newTaskStart.getEpochSecond() + oldTaskEnd.getEpochSecond() - oldTaskStart.getEpochSecond()); - task.setEndTime(newTaskEnd); - task.setStartTime(newTaskStart); + adjustTaskTimesHoliday(task, newTaskStart, newTaskEnd, false); } else if (sourceStartOrEnd == StartOrEnd.START && targetStartOrEnd == StartOrEnd.START) { Instant newTaskStart = sourceStart.plus(delay, ChronoUnit.HOURS); Instant newTaskEnd = Instant.ofEpochSecond(newTaskStart.getEpochSecond() + oldTaskEnd.getEpochSecond() - oldTaskStart.getEpochSecond()); - task.setEndTime(newTaskEnd); - task.setStartTime(newTaskStart); + adjustTaskTimesHoliday(task, newTaskStart, newTaskEnd, false); } else if (sourceStartOrEnd == StartOrEnd.END && targetStartOrEnd == StartOrEnd.END) { Instant newTaskEnd = sourceEnd.plus(delay, ChronoUnit.HOURS) .plus(endAdjustmentMinutes(bestSourceTask, task), ChronoUnit.MINUTES); Instant newTaskStart = Instant.ofEpochSecond(newTaskEnd.getEpochSecond() - (oldTaskEnd.getEpochSecond() - oldTaskStart.getEpochSecond())); - task.setEndTime(newTaskEnd); - task.setStartTime(newTaskStart); + adjustTaskTimesHoliday(task, newTaskStart, newTaskEnd, true); } else if (sourceStartOrEnd == StartOrEnd.START && targetStartOrEnd == StartOrEnd.END) { Instant newTaskEnd = sourceStart.plus(delay, ChronoUnit.HOURS).minus(1, ChronoUnit.MINUTES); if (isMilestone(task)) { newTaskEnd = newTaskEnd.plus(1, ChronoUnit.MINUTES); } Instant newTaskStart = Instant.ofEpochSecond(newTaskEnd.getEpochSecond() - (oldTaskEnd.getEpochSecond() - oldTaskStart.getEpochSecond())); - task.setEndTime(newTaskEnd); - task.setStartTime(newTaskStart); + adjustTaskTimesHoliday(task, newTaskStart, newTaskEnd, true); } } @@ -620,6 +676,7 @@ private void setTaskNewEndDate(Task task, DependencyLink dep) { Instant sourceEnd = getTaskEndTime(bestSourceTask); int delay = dep.getDuration(); StartOrEnd sourceStartOrEnd = dep.getSourceKind(); + StartOrEnd targetStartOrEnd = dep.getTargetKind(); Instant newTaskEnd = task.getEndTime(); if (sourceStartOrEnd == StartOrEnd.END) { newTaskEnd = sourceEnd.plus(delay, ChronoUnit.HOURS) @@ -631,7 +688,7 @@ private void setTaskNewEndDate(Task task, DependencyLink dep) { } } setTaskDuration(task, task.getStartTime(), newTaskEnd); - task.setEndTime(newTaskEnd); + adjustTaskTimesHoliday(task, task.getStartTime(), newTaskEnd, targetStartOrEnd.equals(StartOrEnd.END)); } /** @@ -643,6 +700,7 @@ private void setTaskNewStartDate(Task task, DependencyLink dep) { Instant sourceEnd = getTaskEndTime(bestSourceTask); int delay = dep.getDuration(); StartOrEnd sourceStartOrEnd = dep.getSourceKind(); + StartOrEnd targetStartOrEnd = dep.getTargetKind(); Instant newTaskStart = task.getStartTime(); if (sourceStartOrEnd == StartOrEnd.END) { newTaskStart = sourceEnd.plus(delay, ChronoUnit.HOURS) @@ -651,7 +709,7 @@ private void setTaskNewStartDate(Task task, DependencyLink dep) { newTaskStart = sourceStart.plus(delay, ChronoUnit.HOURS); } setTaskDuration(task, task.getStartTime(), newTaskStart); - task.setStartTime(newTaskStart); + adjustTaskTimesHoliday(task, newTaskStart, task.getEndTime(), targetStartOrEnd.equals(StartOrEnd.END)); } /** @@ -681,23 +739,19 @@ private void setWorkpackageNewDates(Workpackage workpackage, DependencyLink depe if (sourceStartOrEnd == StartOrEnd.END && targetStartOrEnd == StartOrEnd.START) { LocalDate newWorkpackageStart = sourceEnd.plusDays(delay); LocalDate newWorkpackageEnd = newWorkpackageStart.plusDays(duration); - workpackage.setEndDate(newWorkpackageEnd); - workpackage.setStartDate(newWorkpackageStart); + adjustWorkpackageDatesHoliday(workpackage, newWorkpackageStart, newWorkpackageEnd); } else if (sourceStartOrEnd == StartOrEnd.START && targetStartOrEnd == StartOrEnd.START) { LocalDate newWorkpackageStart = sourceStart.plusDays(delay); LocalDate newWorkpackageEnd = newWorkpackageStart.plusDays(duration); - workpackage.setEndDate(newWorkpackageEnd); - workpackage.setStartDate(newWorkpackageStart); + adjustWorkpackageDatesHoliday(workpackage, newWorkpackageStart, newWorkpackageEnd); } else if (sourceStartOrEnd == StartOrEnd.END && targetStartOrEnd == StartOrEnd.END) { LocalDate newWorkpackageEnd = sourceEnd.plusDays(delay); LocalDate newWorkpackageStart = newWorkpackageEnd.minusDays(duration); - workpackage.setEndDate(newWorkpackageEnd); - workpackage.setStartDate(newWorkpackageStart); + adjustWorkpackageDatesHoliday(workpackage, newWorkpackageStart, newWorkpackageEnd); } else if (sourceStartOrEnd == StartOrEnd.START && targetStartOrEnd == StartOrEnd.END) { LocalDate newWorkpackageEnd = sourceStart.plusDays(delay); LocalDate newWorkpackageStart = newWorkpackageEnd.minusDays(duration); - workpackage.setEndDate(newWorkpackageEnd); - workpackage.setStartDate(newWorkpackageStart); + adjustWorkpackageDatesHoliday(workpackage, newWorkpackageStart, newWorkpackageEnd); } } @@ -721,7 +775,7 @@ private void setWorkpackageNewEndDate(Workpackage workpackage, DependencyLink de newWorkpackageEnd = sourceStart.plusDays(delay); } workpackage.setDuration((int) ChronoUnit.DAYS.between(workpackage.getStartDate(), newWorkpackageEnd)); - workpackage.setEndDate(newWorkpackageEnd); + adjustWorkpackageDatesHoliday(workpackage, workpackage.getStartDate(), newWorkpackageEnd); } /** @@ -741,7 +795,7 @@ private void setWorkpackageNewStartDate(Workpackage workpackage, DependencyLink newWorkpackageStart = sourceStart.plusDays(delay); } workpackage.setDuration((int) ChronoUnit.DAYS.between(newWorkpackageStart, workpackage.getEndDate())); - workpackage.setStartDate(newWorkpackageStart); + adjustWorkpackageDatesHoliday(workpackage, newWorkpackageStart, workpackage.getEndDate()); } private static Instant getlaterInstant(DependencyLink dep) { @@ -861,10 +915,11 @@ public void createWorkpackage(EObject context) { Workpackage newWorkpackage = PepperFactory.eINSTANCE.createWorkpackage(); newWorkpackage.setName("New Workpackage"); if (context instanceof Workpackage workpackage) { - // The new task follows the context task and has the same duration than the context task. + // The new task follows the context task and has the same duration as the context task. if (workpackage.getEndDate() != null && workpackage.getStartDate() != null) { - newWorkpackage.setStartDate(workpackage.getEndDate().plusDays(1)); - newWorkpackage.setEndDate(workpackage.getEndDate().plusDays(workpackage.getEndDate().toEpochDay() - workpackage.getStartDate().toEpochDay() + 1)); + LocalDate start = workpackage.getEndDate().plusDays(1); + LocalDate end = workpackage.getEndDate().plusDays(workpackage.getEndDate().toEpochDay() - workpackage.getStartDate().toEpochDay() + 1); + adjustWorkpackageDatesHoliday(newWorkpackage, start, end); } EObject parent = context.eContainer(); @@ -872,12 +927,6 @@ public void createWorkpackage(EObject context) { int index = project.getOwnedWorkpackages().indexOf(context); project.getOwnedWorkpackages().add(index + 1, newWorkpackage); } - } else if (context instanceof Project project) { - LocalDate now = LocalDate.now(); - newWorkpackage.setStartDate(now); - newWorkpackage.setEndDate(now.plusDays(28)); - - project.getOwnedWorkpackages().add(newWorkpackage); } } @@ -910,16 +959,15 @@ public void editWorkpackage(EObject eObject, String name, String description, Lo if (dependencies.isEmpty() || differenceEnd != differenceStart) { if (startDateControlledByDependency && !endDateControlledByDependency) { this.workpackageSetDuration(workpackage, startDate, endDate); - workpackage.setEndDate(endDate.plusDays(differenceStart)); + adjustWorkpackageDatesHoliday(workpackage, workpackage.getStartDate(), endDate.plusDays(differenceStart)); } else if (endDateControlledByDependency && !startDateControlledByDependency) { this.workpackageSetDuration(workpackage, startDate, endDate); - workpackage.setStartDate(startDate.plusDays(differenceEnd)); + adjustWorkpackageDatesHoliday(workpackage, startDate.plusDays(differenceEnd), workpackage.getEndDate()); } else if (!startDateControlledByDependency && !endDateControlledByDependency) { if (!keepDuration) { this.workpackageSetDuration(workpackage, startDate, endDate); } - workpackage.setStartDate(startDate); - workpackage.setEndDate(endDate); + adjustWorkpackageDatesHoliday(workpackage, startDate, endDate); } if (!startDateControlledByDependency || !endDateControlledByDependency) { followMoveDependency(workpackage); @@ -932,6 +980,32 @@ public void editWorkpackage(EObject eObject, String name, String description, Lo } } + private void adjustWorkpackageDatesHoliday(Workpackage workpackage, LocalDate start, LocalDate end) { + Pair startEnd = new Pair<>(start, end); + if (isNonWorkingDay(start) || isNonWorkingDay(end)) { + startEnd = adjustWorkpackageHoliday(start, end); + } + workpackage.setStartDate(startEnd.first); + workpackage.setEndDate(startEnd.second); + } + + private Pair adjustWorkpackageHoliday(LocalDate start, LocalDate end) { + LocalDate newStart = start; + LocalDate newEnd = end; + + while (isNonWorkingDay(newStart)) { + newStart = newStart.plusDays(1); //TEMPORAIRE + } + + long durationChange = newStart.toEpochDay() - start.toEpochDay(); + newEnd = newEnd.plusDays(durationChange); + while (isNonWorkingDay(newEnd)) { + newEnd = newEnd.plusDays(1); //TEMPORAIRE + } + + return new Pair<>(newStart, newEnd); + } + private void workpackageSetDuration(Workpackage workpackage, LocalDate start, LocalDate end) { int duration = (int) ChronoUnit.DAYS.between(start, end) + 1; //+1 because between(00:00, 00:59) = 0. We want 1. workpackage.setDuration(duration); diff --git a/backend/pepper-starter/src/test/java/pepper/starter/configuration/view/PepperMMJavaServiceTests.java b/backend/pepper-starter/src/test/java/pepper/starter/configuration/view/PepperMMJavaServiceTests.java index 7d56c59..f4bbe5e 100644 --- a/backend/pepper-starter/src/test/java/pepper/starter/configuration/view/PepperMMJavaServiceTests.java +++ b/backend/pepper-starter/src/test/java/pepper/starter/configuration/view/PepperMMJavaServiceTests.java @@ -309,11 +309,8 @@ public void createTask() { workpackage.getOwnedTasks().add(task1); var service = new PepperMMJavaService(new IFeedbackMessageService.NoOp()); - service.createTask(workpackage); - assertThat(workpackage.getOwnedTasks()).hasSize(2); - service.createTask(task1); - assertThat(workpackage.getOwnedTasks()).hasSize(3); + assertThat(workpackage.getOwnedTasks()).hasSize(2); assertThat(workpackage.getOwnedTasks().get(1).getStartTime()).isEqualTo(Instant.parse(DATE2024_01_02_T00_00_00)); assertThat(workpackage.getOwnedTasks().get(1).getEndTime()).isEqualTo(Instant.parse(DATE2024_01_02_T23_59_00)); @@ -360,15 +357,15 @@ public void deleteTask() { public void createWorkpackage() { Project project = PepperFactory.eINSTANCE.createProject(); Workpackage projectWorkpackage = PepperFactory.eINSTANCE.createWorkpackage(); - projectWorkpackage.setStartDate(LocalDate.ofYearDay(2026, 1)); - projectWorkpackage.setEndDate(LocalDate.ofYearDay(2026, 3)); + projectWorkpackage.setStartDate(LocalDate.ofYearDay(2026, 2)); + projectWorkpackage.setEndDate(LocalDate.ofYearDay(2026, 5)); project.getOwnedWorkpackages().add(projectWorkpackage); var service = new PepperMMJavaService(new IFeedbackMessageService.NoOp()); service.createWorkpackage(projectWorkpackage); assertThat(project.getOwnedWorkpackages()).hasSize(2); - assertThat(project.getOwnedWorkpackages().get(1).getStartDate()).isEqualTo(LocalDate.ofYearDay(2026, 4)); - assertThat(project.getOwnedWorkpackages().get(1).getEndDate()).isEqualTo(LocalDate.ofYearDay(2026, 6)); + assertThat(project.getOwnedWorkpackages().get(1).getStartDate()).isEqualTo(LocalDate.ofYearDay(2026, 6)); + assertThat(project.getOwnedWorkpackages().get(1).getEndDate()).isEqualTo(LocalDate.ofYearDay(2026, 9)); } @Test @@ -377,11 +374,11 @@ public void editWorkpackage() { workpackage.setEndDate(LocalDate.ofYearDay(2026, 8)); var service = new PepperMMJavaService(new IFeedbackMessageService.NoOp()); - service.editWorkpackage(workpackage, NEW_NAME, NEW_DESCRIPTION, LocalDate.ofYearDay(2026, 1), LocalDate.ofYearDay(2026, 3), 10, false); + service.editWorkpackage(workpackage, NEW_NAME, NEW_DESCRIPTION, LocalDate.ofYearDay(2026, 1), LocalDate.ofYearDay(2026, 5), 10, false); assertThat(workpackage.getName()).isEqualTo(NEW_NAME); assertThat(workpackage.getDescription()).isEqualTo(NEW_DESCRIPTION); assertThat(workpackage.getStartDate()).isEqualTo(LocalDate.ofYearDay(2026, 1)); - assertThat(workpackage.getEndDate()).isEqualTo(LocalDate.ofYearDay(2026, 3)); + assertThat(workpackage.getEndDate()).isEqualTo(LocalDate.ofYearDay(2026, 5)); assertThat(workpackage.getProgress()).isEqualTo(10); }