diff --git a/pom.xml b/pom.xml
index 7099dd8cbc..8729aa8f5c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -894,6 +894,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..b7a2dbe14b
--- /dev/null
+++ b/timefold-solver/README.md
@@ -0,0 +1,7 @@
+## OptaPlanner
+
+This module contains articles about OptaPlanner.
+
+### Relevant articles
+
+- [A Guide to OptaPlanner](https://www.baeldung.com/opta-planner)
diff --git a/timefold-solver/pom.xml b/timefold-solver/pom.xml
new file mode 100644
index 0000000000..2a219a593c
--- /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..c755896201
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Employee.java
@@ -0,0 +1,32 @@
+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;
+ }
+
+ // ************************************************************************
+ // Getters and setters
+ // ************************************************************************
+
+ 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..04087134c6
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/Shift.java
@@ -0,0 +1,57 @@
+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;
+ }
+
+ // ************************************************************************
+ // Getters and setters
+ // ************************************************************************
+
+ 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..85abc8eb66
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftSchedule.java
@@ -0,0 +1,47 @@
+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 = null;
+
+ // A no-arg constructor is required for @PlanningSolution annotated classes
+ public ShiftSchedule() {
+ }
+
+ public ShiftSchedule(List employees, List shifts) {
+ this.employees = employees;
+ this.shifts = shifts;
+ }
+
+ // ************************************************************************
+ // Getters and setters
+ // ************************************************************************
+
+ 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/ShiftScheduleApp.java b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleApp.java
new file mode 100644
index 0000000000..9c25922dea
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleApp.java
@@ -0,0 +1,54 @@
+package com.baeldung.timefoldsolver;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Set;
+
+import ai.timefold.solver.core.api.solver.Solver;
+import ai.timefold.solver.core.api.solver.SolverFactory;
+import ai.timefold.solver.core.config.solver.SolverConfig;
+
+public class ShiftScheduleApp {
+
+ public static void main(String[] args) {
+ SolverFactory solverFactory = SolverFactory.create(new SolverConfig()
+ .withSolutionClass(ShiftSchedule.class)
+ .withEntityClasses(Shift.class)
+ .withConstraintProviderClass(ShiftScheduleConstraintProvider.class)
+ // The solver runs only for 5 seconds on this small dataset.
+ // It's recommended to run for at least 5 minutes ("5m") otherwise.
+ .withTerminationSpentLimit(Duration.ofSeconds(5)));
+ Solver solver = solverFactory.buildSolver();
+
+ ShiftSchedule problem = loadProblem();
+ ShiftSchedule solution = solver.solve(problem);
+ printSolution(solution);
+ }
+
+ private static 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 static void printSolution(ShiftSchedule solution) {
+ System.out.println("Shift assignments");
+ for (Shift shift : solution.getShifts()) {
+ System.out.println(" " + shift.getStart().toLocalDate()
+ + " " + shift.getStart().toLocalTime() + " - " + shift.getEnd().toLocalTime()
+ + ": " + shift.getEmployee().getName());
+ }
+ }
+
+}
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..0846429460
--- /dev/null
+++ b/timefold-solver/src/main/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProvider.java
@@ -0,0 +1,37 @@
+package com.baeldung.timefoldsolver;
+
+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;
+
+import static ai.timefold.solver.core.api.score.stream.Joiners.equal;
+
+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/ShiftScheduleConstraintProviderTest.java b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderTest.java
new file mode 100644
index 0000000000..9979f5d5e0
--- /dev/null
+++ b/timefold-solver/src/test/java/com/baeldung/timefoldsolver/ShiftScheduleConstraintProviderTest.java
@@ -0,0 +1,50 @@
+package com.baeldung.timefoldsolver;
+
+import java.time.LocalDate;
+import java.util.Set;
+
+import ai.timefold.solver.test.api.score.stream.ConstraintVerifier;
+import org.junit.jupiter.api.Test;
+
+class ShiftScheduleConstraintProviderTest {
+
+ 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 atMostOneShiftPerDay() {
+ 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 both A-B and B-A. To avoid that, use forEachUniquePair() in the constraint instead.
+ .penalizesBy(2);
+ 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 requiredSkill() {
+ 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);
+ constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill)
+ .given(
+ ann,
+ new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Waiter", ann))
+ .penalizesBy(0);
+ }
+
+}