Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- https://github.com/ObeoNetwork/pepper/issues/89[#89] Make duration (AbstractTask and Workpackage) computed from non working days. For now, it corresponds to week-end and french public holidays 2026.
- https://github.com/ObeoNetwork/pepper/issues/94[#94] Rename the dependency link duration to dependency link delay and display the delay in days
- https://github.com/ObeoNetwork/pepper/issues/60[#60] Take Resource (including Unavailability period) into account for effort commutation
- https://github.com/ObeoNetwork/pepper/issues/98[#98] Change the way the impacted tasks are calculated. Impacted tasks are always moved (effort is kept)

=== Bug fixes

Expand Down
2 changes: 1 addition & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Follow the links below for more informations :
* https://blog.obeosoft.com/pepper-a-case-of-open-innovation-with-sirius-web[Blog post presenting Pepper]


= Starting with Pepper
== Starting with Pepper

This repository is a mono repo containing both frontend and backend components.

Expand Down
83 changes: 61 additions & 22 deletions backend/pepper-domain-services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,88 @@
Pepper only supports a single time zone.
The server and the browser must use the same time zone.

## Considering task constraints
## Upadate philosophy

A task is constrained by two `TimeConstraint` values among:
There are two approaches to solve the update.

- `START`
- `END`
- `EFFORT`
If a constrained boundary (`START` or `END`) is also constrained by a dependency, the dependency constraint is considered stronger than the task constraint.
### Approach 1: Full update

In that case, the effort is no longer considered constraining.
Except if there is a cycle that is detected, the update should always succeed.

A boundary constrained by a dependency can not be changed directly.
The philophy is "I know what I am doing, so I let the algorithm change task boundaries I specifically set in the model"

For example: A START-EFFORT task has its end date constrained by a dependency. If the start date is moved, the end date remains unchanged and the effort is updated accordingly.
### Approach 2: Validation

"I don't want the update to change task boundaries I specifically set in the model"

## Task bounds computation
If this occurs, then conflicting changes are not applied until the user cancel or accept the changes.

An AbstractTask has its bounds defined as Instant.
When modifying the task, either from Gantt, details view or by the algorithm, the AbstractTask bounds are rounded to the closest half-day.
## Task definition

Gantt task are supported for AbstractTask and Workpackage.
A task has start and end boundaries.
A task has an effort which is the amount of time worked on this task.
Non-working days (in week and configured fixed non-working days) do not consume any effort.

The workpackage has its bounds defined as LocalDate
An AbstractTask has its boundaries defined as Instant.
When modifying an AbstractTask, either from Gantt, details view or by the algorithm, its boundaries are rounded to the closest half-day.

The workpackage has its boundaries defined as LocalDate.
Both workpackage startDate and endDate are included.

## Considering task constraints

### Internal constraints

A task is constrained by two `TimeConstraint` values among:

- `START`
- `END`
- `EFFORT`

The value that is not constraining is generally computed from the two others.

### Task dependencies

A dependency is a link between the boundary of two tasks. One is a constraining task and the others is the constrained tasks.

### Task with assigned persons

If a task has assigned persons, then the calculation of time constraints will consider the unavailability periods of Person.
An unavailability period does not consume any effort.
On the contrary if multiple persons are available on a task, the effort is more consumed.
On the contrary, if multiple persons are available on a task, the effort is more consumed.
If no person is assigned, one working day consume an effort of one day.

## Gantt interactions
A person can not be "used" for multiple tasks at the same time. A person can be assigned to two tasks though.
It only means that person will produce an effort only for one task at a time. The computation will shift the task on the time line to avoid that a person is used for multiple tasks.

### Moving a task
## Constraint priority

- If the task is constrained by dependencies nothing is done.
- Otherwise, the constraining boundary or boundaries are updated according to the task calculation option:
-- `START-END`: both boundaries are updated. The effort may change if the number of included non-working days changes.
-- `START-EFFORT` and `END-EFFORT`: the constraining boundary is updated. The constrained boundary may move by more than the drag delta if the number of included non-working days changes, because the effort is preserved.
The order of priority is:

- the assigned persons
- the dependencies
If two tasks have an END-END dependencies (both finishing at the same time) and are assigned to the same person, the second task is shifted after the first
- the internal constraints
If a constrained boundary (`START` or `END`) is also constrained by a dependency, the dependency constraint is considered stronger than the task constraint.

