S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s

This commit is contained in:
2026-10-30 15:00:45 +01:00
parent f64aa9123d
commit 1b4c7f7f2f
43 changed files with 1042 additions and 118 deletions

View File

@@ -1,6 +1,8 @@
# Project skills and specifications # Project skills and specifications
- Project skills are stored in `skills/`; specifications belong in `specs/`. - Project skills are stored in `skills/`; specifications belong in `specs/`.
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
dedicated Skill tool is available, load the file directly.
- Use `grill-me` only when the user explicitly requests it, for example - Use `grill-me` only when the user explicitly requests it, for example
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`. `Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
- Before using it, read `skills/grill-me/SKILL.md` and its dependency - Before using it, read `skills/grill-me/SKILL.md` and its dependency

View File

@@ -117,7 +117,7 @@ Request bodies (dates use ISO `yyyy-MM-dd`):
``` ```
```json ```json
{"name": "Annual show", "date": "2027-06-12"} {"name": "Annual show", "dayParts": [{"date": "2027-06-12", "type": "AFTERNOON"}]}
``` ```
Names and categories must be nonblank. Birth dates cannot be in the future. Names and categories must be nonblank. Birth dates cannot be in the future.
@@ -138,9 +138,25 @@ Both operations return `204` and are idempotent for existing groups and dancers.
Deleting a dancer removes all their memberships; deleting a group retains the Deleting a dancer removes all their memberships; deleting a group retains the
dancers; deleting a school also deletes its dancers, groups and shows. dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, a date and three automatically created day parts: `MORNING`, Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning with unique date/type combinations. The displayed date range comes from these
groups is not exposed yet. Multiple shows per year are allowed. 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.
`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.
School JSON exports use format version **2**, including dated dayparts, assignments,
and all appearance counts. Version 1 files are intentionally unsupported.
Run the HTTP integration tests with: Run the HTTP integration tests with:

View File

@@ -4,6 +4,7 @@ import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DayPartType; import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List; import java.util.List;
import java.time.LocalDate;
@Getter @Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,7 +13,9 @@ import java.util.List;
public class DayPart extends AbstractEntity { public class DayPart extends AbstractEntity {
@ToString.Include @ToString.Include
private final DayPartType type; private final DayPartType type;
private final LocalDate date;
@ToString.Include @ToString.Include
@Setter
private List<DanceGroup> danceGroups; private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) { public void removeDanceGroup(DanceGroup group) {

View File

@@ -3,7 +3,8 @@ package nl.agilecc.agiledancers.model;
import lombok.*; import lombok.*;
import java.util.List; import java.util.List;
import java.time.LocalDate; import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter @Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -13,7 +14,8 @@ public class Show extends AbstractEntity {
@Setter @Setter
private String name; private String name;
@Setter @Setter
private LocalDate date; private Map<DanceGroupType, Integer> appearances;
@ToString.Include @ToString.Include
private final List<DayPart> dayParts; @Setter
private List<DayPart> dayParts;
} }

View File

@@ -1,18 +1,5 @@
package nl.agilecc.agiledancers.model.specifications; package nl.agilecc.agiledancers.model.specifications;
import lombok.Getter;
public enum DanceGroupType { public enum DanceGroupType {
SHOW_CASE(3), SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3
SELECTION(2),
REGULAR(2),
PRE_SCHOOLER(1);
@Getter
private int dayPartsRequired;
DanceGroupType(int dayPartsRequired) {
this.dayPartsRequired = dayPartsRequired;
}
} }

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class JacksonConfiguration implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper mapper) {
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}

View File

@@ -14,6 +14,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
int status = switch (exception.getKind()) { int status = switch (exception.getKind()) {
case NOT_FOUND -> 404; case NOT_FOUND -> 404;
case INVALID_INPUT -> 400; case INVALID_INPUT -> 400;
case CONFLICT -> 409;
}; };
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE) return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build(); .entity(new ErrorView(exception.getMessage())).build();

View File

@@ -6,6 +6,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*; import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import java.util.List; import java.util.List;
@@ -19,6 +22,9 @@ public class ShowResource {
@Inject @Inject
SchoolManagementService service; SchoolManagementService service;
@Inject
SchedulingService scheduling;
@PathParam("schoolId") @PathParam("schoolId")
long schoolId; long schoolId;
@@ -35,7 +41,7 @@ public class ShowResource {
@POST @POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) { public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), request.date()); var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(ShowView.of(entity)).build(); .entity(ShowView.of(entity)).build();
} }
@@ -43,7 +49,21 @@ public class ShowResource {
@PUT @PUT
@Path("/{id}") @Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) { public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date())); return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
}
private static List<DayPart> parts(ShowRequest request) {
return request.dayParts().stream().map(p -> DayPart.builder()
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList();
}
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(@PathParam("id") long id) {
var result = scheduling.schedule(schoolId, id);
return new SchedulingView(result.optimal(), result.optimal()
? "Optimal balance" : "Valid result; optimal balance not proven");
} }
@DELETE @DELETE

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import jakarta.validation.constraints.NotNull;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}

View File

@@ -1,6 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.specifications.DayPartType; import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List; import java.util.List;
import java.time.LocalDate;
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {} public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}

View File

@@ -0,0 +1,2 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record SchedulingView(boolean optimal, String message) {}

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.*;
import jakarta.validation.constraints.NotNull; import java.util.*;
import java.time.LocalDate; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {} Map<DanceGroupType, Integer> appearances) {}

View File

@@ -1,14 +1,10 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.Show; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.DanceGroup; import java.util.*;
import java.time.LocalDate; public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
import java.util.List;
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
public static ShowView of(Show show) { public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream() return new ShowView(show.getId(), show.getName(), show.getDayParts().stream()
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream() .map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
.map(DanceGroup::getId).toList())).toList());
} }
} }

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException { public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT } public enum Kind { NOT_FOUND, INVALID_INPUT, CONFLICT }
private final Kind kind; private final Kind kind;

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.service;
public interface SchedulingEngine {
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
}

View File

@@ -0,0 +1,19 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class SchedulingService {
@Inject SchoolManagementService schools;
@Inject SchedulingEngine engine;
public SchoolManagementService.SchedulingResult schedule(long schoolId, long showId) {
var input = schools.beginScheduling(schoolId, showId);
try {
var result = engine.solve(input);
schools.finishScheduling(schoolId, input, result);
return result;
} catch (ManagementException e) { throw e; }
catch (RuntimeException | LinkageError e) {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally { schools.endScheduling(showId); }
}
}

View File

@@ -18,6 +18,52 @@ public class SchoolManagementService {
private final DanceGroupRepository groups = new DanceGroupRepository(); private final DanceGroupRepository groups = new DanceGroupRepository();
private final ShowRepository shows = new ShowRepository(); private final ShowRepository shows = new ShowRepository();
private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>();
private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); }
public record SchedulingInput(long showId, long revision, int dayParts, List<Long> groupIds, List<Integer> required) {}
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
Show show = show(schoolId, showId);
if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
List<DanceGroup> current = groups(schoolId);
if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
running.add(showId);
return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(),
current.stream().map(DanceGroup::getId).toList(), current.stream().map(g -> show.getAppearances().get(g.getType())).toList());
}
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
Show show;
try { show = show(schoolId, input.showId()); }
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
// Validate the complete solver output before mutating any assignments.
if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts())
throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach(g -> current.put(g.getId(), g));
for (var part : result.assignments()) {
if (part == null || new HashSet<>(part).size() != part.size() || !input.groupIds().containsAll(part))
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
}
for (int g = 0; g < input.groupIds().size(); g++) {
long id = input.groupIds().get(g);
if (result.assignments().stream().filter(p -> p.contains(id)).count() != input.required().get(g))
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
}
List<DayPart> replacement = new ArrayList<>();
for (int p = 0; p < input.dayParts(); p++) {
DayPart old = show.getDayParts().get(p);
replacement.add(DayPart.builder().date(old.getDate()).type(old.getType())
.danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build());
}
show.setDayParts(List.copyOf(replacement));
}
public synchronized void endScheduling(long showId) { running.remove(showId); }
public synchronized List<DanceSchool> schools() { public synchronized List<DanceSchool> schools() {
return schools.findAll(); return schools.findAll();
} }
@@ -89,6 +135,7 @@ public class SchoolManagementService {
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type) DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build()); .startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
school.addDanceGroup(group); school.addDanceGroup(group);
school.getShows().forEach(this::touch);
return group; return group;
} }
@@ -97,6 +144,7 @@ public class SchoolManagementService {
DanceGroup group = group(schoolId, groupId); DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge); validateAgeRange(startAge, endAge);
group.setCategory(category); group.setCategory(category);
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
group.setType(type); group.setType(type);
group.setStartAge(startAge); group.setStartAge(startAge);
group.setEndAge(endAge); group.setEndAge(endAge);
@@ -107,6 +155,7 @@ public class SchoolManagementService {
public synchronized void deleteGroup(long schoolId, long groupId) { public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId); DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group))); school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
school(schoolId).getShows().forEach(this::touch);
school(schoolId).removeDanceGroup(group); school(schoolId).removeDanceGroup(group);
groups.delete(groupId); groups.delete(groupId);
} }
@@ -129,22 +178,34 @@ public class SchoolManagementService {
return member(school(schoolId).getShows(), showId, "Show"); return member(school(schoolId).getShows(), showId, "Show");
} }
public synchronized Show createShow(long schoolId, String name, LocalDate date) { public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
DanceSchool school = school(schoolId); DanceSchool school = school(schoolId);
List<DayPart> dayParts = Arrays.stream(DayPartType.values()) if (counts == null) counts = ShowConfiguration.defaults(parts.size());
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList(); ShowConfiguration.validate(parts, counts);
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build()); Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
school.addShow(show); school.addShow(show);
return show; return show;
} }
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) { public synchronized Show updateShow(long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
Show show = show(schoolId, showId); Show show = show(schoolId, showId);
if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
ShowConfiguration.validate(parts, counts);
boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
List<DayPart> retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType())
.danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList();
show.setName(name); show.setName(name);
show.setDate(date); show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
if (changed) touch(show);
return show; return show;
} }
private static List<String> partKeys(List<DayPart> parts) {
return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList();
}
public synchronized void deleteShow(long schoolId, long showId) { public synchronized void deleteShow(long schoolId, long showId) {
Show show = show(schoolId, showId); Show show = show(schoolId, showId);
school(schoolId).removeShow(show); school(schoolId).removeShow(show);
@@ -191,8 +252,8 @@ public class SchoolManagementService {
} }
List<Show> rebuiltShows = new ArrayList<>(); List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) { for (Show show : importedShows) {
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream() Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build(); .map(p -> DayPart.builder().date(p.getDate()).type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt); rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
} }
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName()) DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())

View File

@@ -5,31 +5,31 @@ import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
/** Version 1 file format. IDs are local to the file, never server identifiers. */ /** Version 2 file format. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers, public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) { List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record Person(String id, String name, LocalDate birthDate) {} public record Person(String id, String name, LocalDate birthDate) {}
public record Trainer(String id, String name) {} public record Trainer(String id, String name) {}
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge, public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {} List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(DayPartType type, List<String> groupIds) {} public record Part(LocalDate date, DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {} public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}
public static SchoolSnapshot of(DanceSchool s) { public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers()); Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches()); Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups()); Map<DanceGroup, String> groups = ids(s.getDanceGroups());
Map<Show, String> shows = ids(s.getShows()); Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(1, s.getKey(), s.getName(), return new SchoolSnapshot(2, s.getKey(), s.getName(),
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(), s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(), s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(), s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()), g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
g.getDancers().stream().map(dancers::get).toList(), g.getDancers().stream().map(dancers::get).toList(),
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(), g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(), s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(),
show.getDayParts().stream().map(p -> new Part(p.getType(), show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList()); p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
} }
private static <T> Map<T, String> ids(Collection<T> values) { private static <T> Map<T, String> ids(Collection<T> values) {
@@ -39,7 +39,7 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
} }
public DanceSchool restore() { public DanceSchool restore() {
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion); check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt"); text(name, "schoolnaam"); check(key != null, "key ontbreekt"); text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build(); DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>(); Map<String, Dancer> dancerMap = new LinkedHashMap<>();
@@ -73,14 +73,14 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
for (Performance show : list(shows, "shows")) { for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id, "show-id"); check(show != null, "Lege show"); text(show.id, "show-id");
check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam"); check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam");
check(show.date != null, "Showdatum ontbreekt voor " + show.name); List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) { for (Part part : list(show.dayParts, "dayParts")) {
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name); check(part != null && part.type != null && part.date != null && types.add(part.date + ":" + part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream() parts.add(DayPart.builder().date(part.date).type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build()); .map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
} }
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).build(); ShowConfiguration.validate(parts, show.appearances);
Show entity = Show.builder().name(show.name).dayParts(parts).appearances(Map.copyOf(show.appearances)).build();
entity.setId((long) showIds.size()); school.addShow(entity); entity.setId((long) showIds.size()); school.addShow(entity);
} }
return school; return school;

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public final class ShowConfiguration {
private ShowConfiguration() {}
public static Map<DanceGroupType, Integer> defaults(int n) {
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n;
default -> Math.min(2, n);
});
return counts;
}
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size())
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations.");
if (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
}
private static void invalidCounts() {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
}
}

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.service.*;
import java.util.*;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override
public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) {
Loader.loadNativeLibraries();
CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groupIds().size();
BoolVar[][] assigned = new BoolVar[groups][n];
for (int g = 0; g < groups; g++) {
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
model.addEquality(LinearExpr.sum(assigned[g]), input.required().get(g));
}
IntVar[] loads = new IntVar[n];
for (int p = 0; p < n; p++) {
BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) column[g] = assigned[g][p];
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
IntVar max = model.newIntVar(0, groups, "max"), 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}));
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);
CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < n; p++) {
List<Long> ids = new ArrayList<>();
for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groupIds().get(g));
parts.add(List.copyOf(ids));
}
return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
}
}

View File

@@ -1,2 +1,6 @@
quarkus.application.name=agiledancers quarkus.application.name=agiledancers
quarkus.http.host=0.0.0.0 quarkus.http.host=0.0.0.0
# Scheduling is synchronous with a 30-minute solver limit.
quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true

View File

@@ -0,0 +1,109 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) {
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList();
}
private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of());
}
private void run(Show show) {
var input = service.beginScheduling(school, show.getId());
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); }
finally { service.endScheduling(show.getId()); }
}
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER);
var show = service.createShow(school, "Show", parts(6), null);
run(show);
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax());
for (var type : DanceGroupType.values()) group(type);
run(show);
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count());
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
}
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
var 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(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
assertEquals(4, show.getDayParts().size());
service.updateShow(school, show.getId(), "Show", List.of(), null);
assertEquals(counts, show.getAppearances());
service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances());
}
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
}
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); run(show);
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
var input = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
var result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of());
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh,
new SchoolManagementService.SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchoolManagementService.SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId());
var result = new OrToolsSchedulingEngine().solve(input);
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.deleteShow(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId());
}
@Test void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);
var empty = service.createShow(school, "Empty", List.of(), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId()));
assertTrue(show.getAppearances().values().stream().allMatch(n -> n == 1));
}
}

View File

@@ -15,7 +15,7 @@ class SchoolManagementResourceTest {
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12"); private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR", private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"}); "startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12"); private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test @Test
void managesSchoolAndItsResources() { void managesSchoolAndItsResources() {
@@ -46,10 +46,10 @@ class SchoolManagementResourceTest {
long showId = create(school + "/shows", SHOW); long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId; String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200) given().get(show).then().statusCode(200)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING")) .body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty())); .body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01")) put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
.statusCode(200).body("date", equalTo("2027-07-01")); .statusCode(200).body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId)); given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId)) given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId)); .body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
@@ -152,6 +152,29 @@ class SchoolManagementResourceTest {
} }
} }
@Test
void schedulesAndValidatesTheNewShowConfiguration() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
try {
String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400);
long group = create(school + "/dance-groups", GROUP);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
String before = given().get(show).asString();
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (String count : new String[]{"0", "2", "1.5", "null"}) {
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}";
put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before));
}
String exported = given().get(school + "/export").then().statusCode(200).extract().asString();
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then().statusCode(200);
given().get(school + "/export").then().body(equalTo(exported));
} finally { given().delete(school); }
}
private static long create(String path, Object body) { private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then() return given().contentType("application/json").body(body).post(path).then()
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$")) .statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))

View File

@@ -12,11 +12,11 @@ class SchoolSnapshotResourceTest {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE) long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
.then().statusCode(201).extract().jsonPath().getLong("id"); .then().statusCode(201).extract().jsonPath().getLong("id");
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200) String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString(); .header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString();
try { try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200) given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0)); .body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"), for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}", json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) { json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id) given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)

View File

@@ -9,12 +9,12 @@ import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest { class SchoolSnapshotTest {
private SchoolSnapshot complete() { private SchoolSnapshot complete() {
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))), return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new SchoolSnapshot.Trainer("c", "Alex")), List.of(new SchoolSnapshot.Trainer("c", "Alex")),
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1), List.of(new SchoolSnapshot.Performance("s", "Show",
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h")))))); List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
} }
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete(); var snapshot = complete();
@@ -56,7 +56,7 @@ class SchoolSnapshotTest {
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() { @Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete(); var service = new SchoolManagementService(); var valid = complete();
var original = service.importSchool(valid, null); var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows()); var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
assertThrows(ManagementException.class, () -> service.preview(bad)); assertThrows(ManagementException.class, () -> service.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId())); assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId())); assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));

View File

@@ -0,0 +1,67 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); },
input -> { throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "No valid result before the limit"); },
input -> null)) {
scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
assertEquals(before, schools.exportSchool(school));
var next = schools.beginScheduling(school, show.getId());
schools.endScheduling(next.showId());
}
}
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var scheduling = new SchedulingService(); scheduling.schools = schools;
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
scheduling.engine = input -> {
entered.countDown();
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
return new OrToolsSchedulingEngine().solve(input);
};
try (var executor = Executors.newSingleThreadExecutor()) {
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
try {
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(ManagementException.Kind.CONFLICT, assertThrows(ManagementException.class,
() -> scheduling.schedule(school, show.getId())).getKind());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); }
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
}
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
}
}

View File

@@ -110,15 +110,28 @@ test('manages a school, shared memberships and shows through the real REST API',
await tab(page, 'Shows'); await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-06-12'); await page.getByLabel('Datum', { exact: true }).fill('2027-06-12');
await save(page); await save(page);
await expect(page.getByText('Ochtend', { exact: true })).toBeVisible(); await expect(page.getByText('2027-06-12 · AFTERNOON:', { exact: false })).toBeVisible();
await expect(page.getByText('Middag', { exact: true })).toBeVisible();
await expect(page.getByText('Avond', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click(); await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-07-01'); await page.getByLabel('Datum', { exact: true }).fill('2027-07-01');
await save(page); await save(page);
await expect(page.locator('.date-tile')).toContainText('jul 2027'); 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 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');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('Zomershow');
await dialog.dismiss();
});
await page.getByRole('button', { name: 'Opslaan', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Annuleren', exact: true }).click();
await tab(page, 'Overzicht'); await tab(page, 'Overzicht');
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });

View File

@@ -5,6 +5,8 @@ server {
index index.html; index index.html;
location /api/ { location /api/ {
proxy_read_timeout 1860s;
proxy_send_timeout 1860s;
proxy_pass http://${BACKEND_UPSTREAM}; proxy_pass http://${BACKEND_UPSTREAM};
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@@ -2,6 +2,8 @@
"/api/**": { "/api/**": {
"target": "http://127.0.0.1:8080", "target": "http://127.0.0.1:8080",
"secure": false, "secure": false,
"timeout": 1860000,
"proxyTimeout": 1860000,
"changeOrigin": true "changeOrigin": true
} }
} }

View File

@@ -21,11 +21,17 @@ export interface DanceGroup {
styles: GroupStyle[]; styles: GroupStyle[];
dancerIds: number[]; dancerIds: number[];
} }
export type DayPartType = 'MORNING' | 'AFTERNOON' | 'EVENING';
export interface DayPart {
date: string;
type: DayPartType;
danceGroupIds: number[];
}
export interface Show { export interface Show {
id: number; id: number;
name: string; name: string;
date: string; dayParts: DayPart[];
dayParts: { type: 'MORNING' | 'AFTERNOON' | 'EVENING'; danceGroupIds: number[] }[]; appearances: Partial<Record<GroupType, number>>;
} }
export interface SchoolRequest { export interface SchoolRequest {
name: string; name: string;
@@ -43,7 +49,8 @@ export interface GroupRequest {
} }
export interface ShowRequest { export interface ShowRequest {
name: string; name: string;
date: string; dayParts: { date: string; type: DayPartType }[];
appearances?: Partial<Record<GroupType, number>>;
} }
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show'; export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
export type EditorTarget = export type EditorTarget =

View File

@@ -66,4 +66,11 @@ describe('SchoolApi contract', () => {
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' }); .flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
expect(failure).toHaveBeenCalledOnce(); expect(failure).toHaveBeenCalledOnce();
}); });
it('schedules synchronously with room for the 30-minute solver limit', () => {
api.schedule(7, 3).subscribe();
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
expect(request.request.method).toBe('POST');
expect(request.request.timeout).toBe(1860000);
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
});
}); });

View File

@@ -45,6 +45,13 @@ export class SchoolApi {
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> { saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id); return this.save(`${this.base}/${schoolId}/shows`, body, id);
} }
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> {
return this.http.post<{ optimal: boolean; message: string }>(
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
{ timeout: 1860000 },
);
}
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> { delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind kind

View File

@@ -114,7 +114,7 @@
<p class="eyebrow">IN DE SPOTLIGHT</p> <p class="eyebrow">IN DE SPOTLIGHT</p>
@if (upcoming(); as show) { @if (upcoming(); as show) {
<h2>{{ show.name }}</h2> <h2>{{ show.name }}</h2>
<p class="spotlight-date">{{ show.date | date: 'd MMMM yyyy' }}</p> <p class="spotlight-date">{{ dateRange(show) }}</p>
<p>Drie dagdelen. Eén gedeelde passie.</p> <p>Drie dagdelen. Eén gedeelde passie.</p>
<a class="button light" [routerLink]="['/schools', context.school.id, 'shows']" <a class="button light" [routerLink]="['/schools', context.school.id, 'shows']"
>Shows bekijken <span aria-hidden="true">→</span></a >Shows bekijken <span aria-hidden="true">→</span></a
@@ -345,16 +345,39 @@
@for (show of shows(); track show.id) { @for (show of shows(); track show.id) {
<article class="show-card"> <article class="show-card">
<div class="date-tile"> <div class="date-tile">
<strong>{{ show.date | date: 'dd' }}</strong <strong>{{ firstDate(show) | date: 'dd' }}</strong
><span>{{ show.date | date: 'MMM yyyy' }}</span> ><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
</div> </div>
<div class="show-details"> <div class="show-details">
<h3>{{ show.name }}</h3> <h3>{{ show.name }}</h3>
<div class="tag-list"> <p>{{ dateRange(show) }}</p>
<span class="style-tag">Ochtend</span><span class="style-tag">Middag</span @for (part of show.dayParts; track $index) {
><span class="style-tag">Avond</span> <p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
</div> }
<p class="muted">Groepen indelen over dagdelen is nog niet beschikbaar.</p> <p role="status">
{{
scheduleValid(show)
? 'Planning voldoet aan alle optredensaantallen.'
: 'Geen geldige planning. Plan de groepen opnieuw in.'
}}
</p>
<button
type="button"
class="button primary"
[disabled]="
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
"
(click)="schedule(show)"
>
{{
scheduling().includes(show.id)
? 'Bezig met inplannen…'
: 'Schedule groups — vervangt huidige toewijzingen'
}}
</button>
@if (!context.groups.length || !show.dayParts.length) {
<p>Inplannen vereist minstens één dansgroep en één dagdeel.</p>
}
</div> </div>
<div class="row-actions"> <div class="row-actions">
<button <button
@@ -385,7 +408,7 @@
<div class="empty-state"> <div class="empty-state">
<span class="empty-symbol" aria-hidden="true">✦</span> <span class="empty-symbol" aria-hidden="true">✦</span>
<h2>Het podium wacht op je</h2> <h2>Het podium wacht op je</h2>
<p>Plan een show met een ochtend-, middag- en avonddeel.</p> <p>Plan een show met maximaal zes gedateerde dagdelen.</p>
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })"> <button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
Eerste show plannen Eerste show plannen
</button> </button>
@@ -398,6 +421,7 @@
<app-entity-editor <app-entity-editor
[target]="target" [target]="target"
[schoolId]="context.school.id" [schoolId]="context.school.id"
[shows]="context.shows"
(closed)="editor.set(null)" (closed)="editor.set(null)"
(saved)="saved()" (saved)="saved()"
/> />

View File

@@ -11,6 +11,7 @@ import {
GROUP_TYPES, GROUP_TYPES,
ResourceKind, ResourceKind,
SchoolData, SchoolData,
Show,
} from '../core/api-models'; } from '../core/api-models';
import { apiError } from '../core/api-error'; import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
@@ -51,6 +52,7 @@ export class SchoolPage implements OnInit {
readonly editor = signal<EditorTarget | null>(null); readonly editor = signal<EditorTarget | null>(null);
readonly members = signal<DanceGroup | null>(null); readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null); readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]);
readonly deleteBusy = signal(false); readonly deleteBusy = signal(false);
readonly deleteError = signal(''); readonly deleteError = signal('');
readonly search = signal(''); readonly search = signal('');
@@ -60,10 +62,14 @@ export class SchoolPage implements OnInit {
.sort((a, b) => a.name.localeCompare(b.name, 'nl')), .sort((a, b) => a.name.localeCompare(b.name, 'nl')),
); );
readonly shows = computed(() => readonly shows = computed(() =>
[...(this.data()?.shows ?? [])].sort((a, b) => a.date.localeCompare(b.date)), [...(this.data()?.shows ?? [])].sort((a, b) =>
this.firstDate(a).localeCompare(this.firstDate(b)),
),
); );
readonly today = new Date().toLocaleDateString('en-CA'); readonly today = new Date().toLocaleDateString('en-CA');
readonly upcoming = computed(() => this.shows().find((show) => show.date >= this.today)); readonly upcoming = computed(() =>
this.shows().find((show) => this.lastDate(show) >= this.today),
);
readonly tabs = [ readonly tabs = [
{ value: 'overview', label: 'Overzicht' }, { value: 'overview', label: 'Overzicht' },
{ value: 'dancers', label: 'Dansers' }, { value: 'dancers', label: 'Dansers' },
@@ -98,6 +104,74 @@ export class SchoolPage implements OnInit {
this.loading.set(false); this.loading.set(false);
}); });
} }
firstDate(show: Show): string {
return show.dayParts.map((p) => p.date).sort()[0] ?? '';
}
lastDate(show: Show): string {
return (
show.dayParts
.map((p) => p.date)
.sort()
.at(-1) ?? ''
);
}
dateRange(show: Show): string {
const first = this.firstDate(show),
last = this.lastDate(show);
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)),
)
);
}
groupNamesFor(ids: number[]): string {
return (
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
'Geen groepen'
);
}
async schedule(show: Show): Promise<void> {
const schoolId = this.data()?.school.id;
if (
schoolId === undefined ||
this.scheduling().includes(show.id) ||
!show.dayParts.length ||
!this.data()?.groups.length
)
return;
if (
!window.confirm(
`Groepen inplannen voor ${show.name}? Dit vervangt alle huidige toewijzingen.`,
)
)
return;
this.scheduling.update((ids) => [...ids, show.id]);
this.error.set('');
try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set(
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
);
await this.reload();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.scheduling.update((ids) => ids.filter((id) => id !== show.id));
}
}
async reload(): Promise<void> { async reload(): Promise<void> {
const id = this.data()?.school.id; const id = this.data()?.school.id;
if (id === undefined) return; if (id === undefined) return;
@@ -133,6 +207,17 @@ export class SchoolPage implements OnInit {
} }
askDelete(target: DeleteTarget): void { askDelete(target: DeleteTarget): void {
this.deleteError.set(''); this.deleteError.set('');
if (target.kind === 'group') {
const names =
this.data()
?.shows.filter((s) => s.dayParts.some((p) => p.danceGroupIds.length))
.map((s) => s.name) ?? [];
if (names.length)
target = {
...target,
explanation: `${target.explanation} De planning kan ongeldig worden van: ${names.join(', ')}.`,
};
}
this.deleting.set(target); this.deleting.set(target);
} }
membershipChanged(ids: number[]): void { membershipChanged(ids: number[]): void {

View File

@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { EntityEditor } from './entity-editor'; import { EntityEditor } from './entity-editor';
import { EditorTarget } from '../core/api-models'; import { EditorTarget, Show } from '../core/api-models';
describe('EntityEditor', () => { describe('EntityEditor', () => {
const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() }; const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() };
@@ -14,10 +14,11 @@ describe('EntityEditor', () => {
providers: [{ provide: SchoolApi, useValue: api }], providers: [{ provide: SchoolApi, useValue: api }],
}); });
}); });
function editor(target: EditorTarget) { function editor(target: EditorTarget, shows: Show[] = []) {
const fixture = TestBed.createComponent(EntityEditor); const fixture = TestBed.createComponent(EntityEditor);
fixture.componentRef.setInput('target', target); fixture.componentRef.setInput('target', target);
fixture.componentRef.setInput('schoolId', 7); fixture.componentRef.setInput('schoolId', 7);
fixture.componentRef.setInput('shows', shows);
fixture.componentInstance.ngOnInit(); fixture.componentInstance.ngOnInit();
return fixture.componentInstance; return fixture.componentInstance;
} }
@@ -66,4 +67,107 @@ describe('EntityEditor', () => {
expect(component.error()).toContain('niet bereikbaar'); expect(component.error()).toContain('niet bereikbaar');
expect(component.busy()).toBe(false); expect(component.busy()).toBe(false);
}); });
const show: Show = {
id: 3,
name: 'Summer',
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
],
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', () => {
const component = editor({ kind: 'show' });
component.name = 'Show';
expect(component.validDetails()).toBe(true);
component.addPart();
component.dayParts[0].date = '2027-06-01';
component.addPart();
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.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;
expect(component.validDetails()).toBe(false);
});
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
const component = editor({ kind: 'show', value: show });
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2);
component.removePart(2);
component.removePart(1);
expect(component.appearances.REGULAR).toBe(2);
expect(component.validDetails()).toBe(false);
component.setSingleCounts();
expect(component.validDetails()).toBe(true);
});
it('allows cancelling an invalidating edit and skips warnings for name-only edits', async () => {
api.saveShow.mockReturnValue(of(show));
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try {
const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1;
await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2;
confirm.mockClear();
component.name = 'Renamed';
await component.save();
expect(confirm).not.toHaveBeenCalled();
expect(api.saveShow).toHaveBeenCalledOnce();
} finally {
confirm.mockRestore();
}
});
it('names affected shows before adding a group and allows cancellation', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try {
const component = editor({ kind: 'group' }, [show]);
component.name = 'New group';
await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveGroup).not.toHaveBeenCalled();
} finally {
confirm.mockRestore();
}
});
it('displays one disabled appearance field for every type with one daypart', async () => {
// jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
configurable: true,
value: () => undefined,
});
try {
const fixture = TestBed.createComponent(EntityEditor);
fixture.componentRef.setInput('target', { kind: 'show' });
fixture.componentInstance.addPart();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5);
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');
}
});
}); });

View File

@@ -1,7 +1,15 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core'; import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom, Observable } from 'rxjs'; import { firstValueFrom, Observable } from 'rxjs';
import { EditorTarget, GROUP_STYLES, GROUP_TYPES, GroupStyle, GroupType } from '../core/api-models'; import {
EditorTarget,
GROUP_STYLES,
GROUP_TYPES,
GroupStyle,
GroupType,
DayPart,
Show,
} from '../core/api-models';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error'; import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame'; import { DialogFrame } from './dialog-frame';
@@ -89,11 +97,69 @@ import { DialogFrame } from './dialog-frame';
/></label> /></label>
} }
@if (target().kind === 'show') { @if (target().kind === 'show') {
<label>Datum<input type="date" name="showDate" [(ngModel)]="date" required /></label> <fieldset>
<p class="form-hint"> <legend>Gedateerde dagdelen (maximaal zes)</legend>
De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt @for (part of dayParts; track $index; let i = $index) {
later. <div class="form-grid">
</p> <label
>Datum<input
type="date"
[name]="'partDate' + i"
[(ngModel)]="part.date"
required
/></label>
<label
>Dagdeel<select [name]="'partType' + i" [(ngModel)]="part.type" required>
<option value="MORNING">Ochtend</option>
<option value="AFTERNOON">Middag</option>
<option value="EVENING">Avond</option>
</select></label
>
<button type="button" class="text-button" (click)="removePart(i)">
Dagdeel verwijderen
</button>
</div>
}
<button
type="button"
class="button secondary"
[disabled]="dayParts.length >= 6"
(click)="addPart()"
>
Dagdeel toevoegen
</button>
</fieldset>
@if (dayParts.length) {
<fieldset>
<legend>Optredens per groepstype</legend>
@for (option of types; track option.value) {
<label
>{{ option.label
}}<input
type="number"
[name]="'count' + option.value"
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
(ngModelChange)="appearances[option.value] = $event"
required
min="1"
[max]="dayParts.length"
step="1"
[disabled]="dayParts.length === 1"
/></label>
}
<p class="form-hint">
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
vóór opslaan.
</p>
@if (dayParts.length === 1 && !validCounts()) {
<button type="button" class="button secondary" (click)="setSingleCounts()">
Alle aantallen op één zetten
</button>
}
</fieldset>
} @else {
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
}
} }
} }
<div class="dialog-actions"> <div class="dialog-actions">
@@ -120,6 +186,7 @@ import { DialogFrame } from './dialog-frame';
export class EntityEditor implements OnInit { export class EntityEditor implements OnInit {
readonly target = input.required<EditorTarget>(); readonly target = input.required<EditorTarget>();
readonly schoolId = input<number>(); readonly schoolId = input<number>();
readonly shows = input<Show[]>([]);
readonly closed = output<void>(); readonly closed = output<void>();
readonly saved = output<void>(); readonly saved = output<void>();
readonly busy = signal(false); readonly busy = signal(false);
@@ -128,6 +195,9 @@ export class EntityEditor implements OnInit {
readonly styles = GROUP_STYLES; readonly styles = GROUP_STYLES;
readonly today = new Date().toLocaleDateString('en-CA'); readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi); private readonly api = inject(SchoolApi);
dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {};
private countsInitialized = false;
name = ''; name = '';
date = ''; date = '';
type: GroupType = 'REGULAR'; type: GroupType = 'REGULAR';
@@ -151,9 +221,48 @@ export class EntityEditor implements OnInit {
} else { } else {
this.name = target.value.name; this.name = target.value.name;
if (target.kind === 'dancer') this.date = target.value.birthDate; if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') this.date = target.value.date; 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;
}
} }
} }
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);
}
}
}
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,
);
}
toggleStyle(style: GroupStyle, checked: boolean): void { toggleStyle(style: GroupStyle, checked: boolean): void {
this.selectedStyles = checked this.selectedStyles = checked
? [...this.selectedStyles, style] ? [...this.selectedStyles, style]
@@ -162,7 +271,13 @@ export class EntityEditor implements OnInit {
validDetails(): boolean { validDetails(): boolean {
if (!this.name.trim()) return false; if (!this.name.trim()) return false;
if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today; if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today;
if (this.target().kind === 'show') return !!this.date; if (this.target().kind === 'show')
return (
this.dayParts.length <= 6 &&
this.dayParts.every((p) => !!p.date && !!p.type) &&
new Set(this.dayParts.map((p) => p.date + ':' + p.type)).size === this.dayParts.length &&
(!this.dayParts.length || this.validCounts())
);
if (this.target().kind === 'group') if (this.target().kind === 'group')
return ( return (
this.startAge !== null && this.startAge !== null &&
@@ -176,6 +291,28 @@ export class EntityEditor implements OnInit {
} }
async save(): Promise<void> { async save(): Promise<void> {
if (this.busy() || !this.validDetails()) return; if (this.busy() || !this.validDetails()) return;
const current = this.target();
let affected: Show[] = [];
if (current.kind === 'group' && (!current.value || current.value.type !== this.type))
affected = this.shows();
if (
current.kind === 'show' &&
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))
)
affected = [current.value];
const names = affected
.filter((s) => s.dayParts.some((p) => p.danceGroupIds.length))
.map((s) => s.name);
if (
names.length &&
!window.confirm(
`Deze wijziging kan de planning ongeldig maken van: ${names.join(', ')}. Doorgaan?`,
)
)
return;
this.busy.set(true); this.busy.set(true);
this.error.set(''); this.error.set('');
const target = this.target(); const target = this.target();
@@ -207,7 +344,15 @@ export class EntityEditor implements OnInit {
); );
break; break;
case 'show': case 'show':
request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id); request = this.api.saveShow(
schoolId!,
{
name: this.name.trim(),
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
appearances: this.appearances,
},
id,
);
break; break;
} }
try { try {

View File

@@ -9,12 +9,13 @@ conversation, for example:
| Skill | Purpose | | Skill | Purpose |
| --- | --- | | --- | --- |
| [code-review](code-review/SKILL.md) | Review changes against documented coding standards and the originating spec. |
| [grill-me](grill-me/SKILL.md) | Entry point for reviewing a plan or design through questions. | | [grill-me](grill-me/SKILL.md) | Entry point for reviewing a plan or design through questions. |
| [grilling](grilling/SKILL.md) | Interview instructions required by `grill-me`. | | [grilling](grilling/SKILL.md) | Interview instructions required by `grill-me`. |
The skills are copied from [Matt Pocock's skills.sh listing](https://skills.sh/mattpocock/skills/grill-me) The skills are copied from [Matt Pocock's skills.sh listing](https://skills.sh/mattpocock/skills/grill-me)
and its [upstream repository](https://github.com/mattpocock/skills). and its [upstream repository](https://github.com/mattpocock/skills).
The exact upstream commit and paths are recorded in [upstream.json](upstream.json). The exact upstream revisions and paths are recorded in [upstream.json](upstream.json).
The upstream [MIT license](LICENSE.mattpocock) is included. The upstream [MIT license](LICENSE.mattpocock) is included.
These project-local files are available to the agent from the next conversation These project-local files are available to the agent from the next conversation

View File

@@ -0,0 +1,89 @@
---
name: code-review
description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"."
---
Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
- **Standards**: does the code conform to this repo's documented coding standards?
- **Spec**: does the code faithfully implement the originating issue / spec?
Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. Start every sub-agent with a fresh context window.
The issue tracker should have been provided to you. If not, tell the user to run `/setup-matt-pocock-skills`.
## Process
### 1. Pin the fixed point
Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.). If they didn't specify one, ask for it.
Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents.
### 2. Identify the spec source
Look for the originating spec, in this order:
1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in the tracker doc.
2. A path the user passed as an argument.
3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
### 3. Identify the standards sources
Search the repo for every file that documents how code should be written. When `CODING_STANDARDS.md` or `CONTRIBUTING.md` exists, it must be on the list.
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces.
Each smell reads *what it is* → *how to fix*; match it against the diff:
- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies.
- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct.
- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
### 4. Spawn both sub-agents in parallel
Issue both sub-agent calls together, in the foreground, and aggregate the reports they return.
**Standards sub-agent prompt** should include:
- The full diff command and commit list.
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it).
- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
**Spec sub-agent prompt** should include:
- The diff command and commit list.
- The path or fetched contents of the spec.
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
If the spec is missing, skip the Spec sub-agent and note this in the final report.
### 5. Aggregate
Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_).
End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent.
## Why two axes
A change can pass one axis and fail the other:
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
Reporting them separately stops one axis from masking the other.

View File

@@ -0,0 +1,3 @@
interface:
display_name: "Code Review"
short_description: "Review a diff on standards and spec"

View File

@@ -4,6 +4,11 @@
"source": "https://skills.sh/mattpocock/skills/grill-me", "source": "https://skills.sh/mattpocock/skills/grill-me",
"skills": { "skills": {
"grill-me": "skills/productivity/grill-me", "grill-me": "skills/productivity/grill-me",
"grilling": "skills/productivity/grilling" "grilling": "skills/productivity/grilling",
"code-review": {
"path": "skills/engineering/code-review",
"commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa",
"source": "https://www.skills.sh/mattpocock/skills/code-review"
}
} }
} }

View File

@@ -0,0 +1 @@
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.

View File

@@ -0,0 +1 @@
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.

View File

@@ -1,6 +1,6 @@
# S9 — Schedule dance groups across show dayparts with OR-Tools # S9 — Schedule dance groups across show dayparts with OR-Tools
Status: REFINED Status: IN_REVIEW
Agreed after the grill-me review; shared understanding confirmed. Agreed after the grill-me review; shared understanding confirmed.
@@ -121,33 +121,43 @@ S10–S13 are intentionally empty placeholders and are to be refined separately.
## Acceptance criteria ## Acceptance criteria
- [ ] Configure up to six dated dayparts, reject duplicate date/type combinations - [x] Configure up to six dated dayparts, reject duplicate date/type combinations
and derive the displayed show date range from them. and derive the displayed show date range from them.
- [ ] Persist editable per-type counts with the agreed defaults, remove the - [x] Persist editable per-type counts with the agreed defaults, remove the
backend type-level property and validate counts in frontend and backend. backend type-level property and validate counts in frontend and backend.
- [ ] With one daypart, every count is one and frontend fields are disabled. - [x] With one daypart, every count is one and frontend fields are disabled.
- [ ] Daypart edits preserve configured counts and require correction of counts - [x] Daypart edits preserve configured counts and require correction of counts
that exceed the new number of dayparts. that exceed the new number of dayparts.
- [ ] OR-Tools assigns every school group to exactly its type's required number - [x] OR-Tools assigns every school group to exactly its type's required number
of distinct dayparts and minimizes the busiest/quietest appearance gap. of distinct dayparts and minimizes the busiest/quietest appearance gap.
- [ ] Scheduling requires groups and dayparts and replaces assignments only after - [x] Scheduling requires groups and dayparts and replaces assignments only after
a successful, current result. a successful, current result.
- [ ] The synchronous request supports a 30-minute solver limit, accepts valid - [x] The synchronous request supports a 30-minute solver limit, accepts valid
unproven results and communicates whether optimality was proven. unproven results and communicates whether optimality was proven.
- [ ] Failed, infeasible, stale and no-result runs preserve existing assignments - [x] Failed, infeasible, stale and no-result runs preserve existing assignments
and explain why no replacement was saved. and explain why no replacement was saved.
- [ ] Duplicate concurrent runs for a show are prevented in frontend and backend. - [x] Duplicate concurrent runs for a show are prevented in frontend and backend.
- [ ] Potentially invalidating edits warn beforehand, identify affected shows, - [x] Potentially invalidating edits warn beforehand, identify affected shows,
permit cancellation and expose invalid schedules afterward without permit cancellation and expose invalid schedules afterward without
automatically rescheduling. automatically rescheduling.
- [ ] New-format JSON round-trips dates, assignments and all type settings; - [x] New-format JSON round-trips dates, assignments and all type settings;
legacy import support is not required. legacy import support is not required.
- [ ] Any repeatability work respects both the three-minute investigation limit - [x] Any repeatability work respects both the three-minute investigation limit
and the ten-additional-statement implementation limit. and the ten-additional-statement implementation limit.
## Implementation handoff ## Implementation handoff
This specification records agreed behavior; it does not implement scheduling. This specification records the agreed behavior implemented on the current branch.
The current application uses in-memory storage with JSON import/export for The current application uses in-memory storage with JSON import/export for
transfer. S9 adds the scheduling model, frontend controls, backend operation and transfer. S9 adds the scheduling model, frontend controls, backend operation and
JSON changes within that existing persistence approach. JSON changes within that existing persistence approach.
## Implementation validation
- Backend: 29 tests, including real OR-Tools balancing, input validation, JSON
round-trips, concurrent scheduling, stale input rejection and failure preservation.
- Frontend: 20 tests covering count validation, disabled single-daypart fields, edit cancellation,
API timeout contract, production build and lint checks.
- Playwright: real REST workflow for dated dayparts, scheduling and cancellation.
- Repeatability uses one solver-parameter statement (single worker, fixed seed);
no extended investigation was needed.