S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {}
|
||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
public record SchedulingView(boolean optimal, String message) {}
|
||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package nl.agilecc.agiledancers.service;
|
||||||
|
public interface SchedulingEngine {
|
||||||
|
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]+$"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
89
skills/code-review/SKILL.md
Normal file
89
skills/code-review/SKILL.md
Normal 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.
|
||||||
3
skills/code-review/agents/openai.yaml
Normal file
3
skills/code-review/agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Code Review"
|
||||||
|
short_description: "Review a diff on standards and spec"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user