Merge pull request 'feature/S9_ortools-phase1' (#6) from feature/S9_ortools-phase1 into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m44s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-10-30 15:20:57 +01:00
55 changed files with 1183 additions and 158 deletions

View File

@@ -1,6 +1,8 @@
# Project skills and specifications
- 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 to review this plan` or `Gebruik grill-me voor dit ontwerp`.
- Before using it, read `skills/grill-me/SKILL.md` and its dependency

View File

@@ -76,6 +76,20 @@ implementations in an outer adapter. For future calculations, define a port in
`service.port` using domain types and implement it in `solver`; services must not
depend on solver implementations or expose OR-Tools types.
Handwritten production types each have their own file and must be top-level.
Service records live in `service.model`; the school JSON snapshot records and
import preview are service-owned data models. Scheduling records shared with
the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a
scheduling problem. `SchedulingService` calls this interface; dependency injection
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
types, so neither the caller nor the contract depends on OR-Tools or the concrete
school-management service. Architecture tests enforce the port's location and
dependencies, the location of service records, and the top-level type rule
(excluding generated Lombok builders and compiler helpers).
## School management REST API
@@ -117,7 +131,7 @@ Request bodies (dates use ISO `yyyy-MM-dd`):
```
```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.
@@ -138,9 +152,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
dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, a date and three automatically created day parts: `MORNING`,
`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning
groups is not exposed yet. Multiple shows per year are allowed.
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.
`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:

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.model.scheduling;
public record GroupRequirement(long groupId, int appearances) {}

View File

@@ -0,0 +1,9 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
public SchedulingInput {
groups = List.copyOf(groups);
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}

View File

@@ -1,18 +1,5 @@
package nl.agilecc.agiledancers.model.specifications;
import lombok.Getter;
public enum DanceGroupType {
SHOW_CASE(3),
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;
}
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
}

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()) {
case NOT_FOUND -> 404;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build();

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers.rest;
import nl.agilecc.agiledancers.service.model.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser;
import jakarta.inject.Inject;
@@ -20,7 +21,7 @@ public class SchoolSnapshotResource {
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
}
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
public SchoolManagementService.ImportPreview preview(String json) { return service.preview(parse(json)); }
public ImportPreview preview(String json) { return service.preview(parse(json)); }
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
@@ -37,7 +38,7 @@ public class SchoolSnapshotResource {
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
return snapshot;
} catch (Exception e) {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

View File

@@ -6,6 +6,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
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;
@@ -19,6 +22,9 @@ public class ShowResource {
@Inject
SchoolManagementService service;
@Inject
SchedulingService scheduling;
@PathParam("schoolId")
long schoolId;
@@ -35,7 +41,7 @@ public class ShowResource {
@POST
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())
.entity(ShowView.of(entity)).build();
}
@@ -43,7 +49,21 @@ public class ShowResource {
@PUT
@Path("/{id}")
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

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;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}
import java.time.LocalDate;
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;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {}

View File

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

View File

@@ -1,16 +1,15 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT }
private final Kind kind;
private final ManagementExceptionKind kind;
public ManagementException(Kind kind, String message) {
public ManagementException(ManagementExceptionKind kind, String message) {
super(message);
this.kind = kind;
}
public Kind getKind() {
public ManagementExceptionKind getKind() {
return kind;
}
}

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }

View File

@@ -0,0 +1,22 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class SchedulingService {
@Inject SchoolManagementService schools;
@Inject SchedulingEngine engine;
public 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(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally { schools.endScheduling(showId); }
}
}

View File

@@ -1,14 +1,16 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*;
import java.time.LocalDate;
import java.util.*;
import static nl.agilecc.agiledancers.service.ManagementException.Kind.*;
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton
@@ -18,6 +20,50 @@ public class SchoolManagementService {
private final DanceGroupRepository groups = new DanceGroupRepository();
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 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(g -> new GroupRequirement(g.getId(), 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() || !current.keySet().containsAll(part))
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
}
for (GroupRequirement group : input.groups()) {
long id = group.groupId();
if (result.assignments().stream().filter(p -> p.contains(id)).count() != group.appearances())
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() {
return schools.findAll();
}
@@ -89,6 +135,7 @@ public class SchoolManagementService {
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
school.addDanceGroup(group);
school.getShows().forEach(this::touch);
return group;
}
@@ -97,6 +144,7 @@ public class SchoolManagementService {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
group.setType(type);
group.setStartAge(startAge);
group.setEndAge(endAge);
@@ -107,6 +155,7 @@ public class SchoolManagementService {
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
school(schoolId).getShows().forEach(this::touch);
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
@@ -129,29 +178,40 @@ public class SchoolManagementService {
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);
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
ShowConfiguration.validate(parts, counts);
Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
school.addShow(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);
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.setDate(date);
show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
if (changed) touch(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) {
Show show = show(schoolId, showId);
school(schoolId).removeShow(show);
shows.delete(showId);
}
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public synchronized SchoolSnapshot exportSchool(long id) {
return SchoolSnapshot.of(school(id));
@@ -191,8 +251,8 @@ public class SchoolManagementService {
}
List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) {
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
.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);
}
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())

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(ManagementExceptionKind.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(ManagementExceptionKind.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,8 @@
package nl.agilecc.agiledancers.service.model;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service.model;
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record Part(LocalDate date, DayPartType type, List<String> groupIds) {}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.service.model;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
public record Person(String id, String name, LocalDate birthDate) {}

View File

@@ -1,35 +1,31 @@
package nl.agilecc.agiledancers.service;
package nl.agilecc.agiledancers.service.model;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ShowConfiguration;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate;
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,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record Person(String id, String name, LocalDate birthDate) {}
public record Trainer(String id, String name) {}
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
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.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(),
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
g.getDancers().stream().map(dancers::get).toList(),
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
show.getDayParts().stream().map(p -> new Part(p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(),
show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
@@ -39,48 +35,48 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
}
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");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser"); text(d.name, "dansernaam");
check(d.birthDate != null && !d.birthDate.isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name);
Dancer dancer = Dancer.builder().name(d.name).birthDate(d.birthDate).build();
check(d != null, "Lege danser"); text(d.name(), "dansernaam");
check(d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name());
Dancer dancer = Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
// Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size());
put(dancerMap, d.id, dancer); school.addDancer(dancer);
put(dancerMap, d.id(), dancer); school.addDancer(dancer);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); text(c.name, "coachnaam");
Coach coach = Coach.builder().name(c.name).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id, coach); school.addCoach(coach);
check(c != null, "Lege coach"); text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name()).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach); school.addCoach(coach);
}
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) {
check(g != null, "Lege groep"); text(g.category, "groepsnaam");
check(g.type != null && g.startAge != null && g.endAge != null && g.startAge >= 0 && g.endAge >= g.startAge,
"Ongeldig type of leeftijdsbereik voor groep " + g.category);
DanceGroup group = DanceGroup.builder().category(g.category).type(g.type).startAge(g.startAge).endAge(g.endAge)
.styles(new LinkedHashSet<>(list(g.styles, "styles"))).coach(g.coachId == null ? null : ref(coachMap, g.coachId, "groep " + g.category + " verwijst naar een onbekende coach" )).build();
check(g != null, "Lege groep"); text(g.category(), "groepsnaam");
check(g.type() != null && g.startAge() != null && g.endAge() != null && g.startAge() >= 0 && g.endAge() >= g.startAge(),
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
DanceGroup group = DanceGroup.builder().category(g.category()).type(g.type()).startAge(g.startAge()).endAge(g.endAge())
.styles(new LinkedHashSet<>(list(g.styles(), "styles"))).coach(g.coachId() == null ? null : ref(coachMap, g.coachId(), "groep " + g.category() + " verwijst naar een onbekende coach" )).build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds, "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category + " verwijst naar een onbekende danser"));
put(groupMap, g.id, group); school.addDanceGroup(group);
for (String id : unique(g.dancerIds(), "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
put(groupMap, g.id(), group); school.addDanceGroup(group);
}
Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) {
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(show.date != null, "Showdatum ontbreekt voor " + show.name);
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) {
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
check(show != null, "Lege show"); text(show.id(), "show-id");
check(showIds.add(show.id()), "Dubbele show-id " + show.id()); text(show.name(), "shownaam");
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
for (Part part : list(show.dayParts(), "dayParts")) {
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().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());
}
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);
}
return school;
@@ -92,5 +88,5 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
}
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementException.Kind.INVALID_INPUT, message); }
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message); }
}

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service.model;
public record Trainer(String id, String name) {}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.service.port;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine {
SchedulingResult solve(SchedulingInput input);
}

View File

@@ -0,0 +1,55 @@
package nl.agilecc.agiledancers.solver;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
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 SchedulingResult solve(SchedulingInput input) {
Loader.loadNativeLibraries();
CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groups().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.groups().get(g).appearances());
}
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(ManagementExceptionKind.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.groups().get(g).groupId());
parts.add(List.copyOf(ids));
}
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
}
}

View File

@@ -1,2 +1,6 @@
quarkus.application.name=agiledancers
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

@@ -1,10 +1,10 @@
package nl.agilecc.agiledancers;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@@ -15,6 +15,31 @@ class ArchitectureTest {
private static final String ROOT = "nl.agilecc.agiledancers";
private static final String API_MODEL = ROOT + ".rest.apimodel";
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
@ArchTest
static final ArchRule handwritten_types_are_top_level = classes()
.that().areNotAnnotatedWith("lombok.Generated")
.and().doNotHaveModifier(JavaModifier.SYNTHETIC)
.should().beTopLevelClasses();
@ArchTest
static final ArchRule service_records_reside_in_service_model = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areRecords()
.should().resideInAPackage(ROOT + ".service.model");
@ArchTest
static final ArchRule service_interfaces_reside_in_service_port = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areInterfaces()
.should().resideInAPackage(ROOT + ".service.port");
@ArchTest
static final ArchRule scheduling_port_uses_domain_types = classes()
.that().resideInAPackage(ROOT + ".service.port..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java..");
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
@ArchTest
static final ArchRule api_models_reside_in_the_api_model_package = classes()

View File

@@ -0,0 +1,112 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
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 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 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

@@ -3,9 +3,7 @@ package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@@ -15,7 +13,7 @@ class SchoolManagementResourceTest {
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",
"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
void managesSchoolAndItsResources() {
@@ -46,10 +44,10 @@ class SchoolManagementResourceTest {
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
.statusCode(200).body("date", equalTo("2027-07-01"));
put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
.statusCode(200).body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
@@ -152,6 +150,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(io.restassured.path.json.JsonPath.from(exported).getMap("$")));
} finally { given().delete(school); }
}
private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then()
.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)
.then().statusCode(201).extract().jsonPath().getLong("id");
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 {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.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("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test;
@@ -9,12 +10,12 @@ import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(1, 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.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")),
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new Trainer("c", "Alex")),
List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new Performance("s", "Show",
List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
@@ -56,7 +57,7 @@ class SchoolSnapshotTest {
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
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.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));

View File

@@ -0,0 +1,68 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
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(ManagementExceptionKind.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(ManagementExceptionKind.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 page.getByRole('button', { name: 'Show toevoegen' }).click();
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 save(page);
await expect(page.getByText('Ochtend', { exact: true })).toBeVisible();
await expect(page.getByText('Middag', { exact: true })).toBeVisible();
await expect(page.getByText('Avond', { exact: true })).toBeVisible();
await expect(page.getByText('2027-06-12 · AFTERNOON:', { exact: false })).toBeVisible();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-07-01');
await save(page);
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 page.setViewportSize({ width: 390, height: 844 });

View File

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

View File

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

View File

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

View File

@@ -66,4 +66,11 @@ describe('SchoolApi contract', () => {
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
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> {
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> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind

View File

@@ -114,7 +114,7 @@
<p class="eyebrow">IN DE SPOTLIGHT</p>
@if (upcoming(); as show) {
<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>
<a class="button light" [routerLink]="['/schools', context.school.id, 'shows']"
>Shows bekijken <span aria-hidden="true">→</span></a
@@ -345,16 +345,39 @@
@for (show of shows(); track show.id) {
<article class="show-card">
<div class="date-tile">
<strong>{{ show.date | date: 'dd' }}</strong
><span>{{ show.date | date: 'MMM yyyy' }}</span>
<strong>{{ firstDate(show) | date: 'dd' }}</strong
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
</div>
<div class="show-details">
<h3>{{ show.name }}</h3>
<div class="tag-list">
<span class="style-tag">Ochtend</span><span class="style-tag">Middag</span
><span class="style-tag">Avond</span>
</div>
<p class="muted">Groepen indelen over dagdelen is nog niet beschikbaar.</p>
<p>{{ dateRange(show) }}</p>
@for (part of show.dayParts; track $index) {
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</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 class="row-actions">
<button
@@ -385,7 +408,7 @@
<div class="empty-state">
<span class="empty-symbol" aria-hidden="true">✦</span>
<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' })">
Eerste show plannen
</button>
@@ -398,6 +421,7 @@
<app-entity-editor
[target]="target"
[schoolId]="context.school.id"
[shows]="context.shows"
(closed)="editor.set(null)"
(saved)="saved()"
/>

View File

@@ -11,6 +11,7 @@ import {
GROUP_TYPES,
ResourceKind,
SchoolData,
Show,
} from '../core/api-models';
import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api';
@@ -51,6 +52,7 @@ export class SchoolPage implements OnInit {
readonly editor = signal<EditorTarget | null>(null);
readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]);
readonly deleteBusy = signal(false);
readonly deleteError = signal('');
readonly search = signal('');
@@ -60,10 +62,14 @@ export class SchoolPage implements OnInit {
.sort((a, b) => a.name.localeCompare(b.name, 'nl')),
);
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 upcoming = computed(() => this.shows().find((show) => show.date >= this.today));
readonly upcoming = computed(() =>
this.shows().find((show) => this.lastDate(show) >= this.today),
);
readonly tabs = [
{ value: 'overview', label: 'Overzicht' },
{ value: 'dancers', label: 'Dansers' },
@@ -98,6 +104,74 @@ export class SchoolPage implements OnInit {
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> {
const id = this.data()?.school.id;
if (id === undefined) return;
@@ -133,6 +207,17 @@ export class SchoolPage implements OnInit {
}
askDelete(target: DeleteTarget): void {
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);
}
membershipChanged(ids: number[]): void {

View File

@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { SchoolApi } from '../core/school-api';
import { EntityEditor } from './entity-editor';
import { EditorTarget } from '../core/api-models';
import { EditorTarget, Show } from '../core/api-models';
describe('EntityEditor', () => {
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 }],
});
});
function editor(target: EditorTarget) {
function editor(target: EditorTarget, shows: Show[] = []) {
const fixture = TestBed.createComponent(EntityEditor);
fixture.componentRef.setInput('target', target);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentRef.setInput('shows', shows);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
@@ -66,4 +67,107 @@ describe('EntityEditor', () => {
expect(component.error()).toContain('niet bereikbaar');
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 { FormsModule } from '@angular/forms';
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 { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@@ -89,11 +97,69 @@ import { DialogFrame } from './dialog-frame';
/></label>
}
@if (target().kind === 'show') {
<label>Datum<input type="date" name="showDate" [(ngModel)]="date" required /></label>
<fieldset>
<legend>Gedateerde dagdelen (maximaal zes)</legend>
@for (part of dayParts; track $index; let i = $index) {
<div class="form-grid">
<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">
De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt
later.
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">
@@ -120,6 +186,7 @@ import { DialogFrame } from './dialog-frame';
export class EntityEditor implements OnInit {
readonly target = input.required<EditorTarget>();
readonly schoolId = input<number>();
readonly shows = input<Show[]>([]);
readonly closed = output<void>();
readonly saved = output<void>();
readonly busy = signal(false);
@@ -128,6 +195,9 @@ export class EntityEditor implements OnInit {
readonly styles = GROUP_STYLES;
readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi);
dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {};
private countsInitialized = false;
name = '';
date = '';
type: GroupType = 'REGULAR';
@@ -151,9 +221,48 @@ export class EntityEditor implements OnInit {
} else {
this.name = target.value.name;
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 {
this.selectedStyles = checked
? [...this.selectedStyles, style]
@@ -162,7 +271,13 @@ export class EntityEditor implements OnInit {
validDetails(): boolean {
if (!this.name.trim()) return false;
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')
return (
this.startAge !== null &&
@@ -176,6 +291,28 @@ export class EntityEditor implements OnInit {
}
async save(): Promise<void> {
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.error.set('');
const target = this.target();
@@ -207,7 +344,15 @@ export class EntityEditor implements OnInit {
);
break;
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;
}
try {

View File

@@ -9,12 +9,13 @@ conversation, for example:
| 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. |
| [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)
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.
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",
"skills": {
"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
Status: REFINED
Status: IN_REVIEW
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
- [ ] 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.
- [ ] 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.
- [ ] With one daypart, every count is one and frontend fields are disabled.
- [ ] Daypart edits preserve configured counts and require correction of counts
- [x] With one daypart, every count is one and frontend fields are disabled.
- [x] Daypart edits preserve configured counts and require correction of counts
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.
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] Duplicate concurrent runs for a show are prevented in frontend and backend.
- [ ] Potentially invalidating edits warn beforehand, identify affected shows,
- [x] Duplicate concurrent runs for a show are prevented in frontend and backend.
- [x] Potentially invalidating edits warn beforehand, identify affected shows,
permit cancellation and expose invalid schedules afterward without
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.
- [ ] 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.
## 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
transfer. S9 adds the scheduling model, frontend controls, backend operation and
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.