diff --git a/pom.xml b/pom.xml
index eb780e5168..ac7d12a5a0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -895,6 +895,7 @@
tensorflow-java
testing-modules
testing-modules/mockito-simple
+ timefold-solver
vaadin
vavr-modules
vertx-modules
@@ -1139,6 +1140,7 @@
tensorflow-java
testing-modules
testing-modules/mockito-simple
+ timefold-solver
vaadin
vavr-modules
vertx-modules
diff --git a/timefold-solver/README.md b/timefold-solver/README.md
new file mode 100644
index 0000000000..1abc4d4ca0
--- /dev/null
+++ b/timefold-solver/README.md
@@ -0,0 +1,6 @@
+## Timefold Solver
+
+This module contains articles about (Timefold Solver)[https://timefold.ai].
+
+### Relevant articles
+
diff --git a/timefold-solver/pom.xml b/timefold-solver/pom.xml
new file mode 100644
index 0000000000..a16afb9e54
--- /dev/null
+++ b/timefold-solver/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+ timefold-solver
+ timefold-solver
+
+
+ com.baeldung
+ parent-modules
+ 1.0.0-SNAPSHOT
+
+
+
+
+
+ ai.timefold.solver
+ timefold-solver-bom
+ ${version.ai.timefold.solver}
+ pom
+ import
+
+
+
+
+
+ ai.timefold.solver
+ timefold-solver-core
+
+
+ ai.timefold.solver
+ timefold-solver-test
+ test
+
+
+
+
+ 17
+ 17
+ 1.4.0
+
+
+
diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java
new file mode 100644
index 0000000000..ef4752a8c6
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java
@@ -0,0 +1,28 @@
+package com.baeldung.timefoldsolver;
+
+import java.util.Set;
+
+public class Employee {
+
+ private String name;
+ private Set skills;
+
+ public Employee(String name, Set skills) {
+ this.name = name;
+ this.skills = skills;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Set getSkills() {
+ return skills;
+ }
+
+}
diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java
new file mode 100644
index 0000000000..2ef2f2a1b9
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java
@@ -0,0 +1,54 @@
+package com.baeldung.timefoldsolver;
+
+import java.time.LocalDateTime;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
+
+@PlanningEntity
+public class Shift {
+
+ private LocalDateTime start;
+ private LocalDateTime end;
+ private String requiredSkill;
+
+ @PlanningVariable
+ private Employee employee;
+
+ // A no-arg constructor is required for @PlanningEntity annotated classes
+ public Shift() {
+ }
+
+ public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill) {
+ this(start, end, requiredSkill, null);
+ }
+
+ public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill, Employee employee) {
+ this.start = start;
+ this.end = end;
+ this.requiredSkill = requiredSkill;
+ this.employee = employee;
+ }
+
+ @Override
+ public String toString() {
+ return start + " - " + end;
+ }
+
+ public LocalDateTime getStart() {
+ return start;
+ }
+
+ public LocalDateTime getEnd() {
+ return end;
+ }
+
+ public String getRequiredSkill() {
+ return requiredSkill;
+ }
+
+ public Employee getEmployee() {
+ return employee;
+ }
+
+}
diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java
new file mode 100644
index 0000000000..794c31e5b7
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java
@@ -0,0 +1,43 @@
+package com.baeldung.timefoldsolver;
+
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
+import ai.timefold.solver.core.api.domain.solution.PlanningScore;
+import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
+import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
+import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
+
+@PlanningSolution
+public class ShiftSchedule {
+
+ @ValueRangeProvider
+ private List employees;
+ @PlanningEntityCollectionProperty
+ private List shifts;
+
+ @PlanningScore
+ private HardSoftScore score;
+
+ // A no-arg constructor is required for @PlanningSolution annotated classes
+ public ShiftSchedule() {
+ }
+
+ public ShiftSchedule(List employees, List shifts) {
+ this.employees = employees;
+ this.shifts = shifts;
+ }
+
+ public List getEmployees() {
+ return employees;
+ }
+
+ public List getShifts() {
+ return shifts;
+ }
+
+ public HardSoftScore getScore() {
+ return score;
+ }
+
+}
diff --git a/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java
new file mode 100644
index 0000000000..bd308dacbf
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java
@@ -0,0 +1,35 @@
+package com.baeldung.timefoldsolver;
+
+import static ai.timefold.solver.core.api.score.stream.Joiners.equal;
+
+import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
+import ai.timefold.solver.core.api.score.stream.Constraint;
+import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
+import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
+
+public class ShiftScheduleConstraintProvider implements ConstraintProvider {
+
+ @Override
+ public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
+ return new Constraint[] { atMostOneShiftPerDay(constraintFactory), requiredSkill(constraintFactory) };
+ }
+
+ public Constraint atMostOneShiftPerDay(ConstraintFactory constraintFactory) {
+ return constraintFactory.forEach(Shift.class)
+ .join(Shift.class, equal(shift -> shift.getStart()
+ .toLocalDate()), equal(Shift::getEmployee))
+ .filter((shift1, shift2) -> shift1 != shift2)
+ .penalize(HardSoftScore.ONE_HARD)
+ .asConstraint("At most one shift per day");
+ }
+
+ public Constraint requiredSkill(ConstraintFactory constraintFactory) {
+ return constraintFactory.forEach(Shift.class)
+ .filter(shift -> !shift.getEmployee()
+ .getSkills()
+ .contains(shift.getRequiredSkill()))
+ .penalize(HardSoftScore.ONE_HARD)
+ .asConstraint("Required skill");
+ }
+
+}
diff --git a/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java
new file mode 100644
index 0000000000..c8ec5b53c4
--- /dev/null
+++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderUnitTest.java
@@ -0,0 +1,52 @@
+package com.baeldung.timefoldsolver;
+
+import java.time.LocalDate;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import ai.timefold.solver.test.api.score.stream.ConstraintVerifier;
+
+class ShiftScheduleConstraintProviderUnitTest {
+
+ private static final LocalDate MONDAY = LocalDate.of(2030, 4, 1);
+ private static final LocalDate TUESDAY = LocalDate.of(2030, 4, 2);
+
+ ConstraintVerifier constraintVerifier = ConstraintVerifier.build(new ShiftScheduleConstraintProvider(),
+ ShiftSchedule.class, Shift.class);
+
+ @Test
+ void givenTwoShiftsOnOneDay_whenApplyingAtMostOneShiftPerDayConstraint_thenPenalize() {
+ Employee ann = new Employee("Ann", null);
+ constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay)
+ .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann), new Shift(MONDAY.atTime(14, 0), MONDAY.atTime(22, 0), null, ann))
+ // Penalizes by 2 because both {shiftA, shiftB} and {shiftB, shiftA} match.
+ // To avoid that, use forEachUniquePair(Shift) instead of forEach(Shift).join(Shift) in ShiftScheduleConstraintProvider.atMostOneShiftPerDay().
+ .penalizesBy(2);
+ }
+
+ @Test
+ void givenTwoShiftsOnDifferentDays_whenApplyingAtMostOneShiftPerDayConstraint_thenDoNotPenalize() {
+ Employee ann = new Employee("Ann", null);
+ constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay)
+ .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann), new Shift(TUESDAY.atTime(14, 0), TUESDAY.atTime(22, 0), null, ann))
+ .penalizesBy(0);
+ }
+
+ @Test
+ void givenEmployeeLacksRequiredSkill_whenApplyingRequiredSkillConstraint_thenPenalize() {
+ Employee ann = new Employee("Ann", Set.of("Waiter"));
+ constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill)
+ .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Cook", ann))
+ .penalizesBy(1);
+ }
+
+ @Test
+ void givenEmployeeHasRequiredSkill_whenApplyingRequiredSkillConstraint_thenDoNotPenalize() {
+ Employee ann = new Employee("Ann", Set.of("Waiter"));
+ constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill)
+ .given(ann, new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Waiter", ann))
+ .penalizesBy(0);
+ }
+
+}
diff --git a/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java
new file mode 100644
index 0000000000..873ce1a853
--- /dev/null
+++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleSolverUnitTest.java
@@ -0,0 +1,65 @@
+package com.baeldung.timefoldsolver;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
+import ai.timefold.solver.core.api.solver.Solver;
+import ai.timefold.solver.core.api.solver.SolverFactory;
+import ai.timefold.solver.core.config.solver.SolverConfig;
+import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
+
+public class ShiftScheduleSolverUnitTest {
+
+ private static final Logger logger = LoggerFactory.getLogger(ShiftScheduleSolverUnitTest.class);
+
+ @Test
+ public void given3Employees5Shifts_whenSolve_thenScoreIsOptimalAndAllShiftsAssigned() {
+ SolverFactory solverFactory = SolverFactory.create(new SolverConfig().withSolutionClass(ShiftSchedule.class)
+ .withEntityClasses(Shift.class)
+ .withConstraintProviderClass(ShiftScheduleConstraintProvider.class)
+ // For this dataset, we know the optimal score in advance (which is normally not the case).
+ // So we can use .withBestScoreLimit() instead of .withTerminationSpentLimit().
+ .withTerminationConfig(new TerminationConfig().withBestScoreLimit("0hard/0soft")));
+ Solver solver = solverFactory.buildSolver();
+
+ ShiftSchedule problem = loadProblem();
+ ShiftSchedule solution = solver.solve(problem);
+ assertThat(solution.getScore()).isEqualTo(HardSoftScore.ZERO);
+ assertThat(solution.getShifts().size()).isNotZero();
+ for (Shift shift : solution.getShifts()) {
+ assertThat(shift.getEmployee()).isNotNull();
+ }
+ printSolution(solution);
+ }
+
+ private ShiftSchedule loadProblem() {
+ LocalDate monday = LocalDate.of(2030, 4, 1);
+ LocalDate tuesday = LocalDate.of(2030, 4, 2);
+ return new ShiftSchedule(
+ List.of(new Employee("Ann", Set.of("Bartender")), new Employee("Beth", Set.of("Waiter", "Bartender")), new Employee("Carl", Set.of("Waiter"))),
+ List.of(new Shift(monday.atTime(6, 0), monday.atTime(14, 0), "Waiter"), new Shift(monday.atTime(9, 0), monday.atTime(17, 0), "Bartender"),
+ new Shift(monday.atTime(14, 0), monday.atTime(22, 0), "Bartender"), new Shift(tuesday.atTime(6, 0), tuesday.atTime(14, 0), "Waiter"),
+ new Shift(tuesday.atTime(14, 0), tuesday.atTime(22, 0), "Bartender")));
+ }
+
+ private void printSolution(ShiftSchedule solution) {
+ logger.info("Shift assignments");
+ for (Shift shift : solution.getShifts()) {
+ logger.info(" " + shift.getStart()
+ .toLocalDate() + " " + shift.getStart()
+ .toLocalTime() + " - " + shift.getEnd()
+ .toLocalTime() + ": " + shift.getEmployee()
+ .getName());
+ }
+ }
+
+}