A boundary constrained by a dependency can not be changed by a user.

## Gantt interactions

### Changing one task boundary

- If the boundary is constraining, this boundary is updated and the other constraint is preserved.
- If the opposite boundary is constrained by a dependency, the moved boundary is updated and the effort is updated as well.
- If the changed boundary is constraining, this moved boundary is updated and the other constraint is preserved.
- If the changed boundary is not constraining or the opposite boundary is constrained by a dependency, then the moved boundary is updated along with the effort.
- Otherwise, the change is interpreted as a user intent to update the effort by the move delta.
-- [FUTURE ENHANCEMENT] A global option could forbid changing a non-constraining boundary directly. In that mode, moving such a task boundary would not be allowed.

For example: A START-EFFORT task has its end date constrained by a dependency. If the start date is moved, the end date remains unchanged and the effort is updated accordingly.

In any case, the impacted tasks are updating keeping the effort unchanged
-- [FUTURE ENHANCEMENT] Clicking on an alternative key while updating the task, the impacted task would be updated according to their constraint.

### Moving a task

- If the task is constrained by dependencies nothing is done.
- Otherwise, the moved task and all the impacted tasks are updating keeping the effort unchanged.
16 changes: 14 additions & 2 deletions backend/pepper-domain-services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
Contributors:
Obeo - initial API and implementation
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
Expand All @@ -34,6 +34,18 @@
<groupId>org.eclipse.core</groupId>
<artifactId>org.eclipse.core.runtime</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.emf</groupId>
<artifactId>org.eclipse.emf.ecore</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.sirius</groupId>
<artifactId>sirius-components-core</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.sirius</groupId>
<artifactId>sirius-components-interpreter</artifactId>
</dependency>
<dependency>
<groupId>pepper</groupId>
<artifactId>pepper-mm</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,9 @@ public Duration getEffort(Instant startTime, Instant endTime, List<Person> assig
Duration effort = Duration.ZERO;
Instant currentTime = startTime;
while (currentTime.isBefore(endTime)) {
LocalDate currentDate = currentTime.atZone(ZoneOffset.UTC).toLocalDate();
Instant nextDayStart = currentDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
Instant intervalEnd = endTime.isBefore(nextDayStart) ? endTime : nextDayStart;
int nbWorkingPersons = this.getNbWorkingPersons(currentDate, assignedPersons);
Instant nextHalfDayStart = currentTime.truncatedTo(ChronoUnit.HALF_DAYS).plus(1, ChronoUnit.HALF_DAYS);
Instant intervalEnd = endTime.isBefore(nextHalfDayStart) ? endTime : nextHalfDayStart;
int nbWorkingPersons = this.getNbWorkingPersons(currentTime, assignedPersons);
if (nbWorkingPersons > 0) {
effort = effort.plus(Duration.ofHours(Duration.between(currentTime, intervalEnd).toHours() * nbWorkingPersons));
}
Expand Down Expand Up @@ -160,6 +159,7 @@ public Duration getDuration(LocalDate startDate, LocalDate endDate, List<Person>
/**
* Returns the end time reached after the specified number of working hours from {@code startTime}. Non-working days in week and configured fixed non-working days do not consume any effort.
* When persons is provided, days that correspond of unavailability period of all the persons are also excluded.
* With zero effort, returns the supplied instant if the minute before it is on a working day; otherwise advances to the next valid end time in half-day steps.
*
* @param startTime
* the non-null start of the interval
Expand All @@ -174,20 +174,19 @@ public Instant getNextEndTime(Instant startTime, int effortInHours, List<Person>

Duration remainingDuration = Duration.ofHours(effortInHours);
Instant currentEndTime = startTime;
while (!remainingDuration.isZero()) {
while (!remainingDuration.isZero()
|| !this.isWorkingTime(currentEndTime.minus(1, ChronoUnit.MINUTES), persons)) {
LocalDate currentDate = currentEndTime.atZone(ZoneOffset.UTC).toLocalDate();
Instant nextHalfDayStart = currentEndTime.truncatedTo(ChronoUnit.HALF_DAYS).plus(1, ChronoUnit.HALF_DAYS);
int nbWorkingPersons = this.getNbWorkingPersons(currentDate, persons);
if (nbWorkingPersons == 0) {
currentEndTime = nextHalfDayStart;
} else {
int nbWorkingPersons = this.getNbWorkingPersons(currentEndTime, persons);
if (nbWorkingPersons > 0) {
Duration availableDuration = Duration.ofHours(Duration.between(currentEndTime, nextHalfDayStart).toHours() * nbWorkingPersons);
Duration consumedDuration = remainingDuration.compareTo(availableDuration) < 0
? remainingDuration
: availableDuration;
remainingDuration = remainingDuration.minus(consumedDuration);
currentEndTime = nextHalfDayStart;
}
currentEndTime = nextHalfDayStart;
}
return currentEndTime;
}
Expand All @@ -203,18 +202,42 @@ public Instant getNextEndTime(Instant startTime, int effortInHours, List<Person>
* @return the supplied instant or the next valid end time
*/
public Instant getNextEndTime(Instant instant, List<Person> persons) {
if (instant == null) {
return this.getNextEndTime(instant, 0, persons);
}

/**
* Returns the next valid start time reached after the specified number of working hours from {@code startTime}. Non-working days in week and configured fixed non-working days do not consume any
* effort. When persons is provided, days that correspond of unavailability period of all the persons are also excluded.
* With zero effort, returns the supplied instant if it is on a working day; otherwise advances to the next valid start time in half-day steps.
*
* @param startTime
* the non-null start of the interval
* @param effortInHours
* the number of working hours to add
* @return the resulting start time, or {@code null} when {@code startTime} is null
*/
public Instant getNextStartTime(Instant startTime, int effortInHours, List<Person> persons) {
if (startTime == null) {
return null;
}
Instant nextEndTime = instant;
if (!this.isWorkingDay(nextEndTime.minus(1, ChronoUnit.MINUTES).atZone(ZoneOffset.UTC).toLocalDate(), persons)) {
nextEndTime = instant.plus(6, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HALF_DAYS);
while (!this.isWorkingDay(nextEndTime.atZone(ZoneOffset.UTC).toLocalDate(), persons)) {
nextEndTime = nextEndTime.plus(1, ChronoUnit.HALF_DAYS);

Duration remainingDuration = Duration.ofHours(effortInHours);
Instant currentStartTime = startTime;
while (!remainingDuration.isZero()
|| !this.isWorkingTime(currentStartTime, persons)) {
LocalDate currentDate = currentStartTime.atZone(ZoneOffset.UTC).toLocalDate();
Instant nextHalfDayStart = currentStartTime.truncatedTo(ChronoUnit.HALF_DAYS).plus(1, ChronoUnit.HALF_DAYS);
int nbWorkingPersons = this.getNbWorkingPersons(currentStartTime, persons);
if (nbWorkingPersons > 0) {
Duration availableDuration = Duration.ofHours(Duration.between(currentStartTime, nextHalfDayStart).toHours() * nbWorkingPersons);
Duration consumedDuration = remainingDuration.compareTo(availableDuration) < 0
? remainingDuration
: availableDuration;
remainingDuration = remainingDuration.minus(consumedDuration);
}
nextEndTime = nextEndTime.plus(1, ChronoUnit.HALF_DAYS);
currentStartTime = nextHalfDayStart;
}
return nextEndTime;
return currentStartTime;
}

/**
Expand All @@ -226,23 +249,14 @@ public Instant getNextEndTime(Instant instant, List<Person> persons) {
* @return the supplied instant or the previous valid start time
*/
public Instant getPreviousStartTime(Instant instant, List<Person> persons) {
if (instant == null) {
return null;
}
Instant previousStartTime = instant;
if (!this.isWorkingDay(instant.atZone(ZoneOffset.UTC).toLocalDate(), persons)) {
previousStartTime = instant.truncatedTo(ChronoUnit.HALF_DAYS);
while (!this.isWorkingDay(previousStartTime.atZone(ZoneOffset.UTC).toLocalDate(), persons)) {
previousStartTime = previousStartTime.minus(1, ChronoUnit.HALF_DAYS);
}
}
return previousStartTime;
return this.getPreviousStartTime(instant, 0, persons);
}

/**
* Returns the start time reached after moving backward by the specified number of working hours from {@code endTime}. Non-working days in week and configured fixed non-working days do not consume
* any effort.
* When persons is provided, days that correspond of unavailability period of all the persons are also excluded.
* With zero effort, returns the supplied instant if it is on a working day; otherwise moves backward to the previous valid start time in half-day steps.
*
* @param endTime
* the non-null end of the interval
Expand All @@ -258,20 +272,19 @@ public Instant getPreviousStartTime(Instant endTime, int effortInHours, List<Per
Duration remainingDuration = Duration.ofHours(effortInHours);
Instant currentStartTime = endTime;

while (!remainingDuration.isZero()) {
while (!remainingDuration.isZero()
|| !this.isWorkingTime(currentStartTime, persons)) {
Instant previousHalfDayStart = currentStartTime.minusNanos(1).truncatedTo(ChronoUnit.HALF_DAYS);
LocalDate currentDate = previousHalfDayStart.atZone(ZoneOffset.UTC).toLocalDate();
int nbWorkingPersons = this.getNbWorkingPersons(currentDate, persons);
if (nbWorkingPersons == 0) {
currentStartTime = previousHalfDayStart;
} else {
int nbWorkingPersons = this.getNbWorkingPersons(previousHalfDayStart, persons);
if (nbWorkingPersons > 0) {
Duration availableDuration = Duration.ofHours(Duration.between(previousHalfDayStart, currentStartTime).toHours() * nbWorkingPersons);
Duration consumedDuration = remainingDuration.compareTo(availableDuration) < 0
? remainingDuration
: availableDuration;
remainingDuration = remainingDuration.minus(consumedDuration);
currentStartTime = previousHalfDayStart;
}
currentStartTime = previousHalfDayStart;
}
return currentStartTime;
}
Expand Down Expand Up @@ -374,23 +387,49 @@ private boolean isWorkingDay(LocalDate date, List<Person> persons) {
return this.getNbWorkingPersons(date, persons) > 0;
}

private boolean isWorkingTime(Instant instant, List<Person> persons) {
return this.getNbWorkingPersons(instant, persons) > 0;
}

private int getNbWorkingPersons(Instant instant, List<Person> assignedPersons) {
LocalDate date = instant.atZone(ZoneOffset.UTC).toLocalDate();
boolean isNonWorkingDay = NON_WORKING_DAYS_IN_WEEK.contains(date.getDayOfWeek()) || FRENCH_NON_WORKING_DAYS_2026.contains(date);
if (isNonWorkingDay) {
return 0;
}
if (assignedPersons == null || assignedPersons.isEmpty()) {
return 1;
}
PersonCapacityAllocation allocation = PersonCapacityAllocation.current();
return Math.toIntExact(assignedPersons.stream()
.filter(person -> this.isAvailable(person, date))
.filter(person -> allocation == null || allocation.isAvailable(person, instant))
.count());
}

private int getNbWorkingPersons(LocalDate date, List<Person> assignedPersons) {
long nbWorkingDays = 0;
boolean isNonWorkingDay = NON_WORKING_DAYS_IN_WEEK.contains(date.getDayOfWeek()) || FRENCH_NON_WORKING_DAYS_2026.contains(date);
if (!isNonWorkingDay) {
if (assignedPersons == null || assignedPersons.isEmpty()) {
nbWorkingDays = 1;
} else {
PersonCapacityAllocation allocation = PersonCapacityAllocation.current();
nbWorkingDays = assignedPersons.stream()
.filter(person -> person.getUnavailabilityPeriods().stream()
.noneMatch(unavailabilityPeriod -> !date.isBefore(unavailabilityPeriod.getStartDate()) && !date.isAfter(unavailabilityPeriod.getEndDate())))
.filter(person -> this.isAvailable(person, date))
.filter(person -> allocation == null || allocation.isAvailableForDate(person, date))
.count();
}
}

return Math.toIntExact(nbWorkingDays);
}

private boolean isAvailable(Person person, LocalDate date) {
return person.getUnavailabilityPeriods().stream()
.noneMatch(unavailabilityPeriod -> !date.isBefore(unavailabilityPeriod.getStartDate()) && !date.isAfter(unavailabilityPeriod.getEndDate()));
}

public Duration roundToNearestHalfDay(Duration duration) {
return duration.isNegative()
? duration.minusHours(6).truncatedTo(ChronoUnit.HALF_DAYS)
Expand Down
Loading
Loading