diff --git a/README.md b/README.md index c83ec3e..5d9b56c 100644 --- a/README.md +++ b/README.md @@ -154,23 +154,35 @@ dancers; deleting a school also deletes its dancers, groups and shows. Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`), with unique date/type combinations. The displayed date range comes from these -dayparts. Each show stores `appearances` for every group type. Omitting counts -initializes defaults on creation or the initial daypart configuration; later -edits preserve saved counts and reject counts outside 1..N. With no dayparts, -shows remain unscheduled and saved counts are retained. +dayparts. Each show stores `preferredDayParts` (x), defaulting to two or one for +one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly x +dayparts; preschool groups once; showcases every daypart. See +[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints, +coach exceptions, ordering and optimization rules. -`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to -assign every current school group to its required number of distinct dayparts, -minimizing the busiest/quietest appearance gap. It replaces assignments only on -a valid, current result. The response distinguishes optimal balance from a valid -result whose optimality is unproven. Concurrent runs return `409`; changed inputs -also reject the result with `409`. Failed runs preserve existing assignments. -Scheduling runs synchronously with a 30-minute solver limit; development and -production proxies allow 31 minutes. The UI warns before edits that may invalidate -existing schedules and offers explicit scheduling again afterward. +`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered +draft using OR-Tools with a total thirty-minute time limit. A feasible result is +accepted without claiming optimality. Concurrent runs and stale results return +`409`; failures retain existing assignments. Development and production proxies +allow 31 minutes. -School JSON exports use format version **2**, including dated dayparts, assignments, -and all appearance counts. Version 1 files are intentionally unsupported. +`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an +ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved; +unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize` +checks every hard rule before marking a schedule final. Show responses include +`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final +status. The UI supports ordering and moving performances, and displays violations. + +`GET .../coaches` lists coach registrations. `POST .../coaches` with +`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses +the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer` +removes the identity link. The dancers screen supports maintaining these links. + +School JSON exports use format version **3**, including x, schedule status and +coach-to-dancer links. Version 2 remains importable; its schedules migrate to +drafts with default x. Version 1 is unsupported. Legacy `appearances` fields are +retained for file compatibility; S18 scheduling uses x and fixed type exceptions. +The REST `appearances` input is deprecated; use `preferredDayParts`. Run the HTTP integration tests with: diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Coach.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Coach.java index 6abe811..2939fa3 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Coach.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Coach.java @@ -4,6 +4,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.Setter; import lombok.ToString; @Getter @@ -13,4 +14,7 @@ import lombok.ToString; public class Coach extends AbstractEntity { @ToString.Include private final String name; + + @Setter + private Dancer dancer; } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java index d931b0f..4ac6f3c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java @@ -18,6 +18,12 @@ public class Show extends AbstractEntity { @Setter private String name; + @Setter + private Integer preferredDayParts; + + @Setter + private boolean finalized; + @Setter private Map appearances; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java index 643fdf9..16acbdf 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java @@ -1,3 +1,21 @@ package nl.agilecc.agiledancers.model.scheduling; -public record GroupRequirement(long groupId, int appearances) {} +import java.util.Set; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; + +public record GroupRequirement( + long groupId, + int appearances, + boolean showcase, + Set dancerIds, + Set styles, + double averageAge) { + public GroupRequirement { + dancerIds = Set.copyOf(dancerIds); + styles = Set.copyOf(styles); + } + + public GroupRequirement(final long groupId, final int appearances) { + this(groupId, appearances, false, Set.of(), Set.of(), 0); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/ScheduleRules.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/ScheduleRules.java new file mode 100644 index 0000000..5e2b95d --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/ScheduleRules.java @@ -0,0 +1,116 @@ +package nl.agilecc.agiledancers.model.scheduling; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Shared hard-rule validation for solver results and manually edited schedules. */ +public final class ScheduleRules { + private ScheduleRules() {} + + public static List violations(final SchedulingInput input, final List> parts) { + final List errors = new ArrayList<>(); + if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) { + errors.add("Configure dayparts and x between 1 and the number of dayparts."); + } + if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) { + errors.add("Assignments must contain one list for every daypart."); + return List.copyOf(errors); + } + final Map groups = new HashMap<>(); + input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g)); + for (int p = 0; p < parts.size(); p++) { + validatePart(parts.get(p), groups, p, errors); + } + appearances(input, parts, errors); + attendance(input, parts, errors); + final int min = parts.stream().mapToInt(List::size).min().orElse(0); + final int max = parts.stream().mapToInt(List::size).max().orElse(0); + if (max - min > 2) { + errors.add("Daypart performance counts differ by more than two."); + } + return List.copyOf(errors); + } + + private static void validatePart( + final List ids, final Map groups, final int part, final List errors) { + if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) { + errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups."); + return; + } + final List ordered = ids.stream().map(groups::get).toList(); + showcases(ordered, part, errors); + gaps(ordered, part, errors); + } + + private static void showcases(final List groups, final int part, final List errors) { + final long count = groups.stream().filter(GroupRequirement::showcase).count(); + if (count == 0) { + return; + } + final boolean first = groups.getFirst().showcase(); + final boolean last = groups.getLast().showcase(); + if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) { + errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing."); + } + } + + private static void gaps(final List groups, final int part, final List errors) { + final Map last = new HashMap<>(); + for (int i = 0; i < groups.size(); i++) { + for (final long dancer : groups.get(i).dancerIds()) { + final Integer previous = last.put(dancer, i); + if (previous != null && i - previous < 3) { + errors.add("Daypart " + (part + 1) + ": dancer " + dancer + + " needs two intervening performances before group " + + groups.get(i).groupId() + "."); + } + } + } + } + + private static void appearances( + final SchedulingInput input, final List> parts, final List errors) { + for (final GroupRequirement group : input.groups()) { + if (parts.stream() + .filter((final List ids) -> ids.contains(group.groupId())) + .count() + != group.appearances()) { + errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts."); + } + } + } + + private static void attendance( + final SchedulingInput input, final List> parts, final List errors) { + final Set exempt = new HashSet<>(input.coachIds()); + input.groups().stream() + .filter(GroupRequirement::showcase) + .forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds())); + final Map> attendance = new HashMap<>(); + for (final GroupRequirement group : input.groups()) { + recordAttendance(group, parts, attendance); + } + attendance.forEach((final Long dancer, final Set dayparts) -> { + if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) { + errors.add("Dancer " + dancer + " exceeds x=" + input.preferredDayParts() + " dayparts."); + } + }); + } + + private static void recordAttendance( + final GroupRequirement group, final List> parts, final Map> attendance) { + for (int p = 0; p < parts.size(); p++) { + if (parts.get(p).contains(group.groupId())) { + for (final long dancer : group.dancerIds()) { + attendance + .computeIfAbsent(dancer, (final Long id) -> new HashSet<>()) + .add(p); + } + } + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java index 9542e08..7c30a35 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java @@ -1,9 +1,22 @@ package nl.agilecc.agiledancers.model.scheduling; import java.util.List; +import java.util.Set; -public record SchedulingInput(long showId, long revision, int dayParts, List groups) { +public record SchedulingInput( + long showId, + long revision, + int dayParts, + List groups, + int preferredDayParts, + Set coachIds) { public SchedulingInput { groups = List.copyOf(groups); + coachIds = Set.copyOf(coachIds); + } + + public SchedulingInput( + final long showId, final long revision, final int dayParts, final List groups) { + this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of()); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/VarietyScore.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/VarietyScore.java new file mode 100644 index 0000000..9df169b --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/VarietyScore.java @@ -0,0 +1,20 @@ +package nl.agilecc.agiledancers.model.scheduling; + +import java.util.HashSet; +import java.util.Set; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; + +public final class VarietyScore { + private VarietyScore() {} + + /** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */ + public static long penalty(final GroupRequirement a, final GroupRequirement b) { + final Set union = new HashSet<>(a.styles()); + union.addAll(b.styles()); + final Set shared = new HashSet<>(a.styles()); + shared.retainAll(b.styles()); + final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size(); + final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5); + return Math.round(1_000_000 * style) + Math.round(1_000_000 * age); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java new file mode 100644 index 0000000..82fddae --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/CoachResource.java @@ -0,0 +1,48 @@ +package nl.agilecc.agiledancers.rest; + +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import nl.agilecc.agiledancers.rest.apimodel.CoachRequest; +import nl.agilecc.agiledancers.rest.apimodel.CoachView; +import nl.agilecc.agiledancers.service.CoachLinks; + +@Path("/api/dance-schools/{schoolId}/coaches") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class CoachResource { + private @Inject CoachLinks links; + private @PathParam("schoolId") long schoolId; + + @GET + public List list() { + return links.list(schoolId).stream().map(CoachView::of).toList(); + } + + @POST + public CoachView create(final @NotNull @Valid CoachRequest request) { + return CoachView.of(links.link(schoolId, null, request.dancerId())); + } + + @PUT + @Path("/{id}") + public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) { + return CoachView.of(links.link(schoolId, id, request.dancerId())); + } + + @DELETE + @Path("/{id}/dancer") + public void unlink(final @PathParam("id") long id) { + links.unlink(schoolId, id); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java index ea4d997..9fb74f8 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java @@ -20,11 +20,13 @@ import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest; +import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest; import nl.agilecc.agiledancers.rest.apimodel.SchedulingView; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowView; import nl.agilecc.agiledancers.service.SchedulingService; import nl.agilecc.agiledancers.service.SchoolManagementService; +import nl.agilecc.agiledancers.service.ShowSchedules; @Path("/api/dance-schools/{schoolId}/shows") @Produces(MediaType.APPLICATION_JSON) @@ -34,33 +36,60 @@ public class ShowResource { private @Inject SchedulingService scheduling; + private @Inject ShowSchedules schedules; + private @PathParam("schoolId") long schoolId; @GET public List list() { - return service.shows(schoolId).stream().map(ShowView::of).toList(); + return service.shows(schoolId).stream().map(this::view).toList(); } @GET @Path("/{id}") public ShowView get(final @PathParam("id") long id) { - return ShowView.of(service.show(schoolId, id)); + return view(service.show(schoolId, id)); } @POST public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) { - final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances()); + final Show entity = schedules.configure(schoolId, null, settings(request)); return Response.created(uriInfo.getAbsolutePathBuilder() .path(entity.getId().toString()) .build()) - .entity(ShowView.of(entity)) + .entity(view(entity)) .build(); } @PUT @Path("/{id}") public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) { - return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances())); + return view(schedules.configure(schoolId, id, settings(request))); + } + + private ShowView view(final Show show) { + return ShowView.of(show, schedules.violations(schoolId, show.getId())); + } + + private static Show settings(final ShowRequest request) { + return Show.builder() + .name(request.name()) + .preferredDayParts(request.preferredDayParts()) + .dayParts(parts(request)) + .build(); + } + + @PUT + @Path("/{id}/assignments") + public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) { + return view(schedules.saveDraft(schoolId, id, request.assignments())); + } + + @POST + @Path("/{id}/finalize") + @Consumes(MediaType.WILDCARD) + public ShowView finalizeSchedule(final @PathParam("id") long id) { + return view(schedules.finalizeSchedule(schoolId, id)); } private static List parts(final ShowRequest request) { @@ -79,7 +108,7 @@ public class ShowResource { public SchedulingView schedule(final @PathParam("id") long id) { final SchedulingResult result = scheduling.schedule(schoolId, id); return new SchedulingView( - result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven"); + result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven"); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachRequest.java new file mode 100644 index 0000000..59c8aca --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachRequest.java @@ -0,0 +1,5 @@ +package nl.agilecc.agiledancers.rest.apimodel; + +import jakarta.validation.constraints.NotNull; + +public record CoachRequest(@NotNull Long dancerId) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java new file mode 100644 index 0000000..8eb94af --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/CoachView.java @@ -0,0 +1,12 @@ +package nl.agilecc.agiledancers.rest.apimodel; + +import nl.agilecc.agiledancers.model.Coach; + +public record CoachView(long id, String name, Long dancerId) { + public static CoachView of(final Coach coach) { + return new CoachView( + coach.getId(), + coach.getName(), + coach.getDancer() == null ? null : coach.getDancer().getId()); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ScheduleRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ScheduleRequest.java new file mode 100644 index 0000000..731c6c0 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ScheduleRequest.java @@ -0,0 +1,15 @@ +package nl.agilecc.agiledancers.rest.apimodel; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record ScheduleRequest(@NotNull List> assignments) { + public ScheduleRequest { + assignments = assignments == null + ? null + : assignments.stream() + .map((final List p) -> + p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p))) + .toList(); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java index 26ffbca..cb9599b 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java @@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType; public record ShowRequest( @NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts, - Map appearances) { + Map appearances, + Integer preferredDayParts) { public ShowRequest { dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java index bc2feca..3fc54a3 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java @@ -10,13 +10,25 @@ import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record ShowView(long id, String name, List dayParts, Map appearances) { +public record ShowView( + long id, + String name, + List dayParts, + Map appearances, + int preferredDayParts, + boolean finalized, + List violations) { public ShowView { + violations = List.copyOf(violations); dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); } public static ShowView of(final Show show) { + return of(show, List.of()); + } + + public static ShowView of(final Show show, final List violations) { return new ShowView( show.getId(), show.getName(), @@ -28,6 +40,9 @@ public record ShowView(long id, String name, List dayParts, Map list(final long schoolId) { + return List.copyOf(schools.school(schoolId).getCoaches()); + } + + public Coach link(final long schoolId, final Long coachId, final long dancerId) { + synchronized (schools) { + final DanceSchool school = schools.school(schoolId); + final Dancer dancer = schools.dancer(schoolId, dancerId); + final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId); + coach.setDancer(dancer); + school.getShows().forEach(schools::touch); + return coach; + } + } + + private static Coach create(final DanceSchool school, final Dancer dancer) { + final Coach coach = Coach.builder().name(dancer.getName()).build(); + coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1); + school.addCoach(coach); + return coach; + } + + private static Coach required(final DanceSchool school, final long id) { + return school.getCoaches().stream() + .filter((final Coach c) -> c.getId() == id) + .findFirst() + .orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach")); + } + + public void unlink(final long schoolId, final long id) { + synchronized (schools) { + final DanceSchool school = schools.school(schoolId); + required(school, id).setDancer(null); + school.getShows().forEach(schools::touch); + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingInputs.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingInputs.java new file mode 100644 index 0000000..6ce3b14 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingInputs.java @@ -0,0 +1,65 @@ +package nl.agilecc.agiledancers.service; + +import java.time.LocalDate; +import java.time.Period; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import nl.agilecc.agiledancers.model.Coach; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DanceSchool; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; + +public final class SchedulingInputs { + private SchedulingInputs() {} + + public static int preferred(final Show show) { + if (show.getPreferredDayParts() != null) { + return show.getPreferredDayParts(); + } + return (show.getDayParts().size() == 1 ? 1 : 2); + } + + public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) { + final int x = preferred(show); + final LocalDate date = show.getDayParts().stream() + .map(DayPart::getDate) + .min(LocalDate::compareTo) + .orElse(LocalDate.now()); + final List groups = school.getDanceGroups().stream() + .filter((final DanceGroup g) -> !g.getDancers().isEmpty()) + .map((final DanceGroup g) -> requirement(g, show, date)) + .toList(); + final Set coaches = school.getCoaches().stream() + .map(Coach::getDancer) + .filter(java.util.Objects::nonNull) + .map(Dancer::getId) + .collect(Collectors.toSet()); + return new SchedulingInput(show.getId(), revision, show.getDayParts().size(), groups, x, coaches); + } + + private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) { + final int count = switch (group.getType()) { + case PRE_SCHOOLER -> 1; + case SHOW_CASE -> show.getDayParts().size(); + default -> preferred(show); + }; + final double age = group.getDancers().stream() + .mapToInt((final Dancer d) -> + Period.between(d.getBirthDate(), date).getYears()) + .average() + .orElse(0); + return new GroupRequirement( + group.getId(), + count, + group.getType() == DanceGroupType.SHOW_CASE, + group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()), + group.getStyles(), + age); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java index acf1304..d90143c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java @@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool; import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.Show; -import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; +import nl.agilecc.agiledancers.model.scheduling.ScheduleRules; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; @@ -41,7 +41,8 @@ public class SchoolManagementService { private final Map revisions = new HashMap<>(); private final Set running = new HashSet<>(); - private void touch(final Show show) { + public synchronized void touch(final Show show) { + show.setFinalized(false); revisions.merge(show.getId(), 1L, Long::sum); } @@ -58,16 +59,11 @@ public class SchoolManagementService { ManagementExceptionKind.INVALID_INPUT, "Scheduling requires at least one school group and one daypart."); } - ShowConfiguration.validate(show.getDayParts(), show.getAppearances()); + if (SchedulingInputs.preferred(show) > show.getDayParts().size()) { + throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Correct x before scheduling."); + } running.add(showId); - return new SchedulingInput( - showId, - revisions.getOrDefault(showId, 0L), - show.getDayParts().size(), - current.stream() - .map((final DanceGroup g) -> new GroupRequirement( - g.getId(), show.getAppearances().get(g.getType()))) - .toList()); + return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L)); } /** Validates the complete result and input revision before replacing any saved assignments. */ @@ -97,6 +93,7 @@ public class SchoolManagementService { .build()); } show.setDayParts(List.copyOf(replacement)); + touch(show); } private Map validateAssignments( @@ -111,29 +108,13 @@ public class SchoolManagementService { } final Map current = new HashMap<>(); groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g)); - validateParts(result.assignments(), current.keySet()); - for (final GroupRequirement group : input.groups()) { - final long id = group.groupId(); - if (result.assignments().stream() - .filter((final List p) -> p.contains(id)) - .count() - != group.appearances()) { - throw new ManagementException( - ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance."); - } + final List violations = ScheduleRules.violations(input, result.assignments()); + if (!violations.isEmpty()) { + throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations)); } return current; } - private static void validateParts(final List> assignments, final Set groupIds) { - for (final List part : assignments) { - if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) { - throw new ManagementException( - ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments."); - } - } - } - /** Releases the run slot after success or failure, without changing saved assignments. */ public synchronized void endScheduling(final long showId) { running.remove(showId); @@ -186,6 +167,7 @@ public class SchoolManagementService { final Dancer dancer = dancer(schoolId, dancerId); dancer.setName(name); dancer.setBirthDate(birthDate); + school(schoolId).getShows().forEach(this::touch); return dancer; } @@ -193,6 +175,10 @@ public class SchoolManagementService { final DanceSchool school = school(schoolId); final Dancer dancer = dancer(schoolId, dancerId); school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer)); + school.getCoaches().stream() + .filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer) + .forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null)); + school.getShows().forEach(this::touch); school.removeDancer(dancer); dancers.delete(dancerId); } @@ -224,7 +210,7 @@ public class SchoolManagementService { final DanceGroup group = group(schoolId, groupId); validateAgeRange(details.startAge(), details.endAge()); group.setCategory(details.category()); - if (group.getType() != details.type()) { + if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) { school(schoolId).getShows().forEach(this::touch); } group.setType(details.type()); @@ -248,11 +234,13 @@ public class SchoolManagementService { public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) { final DanceGroup group = group(schoolId, groupId); group.addDancer(dancer(schoolId, dancerId)); + school(schoolId).getShows().forEach(this::touch); } public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) { final DanceGroup group = group(schoolId, groupId); group.removeDancer(dancer(schoolId, dancerId)); + school(schoolId).getShows().forEach(this::touch); } public synchronized List shows(final long schoolId) { @@ -273,6 +261,7 @@ public class SchoolManagementService { final Show show = shows.add(Show.builder() .name(name) .dayParts(List.copyOf(parts)) + .preferredDayParts((parts.size() == 1 ? 1 : 2)) .appearances(Map.copyOf(counts)) .build()); school.addShow(show); @@ -292,18 +281,7 @@ public class SchoolManagementService { ShowConfiguration.validate(parts, counts); final boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts())); - final List retained = parts.stream() - .map((final DayPart p) -> DayPart.builder() - .date(p.getDate()) - .type(p.getType()) - .danceGroups(show.getDayParts().stream() - .filter((final DayPart old) -> - old.getDate().equals(p.getDate()) && old.getType() == p.getType()) - .findFirst() - .map(DayPart::getDanceGroups) - .orElse(List.of())) - .build()) - .toList(); + final List retained = ShowConfiguration.retainAssignments(show, parts); show.setName(name); show.setDayParts(retained); show.setAppearances(Map.copyOf(counts)); @@ -413,6 +391,7 @@ public class SchoolManagementService { final Show rebuilt = Show.builder() .name(show.getName()) .appearances(show.getAppearances()) + .preferredDayParts(show.getPreferredDayParts()) .dayParts(show.getDayParts().stream() .map((final DayPart p) -> DayPart.builder() .date(p.getDate()) diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java index e6f3c5b..88ebdde 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java @@ -5,11 +5,27 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; public final class ShowConfiguration { private ShowConfiguration() {} + public static List retainAssignments(final Show show, final List parts) { + return parts.stream() + .map((final DayPart p) -> DayPart.builder() + .date(p.getDate()) + .type(p.getType()) + .danceGroups(show.getDayParts().stream() + .filter((final DayPart old) -> + old.getDate().equals(p.getDate()) && old.getType() == p.getType()) + .findFirst() + .map(DayPart::getDanceGroups) + .orElse(List.of())) + .build()) + .toList(); + } + public static Map defaults(final int n) { final Map counts = new EnumMap<>(DanceGroupType.class); if (n > 0) { @@ -18,7 +34,7 @@ public final class ShowConfiguration { type, switch (type) { case PRE_SCHOOLER -> 1; - case SHOW_CASE, PRE_EDUCATION -> n; + case SHOW_CASE -> n; default -> Math.min(2, n); }); } @@ -43,7 +59,7 @@ public final class ShowConfiguration { } } - private static void validateParts(final List parts) { + public static void validateParts(final List parts) { if (parts == null || parts.size() > 6 || parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null) diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowSchedules.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowSchedules.java new file mode 100644 index 0000000..d89bd9a --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowSchedules.java @@ -0,0 +1,121 @@ +package nl.agilecc.agiledancers.service; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.ArrayList; +import java.util.List; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.ScheduleRules; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; + +@Singleton +public class ShowSchedules { + @Inject + private SchoolManagementService schools; + + public ShowSchedules() {} + + public ShowSchedules(final SchoolManagementService schools) { + this.schools = schools; + } + + /** Retains assignments by date/type; removing dayparts may retain an invalid x in the draft. */ + public Show configure(final long schoolId, final Long showId, final Show settings) { + synchronized (schools) { + ShowConfiguration.validateParts(settings.getDayParts()); + final Show existing = showId == null ? null : schools.show(schoolId, showId); + final int x = settings.getPreferredDayParts() == null + ? (existing == null + ? (settings.getDayParts().size() == 1 ? 1 : 2) + : SchedulingInputs.preferred(existing)) + : settings.getPreferredDayParts(); + validateX(x, existing, settings.getDayParts().size()); + final Show show = existing == null + ? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null) + : existing; + final boolean changed = x != SchedulingInputs.preferred(show) + || !keys(show.getDayParts()).equals(keys(settings.getDayParts())); + show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts())); + show.setName(settings.getName()); + show.setPreferredDayParts(x); + if (changed) { + schools.touch(show); + } + return show; + } + } + + private static List keys(final List parts) { + return parts.stream() + .map((final DayPart p) -> p.getDate() + ":" + p.getType()) + .toList(); + } + + private static void validateX(final int x, final Show existing, final int parts) { + if (x < 1 + || x > 6 + || (parts > 0 && x > parts && (existing == null || x != SchedulingInputs.preferred(existing)))) { + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, + "x must be between 1 and the number of dayparts. Retained x must be corrected before scheduling."); + } + } + + /** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */ + public Show saveDraft(final long schoolId, final long showId, final List> assignments) { + synchronized (schools) { + final Show show = schools.show(schoolId, showId); + if (assignments == null || assignments.size() != show.getDayParts().size()) { + throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart."); + } + final List replacement = new ArrayList<>(); + for (int p = 0; p < assignments.size(); p++) { + final DayPart old = show.getDayParts().get(p); + final List ids = assignments.get(p); + if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) { + throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs."); + } + final List groups = ids.stream() + .map((final Long id) -> schools.group(schoolId, id)) + .toList(); + replacement.add(DayPart.builder() + .date(old.getDate()) + .type(old.getType()) + .danceGroups(groups) + .build()); + } + show.setDayParts(List.copyOf(replacement)); + schools.touch(show); + return show; + } + } + + /** Marks a schedule final only after checking all hard rules against current school data. */ + public Show finalizeSchedule(final long schoolId, final long showId) { + synchronized (schools) { + final Show show = schools.show(schoolId, showId); + final List errors = violations(schoolId, showId); + if (!errors.isEmpty()) { + throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors)); + } + show.setFinalized(true); + return show; + } + } + + public List violations(final long schoolId, final long showId) { + synchronized (schools) { + final Show show = schools.show(schoolId, showId); + final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0); + return ScheduleRules.violations( + input, + show.getDayParts().stream() + .map((final DayPart p) -> p.getDanceGroups().stream() + .map(DanceGroup::getId) + .toList()) + .toList()); + } + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java index 66fcb39..ee032ae 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java @@ -7,7 +7,21 @@ import java.util.List; import java.util.Map; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record Performance(String id, String name, List dayParts, Map appearances) { +public record Performance( + String id, + String name, + List dayParts, + Map appearances, + Integer preferredDayParts, + boolean finalized) { + public Performance( + final String id, + final String name, + final List dayParts, + final Map appearances) { + this(id, name, dayParts, appearances, null, false); + } + public Performance { dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts)); appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances)); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java index 8ab4d2f..8ef7bc9 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java @@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException; import nl.agilecc.agiledancers.service.ManagementExceptionKind; import nl.agilecc.agiledancers.service.ShowConfiguration; -/** Version 2 file format. IDs are local to the file, never server identifiers. */ +/** Version 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */ public record SchoolSnapshot( Integer formatVersion, UUID key, @@ -45,14 +45,15 @@ public record SchoolSnapshot( final Map groups = ids(s.getDanceGroups()); final Map shows = ids(s.getShows()); return new SchoolSnapshot( - 2, + 3, s.getKey(), s.getName(), s.getDancers().stream() .map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate())) .toList(), s.getCoaches().stream() - .map((final Coach c) -> new Trainer(coaches.get(c), c.getName())) + .map((final Coach c) -> new Trainer( + coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer()))) .toList(), s.getDanceGroups().stream() .map((final DanceGroup g) -> new Group( @@ -77,7 +78,9 @@ public record SchoolSnapshot( .map(groups::get) .toList())) .toList(), - show.getAppearances())) + show.getAppearances(), + nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show), + show.isFinalized())) .toList()); } @@ -91,12 +94,14 @@ public record SchoolSnapshot( /** Validates all references and values, then rebuilds an independent graph with temporary IDs. */ public DanceSchool restore() { - check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion); + check( + Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3), + "Niet-ondersteunde formatVersion: " + formatVersion); check(key != null, "key ontbreekt"); text(name, "schoolnaam"); final DanceSchool school = DanceSchool.builder().key(key).name(name).build(); final Map dancerMap = restoreDancers(school); - final Map coachMap = restoreCoaches(school); + final Map coachMap = restoreCoaches(school, dancerMap); final Map groupMap = restoreGroups(school, dancerMap, coachMap); restoreShows(school, groupMap); return school; @@ -120,12 +125,15 @@ public record SchoolSnapshot( return dancerMap; } - private Map restoreCoaches(final DanceSchool school) { + private Map restoreCoaches(final DanceSchool school, final Map dancerMap) { final Map coachMap = new LinkedHashMap<>(); for (final Trainer c : list(coaches, "coaches")) { check(c != null, "Lege coach"); text(c.name(), "coachnaam"); - final Coach coach = Coach.builder().name(c.name()).build(); + final Coach coach = Coach.builder() + .name(c.name()) + .dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser")) + .build(); coach.setId((long) coachMap.size()); put(coachMap, c.id(), coach); school.addCoach(coach); @@ -190,17 +198,24 @@ public record SchoolSnapshot( parts.add(DayPart.builder() .date(part.date()) .type(part.type()) - .danceGroups(unique(part.groupIds(), "groupIds").stream() + .danceGroups(list(part.groupIds(), "groupIds").stream() .map((final String id) -> ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")) .toList()) .build()); } - ShowConfiguration.validate(parts, show.appearances()); + ShowConfiguration.validateParts(parts); + final int x = show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts(); + check(x >= 1 && x <= 6, "x moet tussen 1 en 6 liggen"); final Show entity = Show.builder() .name(show.name()) .dayParts(parts) - .appearances(Map.copyOf(show.appearances())) + .appearances( + show.appearances() == null + ? ShowConfiguration.defaults(parts.size()) + : Map.copyOf(show.appearances())) + .preferredDayParts(x) + .finalized(false) .build(); entity.setId((long) showIds.size()); school.addShow(entity); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java index 89ece7e..fe784b2 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java @@ -1,3 +1,7 @@ package nl.agilecc.agiledancers.service.model; -public record Trainer(String id, String name) {} +public record Trainer(String id, String name, String dancerId) { + public Trainer(final String id, final String name) { + this(id, name, null); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java index 4280bb8..aa359c4 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java @@ -1,14 +1,11 @@ package nl.agilecc.agiledancers.solver; import com.google.ortools.Loader; -import com.google.ortools.sat.BoolVar; import com.google.ortools.sat.CpModel; import com.google.ortools.sat.CpSolver; import com.google.ortools.sat.CpSolverStatus; -import com.google.ortools.sat.IntVar; import com.google.ortools.sat.LinearExpr; import jakarta.inject.Singleton; -import java.util.ArrayList; import java.util.List; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; @@ -18,50 +15,51 @@ import nl.agilecc.agiledancers.service.port.SchedulingEngine; @Singleton public class OrToolsSchedulingEngine implements SchedulingEngine { + private final double timeLimitSeconds; + + public OrToolsSchedulingEngine() { + this(1800); + } + + /** Allows callers to impose a shorter run budget, for example in automated tests. */ + public OrToolsSchedulingEngine(final double timeLimitSeconds) { + this.timeLimitSeconds = timeLimitSeconds; + } + @Override public SchedulingResult solve(final SchedulingInput input) { + final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L); Loader.loadNativeLibraries(); final CpModel model = new CpModel(); - final BoolVar[][] assigned = assignments(model, input); - balance(model, assigned, input.dayParts()); + final OrderedAssignments vars = OrderedAssignments.create(model, input); + final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars); + final LinearExpr variety = SchedulingObjectives.variety(model, input, vars); final CpSolver solver = new CpSolver(); - solver.getParameters().setMaxTimeInSeconds(1800); - // Quarkus owns process shutdown; do not install a native SIGINT handler. solver.getParameters().setCatchSigintSignal(false); - // Single-worker search with a fixed seed makes identical inputs repeatable. solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); - final CpSolverStatus status = solver.solve(model); - requireSolution(status); - return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver)); - } - - private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) { - final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()]; - for (int g = 0; g < assigned.length; g++) { - for (int p = 0; p < input.dayParts(); p++) { - assigned[g][p] = model.newBoolVar("g" + g + "p" + p); + SchedulingResult best = null; + for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) { + final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0; + if (remaining <= 0) { + if (best != null) { + return new SchedulingResult(false, best.assignments()); + } + requireSolution(CpSolverStatus.UNKNOWN); } - model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances()); - } - return assigned; - } - - private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) { - final int groups = assigned.length; - final IntVar[] loads = new IntVar[dayParts]; - for (int p = 0; p < dayParts; p++) { - final BoolVar[] column = new BoolVar[groups]; - for (int g = 0; g < groups; g++) { - column[g] = assigned[g][p]; + solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining)); + model.minimize(objective); + final CpSolverStatus status = solver.solve(model); + if (status == CpSolverStatus.UNKNOWN && best != null) { + return new SchedulingResult(false, best.assignments()); } - loads[p] = model.newIntVar(0, groups, "load" + p); - model.addEquality(loads[p], LinearExpr.sum(column)); + requireSolution(status); + best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver)); + if (status != CpSolverStatus.OPTIMAL) { + return best; + } + model.addEquality(objective, Math.round(solver.objectiveValue())); } - final IntVar max = model.newIntVar(0, groups, "max"); - final IntVar min = model.newIntVar(0, groups, "min"); - model.addMaxEquality(max, loads); - model.addMinEquality(min, loads); - model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1})); + return best; } private static void requireSolution(final CpSolverStatus status) { @@ -77,19 +75,4 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { }); } } - - private static List> readAssignments( - final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) { - final List> parts = new ArrayList<>(); - for (int p = 0; p < input.dayParts(); p++) { - final List ids = new ArrayList<>(); - for (int g = 0; g < assigned.length; g++) { - if (solver.booleanValue(assigned[g][p])) { - ids.add(input.groups().get(g).groupId()); - } - } - parts.add(List.copyOf(ids)); - } - return List.copyOf(parts); - } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrderedAssignments.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrderedAssignments.java new file mode 100644 index 0000000..0c210a1 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrderedAssignments.java @@ -0,0 +1,125 @@ +package nl.agilecc.agiledancers.solver; + +import com.google.ortools.sat.BoolVar; +import com.google.ortools.sat.CpModel; +import com.google.ortools.sat.IntVar; +import com.google.ortools.sat.LinearExpr; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; + +/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */ +public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) { + public static OrderedAssignments create(final CpModel model, final SchedulingInput input) { + final int n = input.groups().size(); + final BoolVar[][] present = new BoolVar[n][input.dayParts()]; + final IntVar[][] positions = new IntVar[n][input.dayParts()]; + for (int g = 0; g < n; g++) { + for (int p = 0; p < input.dayParts(); p++) { + present[g][p] = model.newBoolVar("present" + g + ":" + p); + positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p); + model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]); + model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not()); + } + model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances()); + } + final IntVar[] loads = loads(model, present, positions, input.dayParts()); + final IntVar max = model.newIntVar(0, n, "max"); + final IntVar min = model.newIntVar(0, n, "min"); + final IntVar spread = model.newIntVar(0, 2, "spread"); + model.addMaxEquality(max, loads); + model.addMinEquality(min, loads); + model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1})); + final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread); + result.boundaries(model, input); + result.changingTime(model, input); + return result; + } + + private static IntVar[] loads( + final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) { + final IntVar[] loads = new IntVar[parts]; + for (int p = 0; p < parts; p++) { + final BoolVar[] column = new BoolVar[present.length]; + final IntVar[] order = new IntVar[present.length]; + for (int g = 0; g < present.length; g++) { + column[g] = present[g][p]; + order[g] = positions[g][p]; + } + loads[p] = model.newIntVar(0, present.length, "load" + p); + model.addEquality(loads[p], LinearExpr.sum(column)); + model.addAllDifferent(order); + for (int g = 0; g < present.length; g++) { + model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]); + } + } + return loads; + } + + private void boundaries(final CpModel model, final SchedulingInput input) { + final List showcases = IntStream.range(0, input.groups().size()) + .filter((final int g) -> input.groups().get(g).showcase()) + .boxed() + .toList(); + if (showcases.isEmpty()) { + return; + } + for (int p = 0; p < input.dayParts(); p++) { + final List first = new ArrayList<>(); + final List last = new ArrayList<>(); + for (final int g : showcases) { + first.add(equals(model, position[g][p], LinearExpr.constant(0))); + last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1))); + } + if (showcases.size() == 1) { + model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()}); + } else { + model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1); + model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1); + } + } + } + + private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) { + final BoolVar equal = model.newBoolVar("boundary"); + model.addEquality(position, value).onlyEnforceIf(equal); + model.addDifferent(position, value).onlyEnforceIf(equal.not()); + return equal; + } + + private void changingTime(final CpModel model, final SchedulingInput input) { + for (int a = 0; a < input.groups().size(); a++) { + for (int b = a + 1; b < input.groups().size(); b++) { + if (!java.util.Collections.disjoint( + input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) { + separate(model, a, b); + } + } + } + } + + private void separate(final CpModel model, final int a, final int b) { + for (int p = 0; p < loads.length; p++) { + final IntVar gap = model.newIntVar(0, present.length * 2L, "gap"); + model.addAbsEquality( + gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1})); + model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]}); + } + } + + public List> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) { + final List> result = new ArrayList<>(); + for (int p = 0; p < loads.length; p++) { + final int part = p; + result.add(IntStream.range(0, present.length) + .boxed() + .filter((final Integer g) -> solver.booleanValue(present[g][part])) + .sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part]))) + .map((final Integer g) -> input.groups().get(g).groupId()) + .toList()); + } + return List.copyOf(result); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingObjectives.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingObjectives.java new file mode 100644 index 0000000..a51560e --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingObjectives.java @@ -0,0 +1,132 @@ +package nl.agilecc.agiledancers.solver; + +import com.google.ortools.sat.BoolVar; +import com.google.ortools.sat.CpModel; +import com.google.ortools.sat.IntVar; +import com.google.ortools.sat.LinearExpr; +import com.google.ortools.sat.LinearExprBuilder; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; +import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.VarietyScore; + +public final class SchedulingObjectives { + private SchedulingObjectives() {} + + public static LinearExpr attendance( + final CpModel model, final SchedulingInput input, final OrderedAssignments vars) { + final Set dancers = new HashSet<>(); + final Set showcases = new HashSet<>(); + input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds())); + input.groups().stream() + .filter(GroupRequirement::showcase) + .forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds())); + dancers.removeAll(input.coachIds()); + final LinearExprBuilder penalty = LinearExpr.newBuilder(); + for (final long dancer : dancers) { + final IntVar[] parts = dancerParts(model, input, vars, dancer); + if (showcases.contains(dancer)) { + final IntVar excess = model.newIntVar(0, input.dayParts(), "excess"); + model.addMaxEquality(excess, new LinearExpr[] { + LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0) + }); + penalty.add(excess); + } else { + model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts()); + } + } + return penalty.build(); + } + + private static IntVar[] dancerParts( + final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) { + final List groups = IntStream.range(0, input.groups().size()) + .filter((final int g) -> !input.groups().get(g).showcase() + && input.groups().get(g).dancerIds().contains(dancer)) + .boxed() + .toList(); + final IntVar[] result = new IntVar[input.dayParts()]; + for (int p = 0; p < input.dayParts(); p++) { + final int part = p; + result[p] = model.newBoolVar("dancer" + dancer + ":" + p); + if (groups.isEmpty()) { + model.addEquality(result[p], 0); + } else { + model.addMaxEquality( + result[p], + groups.stream() + .map((final Integer g) -> vars.present()[g][part]) + .toArray(IntVar[]::new)); + } + } + return result; + } + + public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) { + final LinearExprBuilder penalty = LinearExpr.newBuilder(); + // Every nonempty daypart has load - 1 adjacencies. Remove their common score + // to avoid exploring equivalent orders when groups have identical penalties. + final long baseline = minimumPenalty(input.groups()); + final long performances = + input.groups().stream().mapToLong(GroupRequirement::appearances).sum(); + penalty.add(performances * baseline); + for (final IntVar load : vars.loads()) { + final BoolVar active = model.newBoolVar("nonemptyPart"); + model.addGreaterThan(load, 0).onlyEnforceIf(active); + model.addEquality(load, 0).onlyEnforceIf(active.not()); + penalty.addTerm(active, -baseline); + } + for (int a = 0; a < input.groups().size(); a++) { + for (int b = a + 1; b < input.groups().size(); b++) { + final long score = VarietyScore.penalty( + input.groups().get(a), input.groups().get(b)) + - baseline; + if (score > 0) { + addAdjacencies(model, vars, new int[] {a, b}, penalty, score); + } + } + } + return penalty.build(); + } + + private static long minimumPenalty(final List groups) { + long minimum = 2_000_000; + for (int a = 0; a < groups.size(); a++) { + for (int b = a + 1; b < groups.size(); b++) { + minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b))); + } + } + return groups.size() < 2 ? 0 : minimum; + } + + private static void addAdjacencies( + final CpModel model, + final OrderedAssignments vars, + final int[] pair, + final LinearExprBuilder penalty, + final long score) { + for (int p = 0; p < vars.loads().length; p++) { + penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score); + } + } + + private static BoolVar adjacent( + final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) { + final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap"); + model.addAbsEquality( + gap, + LinearExpr.weightedSum( + new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1})); + final BoolVar adjacent = model.newBoolVar("adjacent"); + model.addImplication(adjacent, vars.present()[a][p]); + model.addImplication(adjacent, vars.present()[b][p]); + model.addEquality(gap, 1).onlyEnforceIf(adjacent); + model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] { + adjacent.not(), vars.present()[a][p], vars.present()[b][p] + }); + return adjacent; + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18LifecycleTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18LifecycleTest.java new file mode 100644 index 0000000..71baf0d --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18LifecycleTest.java @@ -0,0 +1,148 @@ +package nl.agilecc.agiledancers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +import nl.agilecc.agiledancers.service.ManagementException; +import nl.agilecc.agiledancers.service.SchedulingInputs; +import nl.agilecc.agiledancers.service.SchedulingService; +import nl.agilecc.agiledancers.service.SchoolManagementService; +import nl.agilecc.agiledancers.service.ShowSchedules; +import nl.agilecc.agiledancers.service.model.GroupDetails; +import nl.agilecc.agiledancers.service.model.SchoolSnapshot; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; + +class S18LifecycleTest { + private final SchoolManagementService schools = new SchoolManagementService(); + private final long school = schools.createSchool("Studio").getId(); + private final ShowSchedules schedules = new ShowSchedules(schools); + + private List parts(final int n) { + return java.util.stream.IntStream.range(0, n) + .mapToObj((final int i) -> DayPart.builder() + .date(LocalDate.of(2027, 1, 1).plusDays(i)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build()) + .toList(); + } + + private Show settings(final int n, final Integer x) { + return Show.builder() + .name("Show") + .dayParts(parts(n)) + .preferredDayParts(x) + .build(); + } + + private DanceGroup group(final DanceGroupType type) { + final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of())); + final long dancer = + schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId(); + schools.addGroupDancer(school, group.getId(), dancer); + return group; + } + + public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() { + final DanceGroup group = group(DanceGroupType.REGULAR); + final Show show = schedules.configure(school, null, settings(2, 1)); + assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId())); + schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of())); + assertTrue(schedules.violations(school, show.getId()).isEmpty()); + schedules.finalizeSchedule(school, show.getId()); + assertTrue(show.isFinalized()); + final SchoolSnapshot snapshot = schools.exportSchool(school); + assertTrue(snapshot.shows().getFirst().finalized()); + final Show imported = snapshot.restore().getShows().iterator().next(); + assertFalse(imported.isFinalized()); + assertEquals(1, imported.getPreferredDayParts()); + assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size()); + schedules.configure(school, show.getId(), settings(2, 1)); + assertTrue(show.isFinalized()); + schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId()))); + assertFalse(show.isFinalized()); + schedules.finalizeSchedule(school, show.getId()); + schools.removeGroupDancer( + school, group.getId(), group.getDancers().iterator().next().getId()); + assertFalse(show.isFinalized()); + assertFalse(schedules.violations(school, show.getId()).isEmpty()); + } + + public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() { + final DanceGroup group = group(DanceGroupType.REGULAR); + final Show show = schedules.configure(school, null, settings(2, 1)); + schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of())); + assertFalse(schedules.violations(school, show.getId()).isEmpty()); + final SchoolSnapshot before = schools.exportSchool(school); + assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of())); + assertThrows( + ManagementException.class, + () -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L)))); + assertEquals(before, schools.exportSchool(school)); + } + + public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() { + final Show one = schedules.configure(school, null, settings(1, null)); + assertEquals(1, one.getPreferredDayParts()); + final Show show = schedules.configure(school, null, settings(3, null)); + assertEquals(2, show.getPreferredDayParts()); + schedules.configure(school, show.getId(), settings(1, null)); + assertEquals(2, show.getPreferredDayParts()); + assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId())); + schedules.configure(school, show.getId(), settings(1, 1)); + assertTrue(schedules.violations(school, show.getId()).isEmpty()); + final int before = schools.shows(school).size(); + assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2))); + assertEquals(before, schools.shows(school).size()); + schedules.configure(school, show.getId(), settings(0, null)); + assertFalse(schedules.violations(school, show.getId()).isEmpty()); + } + + public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() { + group(DanceGroupType.REGULAR); + final Show show = schedules.configure(school, null, settings(1, 1)); + new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId()); + final SchoolSnapshot before = schools.exportSchool(school); + final ManagementException error = assertThrows( + ManagementException.class, + () -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId())); + assertTrue(error.getMessage().contains("limit")); + assertFalse(error.getMessage().contains("infeasible")); + assertEquals(before, schools.exportSchool(school)); + } + + public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() { + final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION); + group(DanceGroupType.PRE_SCHOOLER); + schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of())); + final Show show = schedules.configure(school, null, settings(3, 1)); + final SchedulingInput input = schools.beginScheduling(school, show.getId()); + assertEquals(2, input.groups().size()); + assertEquals(26, input.groups().getFirst().averageAge()); + assertEquals(1, input.groups().getFirst().appearances()); + schools.endScheduling(show.getId()); + new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId()); + assertFalse(show.isFinalized()); + final SchedulingInput stale = schools.beginScheduling(school, show.getId()); + final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale); + schools.updateDancer( + school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1)); + assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result)); + schools.endScheduling(show.getId()); + assertEquals( + 2, SchedulingInputs.of(schools.school(school), show, 0).groups().size()); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java new file mode 100644 index 0000000..69a420f --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java @@ -0,0 +1,136 @@ +package nl.agilecc.agiledancers; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +import io.quarkus.test.junit.QuarkusTest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class S18ResourceTest { + private static final String ROOT = "/api/dance-schools"; + + private static long create(final String url, final Object body) { + return given().contentType("application/json") + .body(body) + .post(url) + .then() + .statusCode(201) + .extract() + .jsonPath() + .getLong("id"); + } + + private static long dancer(final String school, final String name) { + return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01")); + } + + private static long group(final String school, final long dancer) { + final long group = create( + school + "/dance-groups", + Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of())); + given().put(school + "/dance-groups/" + group + "/dancers/" + dancer) + .then() + .statusCode(204); + return group; + } + + private static String show(final String school) { + return school + "/shows/" + + create( + school + "/shows", + Map.of( + "name", + "Show", + "preferredDayParts", + 1, + "dayParts", + List.of(Map.of("date", "2027-01-01", "type", "MORNING")))); + } + + public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() { + final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle")); + try { + final long dancer = dancer(school, "Dancer"); + final long group = group(school, dancer); + final String show = show(school); + given().post(show + "/finalize").then().statusCode(400); + given().contentType("application/json") + .body(Map.of("assignments", List.of(List.of(group)))) + .put(show + "/assignments") + .then() + .statusCode(200) + .body("finalized", equalTo(false), "violations", empty()); + given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true)); + given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer) + .then() + .statusCode(204); + given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1)); + given().contentType("application/json") + .body(Map.of("assignments", List.of(List.of(group, group)))) + .put(show + "/assignments") + .then() + .statusCode(200); + final String snapshot = given().get(school + "/export").asString(); + given().contentType("application/json") + .body(snapshot) + .post(ROOT + "/import/preview") + .then() + .statusCode(200); + } finally { + given().delete(school).then().statusCode(204); + } + } + + public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() { + final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches")); + try { + final long dancer = dancer(school, "Coach"); + group(school, dancer); + final String show = show(school); + given().post(show + "/schedule").then().statusCode(200); + given().post(show + "/finalize").then().statusCode(200); + final long coach = given().contentType("application/json") + .body(Map.of("dancerId", dancer)) + .post(school + "/coaches") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getLong("id"); + given().get(show).then().body("finalized", equalTo(false)); + given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer)); + final String exported = given().get(school + "/export").asString(); + given().contentType("application/json") + .body(exported) + .post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1)) + .then() + .statusCode(200); + given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1")); + final long importedDancer = + given().get(school + "/dancers").jsonPath().getLong("[0].id"); + given().contentType("application/json") + .body(Map.of("dancerId", importedDancer)) + .put(school + "/coaches/0") + .then() + .statusCode(200); + given().delete(school + "/coaches/0/dancer").then().statusCode(204); + given().contentType("application/json") + .body(Map.of("dancerId", -1)) + .post(school + "/coaches") + .then() + .statusCode(404); + given().contentType("application/json") + .body(Map.of("dancerId", importedDancer)) + .put(school + "/coaches/9999") + .then() + .statusCode(404); + } finally { + given().delete(school).then().statusCode(204); + } + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18SolverTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18SolverTest.java new file mode 100644 index 0000000..3ab06d0 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18SolverTest.java @@ -0,0 +1,133 @@ +package nl.agilecc.agiledancers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; +import nl.agilecc.agiledancers.model.scheduling.ScheduleRules; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.scheduling.VarietyScore; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.service.ManagementException; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; + +class S18SolverTest { + private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) { + return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20); + } + + private SchedulingResult solve(final SchedulingInput input) { + final SchedulingResult result = new OrToolsSchedulingEngine().solve(input); + assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty()); + assertTrue(result.optimal()); + return result; + } + + public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() { + final List groups = List.of( + group(1, 2, true, 1), + group(2, 2, true, 2), + group(3, 2, true, 3), + group(4, 1, false, 1), + group(5, 1, false, 5), + group(6, 1, false, 6), + group(7, 1, false, 7)); + final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of()); + final SchedulingResult result = solve(input); + for (final List part : result.assignments()) { + assertTrue(part.getFirst() <= 3); + assertTrue(part.getLast() <= 3); + } + assertEquals( + 1, + result.assignments().stream() + .filter((final List p) -> p.contains(4L)) + .count()); + } + + public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() { + final SchedulingInput input = new SchedulingInput( + 1, + 0, + 2, + List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)), + 1, + Set.of()); + assertThrows(ManagementException.class, () -> solve(input)); + final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L)); + final SchedulingResult result = solve(coach); + assertTrue(result.assignments().stream().allMatch((final List p) -> p.size() == 2)); + } + + public @Test void showcaseExcessTakesPriorityOverPerfectBalance() { + final SchedulingInput input = new SchedulingInput( + 1, + 0, + 2, + List.of( + group(1, 2, true, 1), + group(2, 1, false, 1), + group(3, 1, false, 1), + group(4, 1, false, 4), + group(5, 1, false, 5), + group(6, 1, false, 6), + group(7, 1, false, 7), + group(8, 1, false, 8), + group(9, 1, false, 9), + group(10, 1, false, 10), + group(11, 1, false, 11)), + 1, + Set.of()); + final SchedulingResult result = solve(input); + assertTrue(result.assignments().stream().anyMatch((final List p) -> p.containsAll(List.of(2L, 3L)))); + assertEquals( + 2, + Math.abs(result.assignments().get(0).size() + - result.assignments().get(1).size())); + } + + public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() { + final SchedulingInput input = + new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L)); + assertThrows(ManagementException.class, () -> solve(input)); + assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream() + .anyMatch((final String s) -> s.contains("two intervening"))); + } + + public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() { + solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of())); + solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of())); + } + + public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() { + final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()); + assertFalse(ScheduleRules.violations(input, null).isEmpty()); + assertFalse(ScheduleRules.violations(input, List.of()).isEmpty()); + assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty()); + assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty()); + assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty()); + final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of()); + assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty()); + } + + public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() { + final GroupRequirement hiphop = + new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10); + final GroupRequirement mixed = new GroupRequirement( + 2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5); + final GroupRequirement modern = + new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15); + assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed)); + assertEquals(0, VarietyScore.penalty(hiphop, modern)); + final List order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of())) + .assignments() + .getFirst(); + assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L))); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java index 6edc84c..aa66290 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java @@ -41,7 +41,12 @@ class SchedulingTest { } private DanceGroup group(final DanceGroupType type) { - return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of())); + final DanceGroup group = service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of())); + service.addGroupDancer( + school, + group.getId(), + service.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId()); + return group; } private void run(final Show show) { @@ -86,7 +91,7 @@ class SchedulingTest { final Show show = service.createShow(school, "Show", List.of(), null); assertTrue(show.getAppearances().isEmpty()); service.updateShow(school, show.getId(), "Show", parts(3), null); - assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); + assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER)); final var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2); diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java index 621f969..5b5e662 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -171,15 +171,17 @@ class SchoolManagementResourceTest { final String show = school + "/shows/" + create(school + "/shows", SHOW); given().post(show + "/schedule").then().statusCode(400); final long group = create(school + "/dance-groups", GROUP); + final long dancer = create(school + "/dancers", DANCER); + given().put(school + "/dance-groups/" + group + "/dancers/" + dancer) + .then() + .statusCode(204); given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true)); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); final String before = given().get(show).asString(); - final String counts = - "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}"; - for (final String count : new String[] {"0", "2", "1.5", "null"}) { + for (final String count : new String[] {"0", "2", "1.5", "7"}) { final String body = - "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" - + counts.formatted(count) + "}"; + "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":" + + count + "}"; put(show, body).statusCode(400); given().get(show).then().body(equalTo(before)); } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java index 819abfd..123eea5 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java @@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest { .then() .statusCode(200) .header("Content-Disposition", containsString(".json")) - .body("formatVersion", equalTo(2)) + .body("formatVersion", equalTo(3)) .extract() .asString(); try { @@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest { for (final String bad : new String[] { "{", "null", - json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), + json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"), json.replace("\"name\" :", "\"unknown\" :"), json + " {}", json.replace("\"dancers\" : [ ]", "\"dancers\" : null") diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SnapshotCollectionsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SnapshotCollectionsTest.java index e2ee85d..37f724c 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SnapshotCollectionsTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SnapshotCollectionsTest.java @@ -58,7 +58,7 @@ class SnapshotCollectionsTest { @Test public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() { final Map counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1)); - final ShowRequest show = new ShowRequest("Show", List.of(), counts); + final ShowRequest show = new ShowRequest("Show", List.of(), counts, null); counts.clear(); assertEquals(1, show.appearances().get(DanceGroupType.REGULAR)); assertThrows( diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java index 0f0270f..cfc2819 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java @@ -29,7 +29,7 @@ class SchedulingServiceTest { public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { final var schools = new SchoolManagementService(); final long school = schools.createSchool("School").getId(); - schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())); + populatedGroup(schools, school); final Show show = schools.createShow( school, "Show", @@ -62,7 +62,7 @@ class SchedulingServiceTest { public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { final var schools = new SchoolManagementService(); final long school = schools.createSchool("School").getId(); - schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())); + populatedGroup(schools, school); final Show show = schools.createShow( school, "Show", @@ -84,7 +84,7 @@ class SchedulingServiceTest { ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())) .getKind()); - schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of())); + populatedGroup(schools, school); } finally { release.countDown(); } @@ -98,6 +98,16 @@ class SchedulingServiceTest { assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size()); } + private static void populatedGroup(final SchoolManagementService schools, final long school) { + final long group = schools.createGroup( + school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())) + .getId(); + schools.addGroupDancer( + school, + group, + schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId()); + } + private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) { final SchedulingEngine engine = (final SchedulingInput input) -> { entered.countDown(); diff --git a/agiledancers-fe/e2e/school-management.spec.ts b/agiledancers-fe/e2e/school-management.spec.ts index 0764762..64ee27c 100644 --- a/agiledancers-fe/e2e/school-management.spec.ts +++ b/agiledancers-fe/e2e/school-management.spec.ts @@ -31,6 +31,7 @@ async function confirmDelete(page: Page) { test('manages a school, shared memberships and shows through the real REST API', async ({ page, + request, }) => { const schoolName = `E2E Studio ${Date.now()}`; const browserErrors: string[] = []; @@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API', ).toBeVisible(); await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true }); + // Two independent performances give Sam time to change between the shared groups. + for (const name of ['Modern adults', 'Streetdance adults']) { + const dancerResponse = await request.post( + `${backend}/api/dance-schools/${schoolToDelete}/dancers`, + { + data: { name, birthDate: '1990-01-01' }, + }, + ); + const dancer = (await dancerResponse.json()) as { id: number }; + const groupResponse = await request.post( + `${backend}/api/dance-schools/${schoolToDelete}/dance-groups`, + { + data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] }, + }, + ); + const group = (await groupResponse.json()) as { id: number }; + await request.put( + `${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`, + ); + } await tab(page, 'Shows'); await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); @@ -120,8 +141,19 @@ test('manages a school, shared memberships and shows through the real REST API', await expect(page.locator('.date-tile')).toContainText('jul 2027'); page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: /Schedule groups/ }).click(); - await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible(); - await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible(); + await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible(); + await expect( + page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click(); + const scheduleDialog = page.getByRole('dialog'); + await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click(); + await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible(); + await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click(); + await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click(); + await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible(); + await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled(); + await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click(); await page.getByRole('button', { name: 'Zomershow bewerken' }).click(); await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click(); await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02'); @@ -148,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API', await tab(page, 'Dansers'); await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click(); await confirmDelete(page); - await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0); await tab(page, 'Dansgroepen'); await expect(page.getByText(/0 dansers/)).toHaveCount(2); await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click(); @@ -166,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API', expect(browserErrors).toEqual([]); }); +test('links a coach and finalizes performances spread across multiple dates', async ({ + page, + request, +}) => { + const schoolResponse = await request.post(`${backend}/api/dance-schools`, { + data: { name: `Coach show ${Date.now()}` }, + }); + const school = (await schoolResponse.json()) as School; + schoolToDelete = school.id; + const base = `${backend}/api/dance-schools/${school.id}`; + const dancerResponse = await request.post(`${base}/dancers`, { + data: { name: 'Dancing coach', birthDate: '1990-01-01' }, + }); + const dancer = (await dancerResponse.json()) as { id: number }; + const groupIds: number[] = []; + for (const name of ['Preschool', 'Adults']) { + const response = await request.post(`${base}/dance-groups`, { + data: { + category: name, + type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR', + startAge: 0, + endAge: 99, + styles: [], + }, + }); + const group = (await response.json()) as { id: number }; + groupIds.push(group.id); + await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`); + } + const showResponse = await request.post(`${base}/shows`, { + data: { + name: 'Coach across dates', + preferredDayParts: 1, + dayParts: [ + { date: '2027-06-01', type: 'AFTERNOON' }, + { date: '2027-06-02', type: 'AFTERNOON' }, + ], + }, + }); + const show = (await showResponse.json()) as { id: number }; + await request.put(`${base}/shows/${show.id}/assignments`, { + data: { assignments: groupIds.map((id) => [id]) }, + }); + await page.goto(`/schools/${school.id}/shows`); + await expect(page.getByText(/exceeds x=1/)).toBeVisible(); + await tab(page, 'Dansers'); + await page.getByText('Coaches koppelen aan dansers', { exact: true }).click(); + await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' }); + await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible(); + await tab(page, 'Shows'); + await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible(); + await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click(); + await expect( + page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }), + ).toBeDisabled(); + const finalResponse = await request.get(`${base}/shows/${show.id}`); + expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true); +}); + test('shows a useful connection error and recovers on retry', async ({ page }) => { await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed')); await page.goto('/schools'); diff --git a/agiledancers-fe/src/app/core/api-models.ts b/agiledancers-fe/src/app/core/api-models.ts index d643150..6fa6a02 100644 --- a/agiledancers-fe/src/app/core/api-models.ts +++ b/agiledancers-fe/src/app/core/api-models.ts @@ -27,11 +27,19 @@ export interface DayPart { type: DayPartType; danceGroupIds: number[]; } +export interface Coach { + id: number; + name: string; + dancerId: number | null; +} export interface Show { id: number; name: string; dayParts: DayPart[]; appearances: Partial>; + preferredDayParts: number; + finalized: boolean; + violations: string[]; } export interface SchoolRequest { name: string; @@ -51,6 +59,7 @@ export interface ShowRequest { name: string; dayParts: { date: string; type: DayPartType }[]; appearances?: Partial>; + preferredDayParts?: number; } export type ResourceKind = 'school' | 'dancer' | 'group' | 'show'; export type EditorTarget = @@ -63,6 +72,7 @@ export interface SchoolData { dancers: Dancer[]; groups: DanceGroup[]; shows: Show[]; + coaches: Coach[]; } export const GROUP_TYPES: { value: GroupType; label: string }[] = [ { value: 'REGULAR', label: 'Regulier' }, diff --git a/agiledancers-fe/src/app/core/school-api.spec.ts b/agiledancers-fe/src/app/core/school-api.spec.ts index f540a1d..b892189 100644 --- a/agiledancers-fe/src/app/core/school-api.spec.ts +++ b/agiledancers-fe/src/app/core/school-api.spec.ts @@ -24,7 +24,14 @@ describe('SchoolApi contract', () => { http.expectOne('/api/dance-schools/7/dance-groups').flush([]); expect(received).not.toHaveBeenCalled(); http.expectOne('/api/dance-schools/7/shows').flush([]); - expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] }); + http.expectOne('/api/dance-schools/7/coaches').flush([]); + expect(received).toHaveBeenCalledWith({ + school, + dancers: [], + groups: [], + shows: [], + coaches: [], + }); }); it('updates an existing entity even when its id is zero', () => { diff --git a/agiledancers-fe/src/app/core/school-api.ts b/agiledancers-fe/src/app/core/school-api.ts index a49064c..07cc84b 100644 --- a/agiledancers-fe/src/app/core/school-api.ts +++ b/agiledancers-fe/src/app/core/school-api.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { forkJoin, Observable } from 'rxjs'; import { + Coach, DanceGroup, Dancer, DancerRequest, @@ -29,6 +30,7 @@ export class SchoolApi { dancers: this.http.get(`${base}/dancers`), groups: this.http.get(`${base}/dance-groups`), shows: this.http.get(`${base}/shows`), + coaches: this.http.get(`${base}/coaches`), }); } saveSchool(body: SchoolRequest, id?: number): Observable { @@ -52,6 +54,20 @@ export class SchoolApi { { timeout: 1860000 }, ); } + saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable { + return this.http.put(`${this.base}/${schoolId}/shows/${showId}/assignments`, { + assignments, + }); + } + finalize(schoolId: number, showId: number): Observable { + return this.http.post(`${this.base}/${schoolId}/shows/${showId}/finalize`, null); + } + linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable { + return this.save(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId); + } + unlinkCoach(schoolId: number, coachId: number): Observable { + return this.http.delete(`${this.base}/${schoolId}/coaches/${coachId}/dancer`); + } delete(kind: ResourceKind, schoolId: number, id: number): Observable { const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ kind diff --git a/agiledancers-fe/src/app/schools/school-page.html b/agiledancers-fe/src/app/schools/school-page.html index cfb2282..b6f3b29 100644 --- a/agiledancers-fe/src/app/schools/school-page.html +++ b/agiledancers-fe/src/app/schools/school-page.html @@ -151,6 +151,7 @@ } @case ('dancers') { +

Dansers

@@ -350,6 +351,15 @@

{{ show.name }}

+

+ {{ show.finalized ? 'Definitief' : 'Concept' }} · x = {{ show.preferredDayParts }} +

+ @for (violation of show.violations; track $index) { +

{{ violation }}

+ } +

{{ dateRange(show) }}

@for (part of show.dayParts; track $index) {

{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}

@@ -357,7 +367,7 @@

{{ scheduleValid(show) - ? 'Planning voldoet aan alle optredensaantallen.' + ? 'Planning voldoet aan alle harde regels.' : 'Geen geldige planning. Plan de groepen opnieuw in.' }}

@@ -365,7 +375,10 @@ type="button" class="button primary" [disabled]=" - scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length + scheduling().includes(show.id) || + !context.groups.length || + !show.dayParts.length || + show.preferredDayParts > show.dayParts.length " (click)="schedule(show)" > @@ -426,6 +439,15 @@ (saved)="saved()" /> } + @if (scheduleEditing(); as show) { + + } @if (members(); as group) { (null); + readonly scheduleEditing = signal(null); readonly members = signal(null); readonly deleting = signal(null); readonly scheduling = signal([]); @@ -86,6 +91,7 @@ export class SchoolPage implements OnInit { this.search.set(''); this.editor.set(null); this.members.set(null); + this.scheduleEditing.set(null); this.deleting.set(null); this.section.set(params.get('section') ?? 'overview'); return this.api.schoolData(Number(params.get('schoolId'))).pipe( @@ -121,22 +127,9 @@ export class SchoolPage implements OnInit { return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`; } scheduleValid(show: Show): boolean { - const groups = this.data()?.groups ?? []; - return ( - !!groups.length && - !!show.dayParts.length && - groups.every( - (g) => - show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length === - show.appearances[g.type], - ) && - show.dayParts.every( - (p) => - new Set(p.danceGroupIds).size === p.danceGroupIds.length && - p.danceGroupIds.every((id) => groups.some((g) => g.id === id)), - ) - ); + return show.violations?.length === 0; } + groupNamesFor(ids: number[]): string { return ( ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') || @@ -163,7 +156,9 @@ export class SchoolPage implements OnInit { try { const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); this.notice.set( - result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.', + result.optimal + ? 'Optimaal conceptrooster.' + : 'Geldig conceptrooster; optimaliteit niet bewezen.', ); await this.reload(); } catch (error: unknown) { @@ -221,6 +216,7 @@ export class SchoolPage implements OnInit { this.deleting.set(target); } membershipChanged(ids: number[]): void { + void this.reload(); const groupId = this.members()?.id; this.data.update((data) => data diff --git a/agiledancers-fe/src/app/shared/coach-editor.ts b/agiledancers-fe/src/app/shared/coach-editor.ts new file mode 100644 index 0000000..a34e053 --- /dev/null +++ b/agiledancers-fe/src/app/shared/coach-editor.ts @@ -0,0 +1,70 @@ +import { Component, inject, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { SchoolData } from '../core/api-models'; +import { SchoolApi } from '../core/school-api'; +import { apiError } from '../core/api-error'; + +@Component({ + selector: 'app-coach-editor', + imports: [FormsModule], + template: ` +
+ Coaches koppelen aan dansers +

+ Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze + meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht. +

+ @if (error()) { + + } +
+ @for (coach of data().coaches; track coach.id) { + + } + +
+
+ `, +}) +export class CoachEditor { + readonly data = input.required(); + readonly changed = output(); + readonly busy = signal(false); + readonly error = signal(''); + private readonly api = inject(SchoolApi); + + async link(dancerId: number | null, coachId?: number): Promise { + if (this.busy() || (dancerId === null && coachId === undefined)) return; + this.busy.set(true); + this.error.set(''); + try { + await firstValueFrom( + dancerId === null + ? this.api.unlinkCoach(this.data().school.id, coachId!) + : this.api.linkCoach(this.data().school.id, dancerId, coachId), + ); + this.changed.emit(); + } catch (error: unknown) { + this.error.set(apiError(error)); + } finally { + this.busy.set(false); + } + } +} diff --git a/agiledancers-fe/src/app/shared/entity-editor.spec.ts b/agiledancers-fe/src/app/shared/entity-editor.spec.ts index d94f419..bb0689e 100644 --- a/agiledancers-fe/src/app/shared/entity-editor.spec.ts +++ b/agiledancers-fe/src/app/shared/entity-editor.spec.ts @@ -70,6 +70,9 @@ describe('EntityEditor', () => { const show: Show = { id: 3, name: 'Summer', + preferredDayParts: 2, + finalized: false, + violations: [], dayParts: [ { date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] }, { date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] }, @@ -77,7 +80,7 @@ describe('EntityEditor', () => { appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 }, }; - it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => { + it('defaults x to two and rejects duplicate dated dayparts and fractional x', () => { const component = editor({ kind: 'show' }); component.name = 'Show'; expect(component.validDetails()).toBe(true); @@ -87,31 +90,25 @@ describe('EntityEditor', () => { component.dayParts[1].date = '2027-06-02'; component.addPart(); component.dayParts[2].date = '2027-06-03'; - expect(component.appearances).toEqual({ - REGULAR: 2, - SELECTION: 2, - PRE_SCHOOLER: 1, - SHOW_CASE: 3, - PRE_EDUCATION: 3, - }); + expect(component.preferredDayParts).toBe(2); expect(component.validDetails()).toBe(true); component.dayParts[2].date = '2027-06-02'; expect(component.validDetails()).toBe(false); component.dayParts[2].date = '2027-06-03'; - component.appearances.PRE_SCHOOLER = 1.5; + component.preferredDayParts = 1.5; expect(component.validDetails()).toBe(false); }); - it('preserves saved counts and requires explicit correction when reducing to one daypart', () => { + it('retains x when dayparts are removed so it can be explicitly corrected', () => { const component = editor({ kind: 'show', value: show }); component.addPart(); component.dayParts[2].date = '2027-06-03'; - expect(component.appearances.SHOW_CASE).toBe(2); + expect(component.preferredDayParts).toBe(2); component.removePart(2); component.removePart(1); - expect(component.appearances.REGULAR).toBe(2); - expect(component.validDetails()).toBe(false); - component.setSingleCounts(); + expect(component.preferredDayParts).toBe(2); + expect(component.validDetails()).toBe(true); + component.preferredDayParts = 1; expect(component.validDetails()).toBe(true); }); @@ -120,11 +117,11 @@ describe('EntityEditor', () => { const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); try { const component = editor({ kind: 'show', value: show }); - component.appearances.REGULAR = 1; + component.preferredDayParts = 1; await component.save(); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); expect(api.saveShow).not.toHaveBeenCalled(); - component.appearances.REGULAR = 2; + component.preferredDayParts = 2; confirm.mockClear(); component.name = 'Renamed'; await component.save(); @@ -147,7 +144,7 @@ describe('EntityEditor', () => { } }); - it('displays one disabled appearance field for every type with one daypart', async () => { + it('displays one editable x field with a default of one for a one-daypart show', async () => { // jsdom does not implement native dialog opening. const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { @@ -163,8 +160,8 @@ describe('EntityEditor', () => { fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; const inputs = Array.from(element.querySelectorAll('input[type="number"]')); - expect(inputs).toHaveLength(5); - expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true); + expect(inputs).toHaveLength(1); + expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true); } finally { if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor); else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal'); diff --git a/agiledancers-fe/src/app/shared/entity-editor.ts b/agiledancers-fe/src/app/shared/entity-editor.ts index ec73894..189a84c 100644 --- a/agiledancers-fe/src/app/shared/entity-editor.ts +++ b/agiledancers-fe/src/app/shared/entity-editor.ts @@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame'; @if (dayParts.length) { -
- Optredens per groepstype - @for (option of types; track option.value) { - - } -

- Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen - vóór opslaan. -

- @if (dayParts.length === 1 && !validCounts()) { - - } -
+ +

+ Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder + dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen. +

} @else {

Zonder dagdelen blijft deze show ongepland.

} @@ -196,8 +185,7 @@ export class EntityEditor implements OnInit { readonly today = new Date().toLocaleDateString('en-CA'); private readonly api = inject(SchoolApi); dayParts: DayPart[] = []; - appearances: Partial> = {}; - private countsInitialized = false; + preferredDayParts = 2; name = ''; date = ''; type: GroupType = 'REGULAR'; @@ -223,44 +211,23 @@ export class EntityEditor implements OnInit { if (target.kind === 'dancer') this.date = target.value.birthDate; if (target.kind === 'show') { this.dayParts = target.value.dayParts.map((p) => ({ ...p })); - this.appearances = { ...target.value.appearances }; - this.countsInitialized = Object.keys(this.appearances).length > 0; + this.preferredDayParts = target.value.preferredDayParts; } } } addPart(): void { if (this.dayParts.length >= 6) return; - const previous = this.dayParts.length; this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] }); - if (!this.countsInitialized) { - for (const { value } of this.types) { - const defaultCount = (n: number) => - value === 'PRE_SCHOOLER' - ? 1 - : value === 'SHOW_CASE' || value === 'PRE_EDUCATION' - ? n - : Math.min(2, n); - if ( - this.appearances[value] === undefined || - this.appearances[value] === defaultCount(previous) - ) - this.appearances[value] = defaultCount(this.dayParts.length); - } - } + if (!this.target().value) this.preferredDayParts = Math.min(2, this.dayParts.length); } removePart(index: number): void { this.dayParts.splice(index, 1); - if (!this.countsInitialized && !this.dayParts.length) this.appearances = {}; - } - setSingleCounts(): void { - for (const option of this.types) this.appearances[option.value] = 1; } validCounts(): boolean { - return this.types.every( - (o) => - Number.isInteger(this.appearances[o.value]) && - this.appearances[o.value]! >= 1 && - this.appearances[o.value]! <= this.dayParts.length, + return ( + Number.isInteger(this.preferredDayParts) && + this.preferredDayParts >= 1 && + this.preferredDayParts <= 6 ); } toggleStyle(style: GroupStyle, checked: boolean): void { @@ -300,7 +267,7 @@ export class EntityEditor implements OnInit { current.value && (JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !== JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) || - JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances)) + current.value.preferredDayParts !== this.preferredDayParts) ) affected = [current.value]; const names = affected @@ -349,7 +316,7 @@ export class EntityEditor implements OnInit { { name: this.name.trim(), dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })), - appearances: this.appearances, + preferredDayParts: this.preferredDayParts, }, id, ); diff --git a/agiledancers-fe/src/app/shared/schedule-editor.spec.ts b/agiledancers-fe/src/app/shared/schedule-editor.spec.ts new file mode 100644 index 0000000..aae9a02 --- /dev/null +++ b/agiledancers-fe/src/app/shared/schedule-editor.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Show, DanceGroup } from '../core/api-models'; +import { SchoolApi } from '../core/school-api'; +import { ScheduleEditor } from './schedule-editor'; + +const groups: DanceGroup[] = [1, 2, 3].map((id) => ({ + id, + category: `Group ${id}`, + type: 'REGULAR', + startAge: 0, + endAge: 99, + styles: [], + dancerIds: [id], +})); +const show: Show = { + id: 4, + name: 'Show', + preferredDayParts: 1, + finalized: true, + violations: [], + appearances: {}, + dayParts: [ + { date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] }, + { date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] }, + ], +}; + +describe('ScheduleEditor', () => { + const api = { saveAssignments: vi.fn(), finalize: vi.fn() }; + beforeEach(() => { + vi.resetAllMocks(); + TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] }); + }); + function editor() { + const fixture = TestBed.createComponent(ScheduleEditor); + fixture.componentRef.setInput('show', show); + fixture.componentRef.setInput('groups', groups); + fixture.componentRef.setInput('schoolId', 7); + fixture.componentInstance.ngOnInit(); + return fixture.componentInstance; + } + it('edits order and dayparts without changing the saved input', () => { + const component = editor(); + component.reorder(0, 1, -1); + component.move(0, 0, 1); + component.add(0, 3); + component.remove(1, 0); + expect(component.assignments).toEqual([[1, 3], [2]]); + expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]); + expect(component.dirty()).toBe(true); + }); + it('saves invalid drafts and displays server violations without finalizing', async () => { + const component = editor(); + component.remove(0, 0); + const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] }; + api.saveAssignments.mockReturnValue(of(draft)); + await component.save(); + expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]); + expect(component.current()).toEqual(draft); + expect(component.dirty()).toBe(false); + expect(api.finalize).not.toHaveBeenCalled(); + }); + it('keeps unsaved edits when saving fails', async () => { + const component = editor(); + component.remove(0, 0); + api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + await component.save(); + expect(component.assignments).toEqual([[2], [3]]); + expect(component.dirty()).toBe(true); + expect(component.error()).toContain('niet bereikbaar'); + expect(component.busy()).toBe(false); + }); + it('finalizes through the server and reports a stale validation failure', async () => { + const component = editor(); + api.finalize.mockReturnValue(of(show)); + await component.finalize(); + expect(component.current()?.finalized).toBe(true); + api.finalize.mockReturnValue( + throwError( + () => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }), + ), + ); + await component.finalize(); + expect(component.error()).toContain('Changing gap violated'); + }); +}); diff --git a/agiledancers-fe/src/app/shared/schedule-editor.ts b/agiledancers-fe/src/app/shared/schedule-editor.ts new file mode 100644 index 0000000..a157880 --- /dev/null +++ b/agiledancers-fe/src/app/shared/schedule-editor.ts @@ -0,0 +1,178 @@ +import { Component, inject, input, OnInit, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; +import { DanceGroup, Show } from '../core/api-models'; +import { SchoolApi } from '../core/school-api'; +import { apiError } from '../core/api-error'; +import { DialogFrame } from './dialog-frame'; + +@Component({ + selector: 'app-schedule-editor', + imports: [FormsModule, DialogFrame], + template: ` + +

+ {{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · x = + {{ show().preferredDayParts }} +

+

+ Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een + onvolledige indeling als concept opslaan. +

+ @if (error()) { + + } + @if (dirty()) { +

+ Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren. +

+ } + @for (violation of current()?.violations ?? []; track $index) { +

{{ violation }}

+ } +
+ @for (part of show().dayParts; track $index; let p = $index) { +
+ + {{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens) + +
    + @for (id of assignments[p]; track $index; let i = $index) { +
  1. + {{ name(id) }} + + + + +
  2. + } +
+ +
+ } +
+
+ + + +
+
+ `, +}) +export class ScheduleEditor implements OnInit { + readonly show = input.required(); + readonly groups = input.required(); + readonly schoolId = input.required(); + readonly closed = output(); + readonly saved = output(); + readonly busy = signal(false); + readonly dirty = signal(false); + readonly error = signal(''); + readonly current = signal(null); + private readonly api = inject(SchoolApi); + assignments: number[][] = []; + + ngOnInit(): void { + this.current.set(this.show()); + this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]); + } + name(id: number): string { + return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`; + } + reorder(p: number, i: number, delta: number): void { + const list = this.assignments[p]; + [list[i], list[i + delta]] = [list[i + delta], list[i]]; + this.dirty.set(true); + } + move(p: number, i: number, target: number): void { + if (target === p || this.assignments[target].includes(this.assignments[p][i])) return; + this.assignments[target].push(this.assignments[p].splice(i, 1)[0]); + this.dirty.set(true); + } + add(p: number, id: number): void { + if (!id || this.assignments[p].includes(id)) return; + this.assignments[p].push(id); + this.dirty.set(true); + } + remove(p: number, i: number): void { + this.assignments[p].splice(i, 1); + this.dirty.set(true); + } + async save(): Promise { + await this.perform(false); + } + async finalize(): Promise { + await this.perform(true); + } + private async perform(finalize: boolean): Promise { + if (this.busy()) return; + this.busy.set(true); + this.error.set(''); + try { + const result = await firstValueFrom( + finalize + ? this.api.finalize(this.schoolId(), this.show().id) + : this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments), + ); + this.current.set(result); + this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]); + this.dirty.set(false); + this.saved.emit(); + } catch (error: unknown) { + this.error.set(apiError(error)); + } finally { + this.busy.set(false); + } + } +} diff --git a/docs/s18-validation.md b/docs/s18-validation.md new file mode 100644 index 0000000..7e65026 --- /dev/null +++ b/docs/s18-validation.md @@ -0,0 +1,70 @@ +# S18 implementation validation + +Validated on 2026-11-03 against the confirmed S18 specification. + +## Automated checks + +- JDK 25 `mvn clean verify`: passed, 52 backend tests and 19 standards-tool + regression tests, with no failures or skipped tests. +- Clean backend line coverage: 1175/1215 lines, 96.70781893004116%, above the + exact 80% gate. No handwritten-code exclusions were introduced. +- Frontend `npm test`: 24 tests across six files passed. +- Frontend production build and ESLint: passed. +- Playwright with the real REST backend: all three scenarios passed, covering school management, + shared memberships, automatic scheduling, manual draft editing and finalization, + a coach accompanying preschoolers across dates, and connection-error recovery. + +Focused solver tests cover showcase bookends (including extra showcases), single +and absent showcases, changing gaps, coach exemptions, infeasibility, the strict +priority of showcase attendance over balance, and style/age scores. Lifecycle +and HTTP tests cover skipped empty groups, first-show-date ages, retained x after +daypart removal, atomic structural validation, invalid drafts, final status +invalidation, stale solver results, timeout preservation, coach identity links, +and ordered JSON round trips including version-2 migration. + +## Manual standards assessment + +The standards report passes without new waivers. Retained review signals were +assessed against `CODING_STANDARDS.md` and `docs/backend-review.md`: + +- `GroupRequirement`, `SchedulingInput`, `Performance` and `ShowView` carry six + or seven related snapshot/API values. Collections are defensively copied; + coach identity and group membership use IDs across the solver boundary. + The larger record shapes make the scheduling contract explicit. Other added + records are small. `OrderedAssignments` deliberately owns mutable OR-Tools + variables and arrays internally during one solve; it is not an API snapshot. +- `ScheduleRules` has cyclomatic signals for independent configuration checks + and showcase cases. Each rule family is isolated in a short method and shared + between finalization and solver-result validation. +- `ShowSchedules` retains branching for nullable initial settings, retained x, + and structural draft validation. It stages complete replacement lists before + mutation and uses the existing aggregate lock. It separates schedule lifecycle + from the already broad `SchoolManagementService` aggregate owner. +- The aggregate owner's fan-out and public-method signals are retained for its + repository and transaction ownership. Scheduling-input preparation, coach + identity operations, lifecycle rules and scoring reside in separate focused + classes. Its new revision operation invalidates final status as well as pending + solver results. +- `SchoolSnapshot` retains orchestration size, fan-out and cognitive signals for + validating and reconstructing an entire graph. Import resolves coach/dancer + links explicitly, retains order, rejects unknown references, accepts invalid + assignment drafts and resets imported final status. Graph construction remains + staged before repository mutation. +- REST resource fan-out is mainly endpoint annotations, request/view types and + domain/service collaborators. OR-Tools stays inside the solver package. + `SchedulingInputs` uses domain types to produce one immutable solver snapshot. +- `OrToolsSchedulingEngine` retains a length/cognitive signal for the ordered + optimization stages and their feasible/optimal/timeout outcomes. Keeping the + incumbent and freezing only proven optima preserves strict objective priority + and valid results within one total deadline. +- Solver objective helpers retain five-parameter review signals where a model, + variables, group pair and objective coefficient are needed. Replacing those + with additional transient wrapper types would obscure the constraint mapping. + Common adjacency penalties are algebraically removed without changing scores. +- Test fan-out and scenario length signals reflect full HTTP/import/concurrency + workflows. Shared fixture setup is extracted where it aids readability; every + executable remains within the blocking limits. + +Public behavior is documented in `show-scheduling-rules.md`, the README and the +school JSON format reference. No new standards waiver, threshold change or +analysis exclusion is required. diff --git a/show-scheduling-rules.md b/show-scheduling-rules.md new file mode 100644 index 0000000..5911248 --- /dev/null +++ b/show-scheduling-rules.md @@ -0,0 +1,89 @@ +# Show scheduling rules + +A show is the complete event, including every dated daypart. It may span several +calendar dates. Counts do not restart on a new date. + +## Required performances + +The manager chooses **x**, the desired number of distinct dayparts, per show. +It defaults to two, or one for a show with only one daypart. A show can be saved +without dayparts but cannot be scheduled. If dayparts are removed, x is retained +and must be corrected before scheduling when it exceeds the remaining count. + +Every nonempty regular, selection and pre-education group performs in exactly x +dayparts, once in each. Each preschool group performs exactly once in the whole +show. Every showcase performs once in every daypart. Groups without dancers are +skipped completely, including for showcase positions and balancing. + +With at least two showcases, one opens and another closes every daypart. Any +additional showcases may appear between them. The opening and closing groups can +change between dayparts. A single showcase may open or close, but never performs +twice in the same daypart. Without showcases there is no opening/closing rule. + +## People and changing clothes + +An ordinary dancer must perform in no more than x distinct dayparts across all +of their groups. The same dancer registration must be used in every group. + +Showcase participants necessarily perform in every daypart. For their other +performances, exceeding x dayparts is allowed but discouraged. We count their +non-showcase dayparts, subtract x, and count only positive excesses. The planner's +first preference is to minimize the sum of these excesses across dancers. + +A coach registration must be explicitly linked to the corresponding dancer; +names are not used to guess identity. Coaches are available throughout the show +and are exempt from both the x limit and the excess penalty. A coach only performs +with a group if included in its dancer membership. Being a group's coach alone +never adds a performance or forces that group into extra dayparts. + +Between any two performances involving the same person, at least two other +performances must intervene in which that person does not dance. This includes +coaches accompanying preschoolers, coaches dancing in other groups, and showcases. +There are no exceptions. Between dayparts we assume sufficient changing time, +so the gap rule restarts even if the dayparts are on the same date. + +## Balance and preferences + +All performances count equally, including preschool groups and showcases. The +busiest daypart may have at most two more performances than the quietest. Zero +is ideal. Preschool groups can cause a difference but have no special exemption. + +Among schedules satisfying every mandatory rule, optimize in this strict order: + +1. Minimize non-showcase daypart excess for showcase participants, excluding coaches. +2. Minimize the difference in performance counts between dayparts. +3. Minimize similarity of consecutive performances within each daypart. + +A lower priority cannot worsen a higher priority. OR-Tools solves these objectives +in sequence, fixing each proven optimum before working on the next. If it finds +only a feasible result at a priority, it returns that valid result without +claiming that the complete schedule is optimal. + +For variety, style similarity is the fraction of shared styles among all distinct +styles of the two groups. Two groups without styles have no style penalty. +Age similarity is one for equal average ages, falling linearly to zero at a +five-year difference and remaining zero above that difference. Style and age +have equal weight. Each component is rounded to millionths for CP-SAT's integer +objective. Showcases count in this comparison. Average age includes every dancing +member equally, including coaches, measured in completed years on the first date +of the show. Empty groups are already excluded. + +## Results, drafts and final schedules + +Scheduling can take up to thirty minutes in total across the optimization stages. +A feasible result is accepted even when optimality is unproven. Every mandatory +rule still applies. Proven infeasibility and reaching the time limit without a +result are reported differently. Failures preserve the saved assignments and +order. Concurrent runs are rejected and changed inputs invalidate pending results. + +Automatic results are drafts for manager review. Managers can also move, add, +remove and reorder performances manually and save incomplete or invalid drafts. +The application lists the rules those drafts violate. An explicit finalization +action checks all mandatory rules; only a valid schedule can become final. +Preferences alone never block finalization. + +Manual edits and relevant membership, dancer-age, coach-link or show-setting +changes return final schedules to draft. Existing schedules and imported schedules +are drafts. Import retains assignment order and recalculates violations. Export +is available for both statuses and records status, x and coach identity links; +reimport always requires a fresh finalization. diff --git a/specs/School-JSON-format.md b/specs/School-JSON-format.md index b47d9ce..b7d90ec 100644 --- a/specs/School-JSON-format.md +++ b/specs/School-JSON-format.md @@ -1,4 +1,4 @@ -# School JSON format, version 1 +# School JSON format, version 3 Export: `GET /api/dance-schools/{id}/export` downloads readable JSON. Preview: `POST /api/dance-schools/import/preview` accepts the file itself as @@ -11,19 +11,19 @@ There is no merge endpoint. ```json { - "formatVersion": 1, + "formatVersion": 3, "key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee", "name": "Studio", "dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }], - "coaches": [{ "id": "c1", "name": "Alex" }], + "coaches": [{ "id": "c1", "name": "Alex", "dancerId": "d1" }], "groups": [{ "id": "g1", "category": "Hiphop", "type": "REGULAR", "startAge": 12, "endAge": 18, "styles": ["HIPHOP"], "dancerIds": ["d1"], "coachId": "c1" }], "shows": [{ - "id": "s1", "name": "Summer show", "date": "2027-06-12", - "dayParts": [{ "type": "MORNING", "groupIds": ["g1"] }] + "id": "s1", "name": "Summer show", "preferredDayParts": 1, "finalized": false, + "dayParts": [{ "date": "2027-06-12", "type": "MORNING", "groupIds": ["g1"] }] }] } ``` @@ -32,14 +32,20 @@ There is no merge endpoint. inside each collection are nonblank strings, unique within that collection, and local to this file. They can change on export. References must resolve within the file. A dancer or coach can be referenced by multiple groups. A group can appear -in multiple day parts. A day-part type can occur once per show. +in multiple day parts. A date/type combination can occur once per show, with at most six dayparts. -All fields are required except `coachId`, which may be absent or null. Collections +The optional `coachId` and coach `dancerId` references may be absent or null. +`preferredDayParts` defaults to two (one for a single daypart); `finalized` +defaults to false. Legacy version 2 files remain supported; version 1 is rejected. +Legacy `appearances` counts may be present but do not govern S18 scheduling. +Every imported schedule becomes a draft, even when exported as final. +`preferredDayParts` must be an integer from one to six; a retained value exceeding +the daypart count must be corrected before scheduling or finalization. Collections may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`; birth dates cannot be in the future. Ages are nonnegative integers with `startAge <= endAge`. Enum values use the same uppercase names as the management API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`. -Unknown fields, duplicate JSON properties, duplicate IDs or references, invalid +Unknown fields, duplicate JSON properties, duplicate IDs or membership references, invalid values and unsupported versions are rejected. Validation is repeated on commit. The entire school is prepared before publishing it under the shared service lock. Replacing retains the numeric school ID for existing browser links; imported diff --git a/specs/in_review/S18_new-rules-for-plotting-dance-groups-in-show.md b/specs/in_review/S18_new-rules-for-plotting-dance-groups-in-show.md new file mode 100644 index 0000000..c447d2a --- /dev/null +++ b/specs/in_review/S18_new-rules-for-plotting-dance-groups-in-show.md @@ -0,0 +1,145 @@ +# S18 — Scheduling dance groups across a show + +Status: IN_REVIEW + +As a dance school manager, +I want OR-Tools to schedule groups and order performances using participation, +changing-time and variety rules, +so that the complete show is manageable for dancers and enjoyable for audiences. + +Refinement confirmed by the maintainer on 2026-11-03. This specification records +the agreed design. Implementation and validation are complete and ready for review. +See [validation evidence](../../docs/s18-validation.md) and +[the applied scheduling rules](../../show-scheduling-rules.md). + +## Show scope and configuration + +- A show consists of dayparts and can span multiple dates, typically two days. + All counts below apply across the entire show, not separately per date. +- Each show has one configurable integer `x`, between 1 and the number of + dayparts inclusive. Its default is 2, or 1 for a one-daypart show. +- This replaces the existing configurable appearance counts per group type. +- A show without dayparts can be stored but cannot be scheduled. +- If reducing the number of dayparts makes `x` invalid, require the user to + correct it before scheduling again; do not silently change it. +- Groups without dancers are excluded from scheduling and from all performance + counts and showcase-position requirements. + +## Required group appearances + +- Each nonempty REGULAR, SELECTION and PRE_EDUCATION group performs in exactly + `x` distinct dayparts, once within each selected daypart. +- Each nonempty PRE_SCHOOLER group performs exactly once in the complete show, + regardless of `x`. At most two such groups are expected. They can be assigned + freely to dayparts; no additional placement restriction applies. +- Each nonempty SHOW_CASE group performs exactly once in every daypart. +- With at least two showcase groups, each daypart starts with one showcase and + ends with another. Additional showcases can appear anywhere between them. + Opening and closing groups may differ between dayparts. +- With exactly one showcase group, it appears either first or last in each + daypart; the planner chooses. It does not perform twice in that daypart. +- With no showcase groups, there is no showcase-position requirement. + +## Dancer participation and coaches + +- A dancer without a showcase membership performs in at most `x` distinct + dayparts across the complete show. This is a hard constraint. +- A showcase participant necessarily performs in all dayparts. Their other + group performances may span more than `x` dayparts, but this is undesirable. +- For each non-coach showcase participant, count the distinct dayparts in which + they perform outside showcases. Their excess is `max(0, count - x)`. + Minimize the sum of these excesses as the first optimization priority. +- A person dancing in several groups must use the same dancer registration in + every group, including when accompanying preschoolers. Up to five group + memberships per dancer are expected. +- Explicitly link a coach registration to the corresponding dancer registration; + never infer identity from matching names. Support maintaining this link. +- Coaches are available in all dayparts and are excluded from both the hard + dancer limit and the excess penalty, including coaches in showcases. +- Being assigned as a group's coach does not automatically mean performing in + that group. Actual performances are determined by dancer membership only. + Coach availability never increases a group's required appearances. +- Dancing coaches remain subject to changing-time rules and contribute to the + group's average age just like other dancing members. + +## Changing time and feasibility + +- Between any two performances involving the same dancer within a daypart, + there must be at least two intervening performances not involving that dancer. +- This is mandatory for everyone, including coaches and showcase participants. +- The rule restarts at each daypart boundary. Assume enough changing time + between dayparts, including on the same date. +- All hard constraints apply together, even for unusual membership combinations + such as a person dancing in both preschool and ordinary groups. Do not relax + a rule to obtain a result. + +## Balance and optimization priorities + +- Count all scheduled performances, including preschool and showcase groups. +- The difference between the largest and smallest daypart performance counts + must not exceed two. This is a hard constraint. +- Prefer a difference of zero. Preschool groups are an expected cause of + differences but have no special exemption or exclusive right to cause them. +- Optimize valid schedules in this strict priority order: + 1. Minimize the total excess for non-coach showcase participants defined above. + 2. Minimize the difference between largest and smallest daypart counts. + 3. Improve style and age variety between consecutive performances within + each daypart. +- A lower priority must not worsen a higher priority. Showcases participate in + variety scoring as well. + +## Variety scoring + +- For adjacent groups, the style penalty is the number of shared styles divided + by the number of distinct styles in their combined style sets. No shared + styles gives zero penalty; two empty style sets also give zero penalty. +- Compute dancer ages on the first date of the show. All dancers in a group, + including dancing coaches, contribute equally to its average age. +- The age penalty decreases linearly from 1 for equal average ages to 0 for a + difference of five years; differences greater than five years also score 0. + Equivalently: `max(0, 1 - abs(meanAgeA - meanAgeB) / 5)`. +- Style and age penalties have equal weight. Minimize their combined penalties + over adjacent performances. Empty groups have already been excluded. + +## Solver execution and failure + +- Retain a maximum solver runtime of thirty minutes. +- Accept a feasible result without proof of optimality, provided every hard + constraint holds. Distinguish feasibility from proven optimality. +- If no valid result is found, preserve the existing assignments and order. +- Explain whether infeasibility was proven or the time limit expired without + a valid result; do not report a timeout as proof of infeasibility. +- Retain existing protections against concurrent scheduling and applying stale + results after relevant data changes. + +## Manual editing and schedule lifecycle + +- Add UI and API support for moving performances between dayparts and changing + their order. Partial or temporarily invalid assignments can be saved as drafts. +- Distinguish draft and final schedules explicitly. Display concrete violations + for invalid drafts; do not present them as valid final schedules. +- Add an explicit finalization action that checks all hard rules and permits + finalization only when they all pass. Optimization preferences do not block it. +- Automatically generated valid schedules also start as drafts for manager review. +- Manual changes to a final schedule return it to draft. Changes to relevant + dancer memberships, coach identity links or show settings likewise invalidate + final status when they affect scheduling rules. +- Treat existing schedules as drafts when introducing this lifecycle. +- JSON import preserves assignments and order but makes imported schedules + drafts and validates them against the new rules. +- JSON export remains available for drafts and finals and preserves status. + Import still produces a draft even when the exported status was final. +- Include the new configuration and coach identity information in persistence + and school JSON round trips so scheduling semantics survive saving and import. + +## Documentation and validation + +- Provide `show-scheduling-rules.md` describing the rules applied by OR-Tools in + plain English, including hard constraints, exceptions, optimization priorities, + scoring, cross-date scope and failure behavior. +- Validate automatic results and finalization using the same agreed rules. +- Verification must cover ordinary, preschool and showcase appearances; coach + identity and exemptions; overlapping memberships; changing gaps; balance; + optimization priority and scoring; multi-date shows; empty groups; invalid + configuration; infeasible and timed-out runs; manual drafts/finalization; and + persistence/import/export of the new information.