diff --git a/README.md b/README.md index b8d8e5e..8a43ab6 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group ID and appearance count in one record. `service.port.SchedulingEngine` is the application-owned interface for solving a -scheduling problem. `SchedulingService` calls this interface; dependency injection +scheduling problem. `SchedulingRuns` calls this interface for background runs; +`SchedulingService` retains the synchronous service entry point. Dependency injection supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain types, so neither the caller nor the contract depends on OR-Tools or the concrete school-management service. Architecture tests enforce the port's location and @@ -160,11 +161,22 @@ dayparts; preschool groups once; showcases every daypart. See [show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints, coach exceptions, ordering and optimization rules. -`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered -draft using OR-Tools with a total thirty-minute time limit. A feasible result is -accepted without claiming optimality. Concurrent runs and stale results return -`409`; failures retain existing assignments. Development and production proxies -allow 31 minutes. +`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a +run identifier and starts an ordered draft calculation in the background using +OR-Tools with a total thirty-minute time limit. Duplicate starts for the same +show return `409`; different shows may run concurrently. A feasible result is +accepted without claiming optimality. Stale results and failures retain existing +assignments and appear as distinct terminal run states. + +`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists). +`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot +on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients +recover current state on reconnection rather than requiring event replay. The +production proxy disables buffering. The UI shows phase, elapsed time, solution +discovery and final objective scores, and reports completion in an application-wide +snackbar. Reloading recovers the latest state without replaying old notifications. +Run state currently shares the application's in-memory lifetime; durable restart +recovery requires the PostgreSQL integration specified in S16. `PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingFailureKind.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingFailureKind.java new file mode 100644 index 0000000..1b85227 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingFailureKind.java @@ -0,0 +1,7 @@ +package nl.agilecc.agiledancers.model.scheduling; + +public enum SchedulingFailureKind { + INFEASIBLE, + NO_SOLUTION, + SOLVER_FAILED +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingProgress.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingProgress.java new file mode 100644 index 0000000..9ff6cb2 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingProgress.java @@ -0,0 +1,4 @@ +package nl.agilecc.agiledancers.model.scheduling; + +/** Progress is observational; intermediate assignments are never published or applied. */ +public record SchedulingProgress(String phase, boolean feasible) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingSummary.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingSummary.java new file mode 100644 index 0000000..2659865 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingSummary.java @@ -0,0 +1,59 @@ +package nl.agilecc.agiledancers.model.scheduling; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Scores match the solver objectives; lower values are better. */ +public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) { + public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) { + final List> parts = result.assignments(); + final Map groups = new HashMap<>(); + input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group)); + final int min = parts.stream().mapToInt(List::size).min().orElse(0); + final int max = parts.stream().mapToInt(List::size).max().orElse(0); + return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups)); + } + + private static long attendance(final SchedulingInput input, final List> parts) { + final Set showcases = new HashSet<>(); + final Map> attendance = new HashMap<>(); + for (final GroupRequirement group : input.groups()) { + if (group.showcase()) { + showcases.addAll(group.dancerIds()); + } else { + recordAttendance(group, parts, attendance); + } + } + showcases.removeAll(input.coachDancerIds()); + return showcases.stream() + .mapToLong((final Long dancer) -> + Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts())) + .sum(); + } + + private static void recordAttendance( + final GroupRequirement group, final List> parts, final Map> attendance) { + for (int part = 0; part < parts.size(); part++) { + if (parts.get(part).contains(group.groupId())) { + for (final long dancer : group.dancerIds()) { + attendance + .computeIfAbsent(dancer, (final Long id) -> new HashSet<>()) + .add(part); + } + } + } + } + + private static long variety(final List> parts, final Map groups) { + long penalty = 0; + for (final List part : parts) { + for (int index = 1; index < part.size(); index++) { + penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index))); + } + } + return penalty; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java new file mode 100644 index 0000000..7f90dfe --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchedulingEventsResource.java @@ -0,0 +1,41 @@ +package nl.agilecc.agiledancers.rest; + +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.subscription.BackPressureStrategy; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.time.Duration; +import java.util.List; +import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView; +import nl.agilecc.agiledancers.service.SchedulingRuns; +import nl.agilecc.agiledancers.service.model.SchedulingRun; +import org.jboss.resteasy.reactive.RestStreamElementType; + +@Path("/api/scheduling/events") +public class SchedulingEventsResource { + private @Inject SchedulingRuns scheduling; + + @GET + @Produces(MediaType.SERVER_SENT_EVENTS) + @RestStreamElementType(MediaType.APPLICATION_JSON) + public Multi events() { + final Multi updates = Multi.createFrom() + .emitter( + (final io.smallrye.mutiny.subscription.MultiEmitter emitter) -> { + final Runnable unsubscribe = scheduling.subscribe( + (final List runs) -> emitter.emit(new SchedulingEventView(runs))); + emitter.onTermination(unsubscribe); + }, + BackPressureStrategy.LATEST); + // Heartbeats keep idle proxy connections alive without changing the current snapshot. + final Multi heartbeat = Multi.createFrom() + .ticks() + .every(Duration.ofSeconds(15)) + .onItem() + .transform((final Long tick) -> new SchedulingEventView(null)); + return Multi.createBy().merging().streams(updates, heartbeat); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java index 9fb74f8..3f6bf57 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java @@ -18,15 +18,14 @@ import jakarta.ws.rs.core.UriInfo; import java.util.List; import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.Show; -import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest; import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest; -import nl.agilecc.agiledancers.rest.apimodel.SchedulingView; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowView; -import nl.agilecc.agiledancers.service.SchedulingService; +import nl.agilecc.agiledancers.service.SchedulingRuns; import nl.agilecc.agiledancers.service.SchoolManagementService; import nl.agilecc.agiledancers.service.ShowSchedules; +import nl.agilecc.agiledancers.service.model.SchedulingRun; @Path("/api/dance-schools/{schoolId}/shows") @Produces(MediaType.APPLICATION_JSON) @@ -34,7 +33,7 @@ import nl.agilecc.agiledancers.service.ShowSchedules; public class ShowResource { private @Inject SchoolManagementService service; - private @Inject SchedulingService scheduling; + private @Inject SchedulingRuns scheduling; private @Inject ShowSchedules schedules; @@ -105,10 +104,14 @@ public class ShowResource { @POST @Path("/{id}/schedule") @Consumes(MediaType.WILDCARD) - public SchedulingView schedule(final @PathParam("id") long id) { - final SchedulingResult result = scheduling.schedule(schoolId, id); - return new SchedulingView( - result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven"); + public Response schedule(final @PathParam("id") long id) { + return Response.accepted(scheduling.start(schoolId, id)).build(); + } + + @GET + @Path("/{id}/schedule") + public SchedulingRun schedulingStatus(final @PathParam("id") long id) { + return scheduling.status(schoolId, id); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java new file mode 100644 index 0000000..f9f1ed4 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingEventView.java @@ -0,0 +1,11 @@ +package nl.agilecc.agiledancers.rest.apimodel; + +import java.util.List; +import nl.agilecc.agiledancers.service.model.SchedulingRun; + +/** A null runs field is a heartbeat, not a replacement of the current snapshot. */ +public record SchedulingEventView(List runs) { + public SchedulingEventView { + runs = runs == null ? null : List.copyOf(runs); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingFailure.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingFailure.java new file mode 100644 index 0000000..a4ea159 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingFailure.java @@ -0,0 +1,17 @@ +package nl.agilecc.agiledancers.service; + +import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind; + +/** Carries a stable calculation outcome independently of its human-readable explanation. */ +public class SchedulingFailure extends ManagementException { + private final SchedulingFailureKind outcome; + + public SchedulingFailure(final SchedulingFailureKind outcome, final String message) { + super(ManagementExceptionKind.INVALID_INPUT, message); + this.outcome = outcome; + } + + public SchedulingFailureKind getOutcome() { + return outcome; + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java new file mode 100644 index 0000000..683190b --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingRuns.java @@ -0,0 +1,123 @@ +package nl.agilecc.agiledancers.service; + +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; +import nl.agilecc.agiledancers.service.model.SchedulingRun; +import nl.agilecc.agiledancers.service.port.SchedulingEngine; + +/** Owns background tasks and publishes current state independently of any HTTP connection. */ +@Singleton +public class SchedulingRuns { + private final SchoolManagementService schools; + private final SchedulingEngine engine; + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private final Map runs = new HashMap<>(); + private final Map>> listeners = new HashMap<>(); + + @Inject + public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) { + this.schools = schools; + this.engine = engine; + } + + public synchronized SchedulingRun start(final long schoolId, final long showId) { + final SchedulingInput input; + final SchedulingRun run; + synchronized (schools) { + final String name = schools.show(schoolId, showId).getName(); + input = schools.beginScheduling(schoolId, showId); + run = SchedulingRun.started(schoolId, showId, name); + } + publish(run); + try { + executor.submit(() -> calculate(run, input)); + } catch (final RuntimeException failure) { + schools.endScheduling(showId); + publish(run.completed("SOLVER_FAILED", "Berekening kon niet starten. Bestaande indeling behouden.", null)); + throw failure; + } + return run; + } + + public synchronized SchedulingRun status(final long schoolId, final long showId) { + schools.show(schoolId, showId); + return runs.get(showId); + } + + /** Registration and initial snapshot share the publication lock, preventing a missed update. */ + public synchronized Runnable subscribe(final Consumer> listener) { + final Object key = new Object(); + listeners.put(key, listener); + listener.accept(List.copyOf(runs.values())); + return () -> unsubscribe(key); + } + + private synchronized void unsubscribe(final Object key) { + listeners.remove(key); + } + + private synchronized void publish(final SchedulingRun run) { + runs.put(run.showId(), run); + final List snapshot = List.copyOf(runs.values()); + listeners.values().forEach((final Consumer> listener) -> listener.accept(snapshot)); + } + + private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) { + publish(runs.get(run.showId()).progressed(update)); + } + + private void calculate(final SchedulingRun run, final SchedulingInput input) { + SchedulingRun completed; + try { + final SchedulingResult result = + engine.solve(input, (final SchedulingProgress update) -> progress(run, update)); + final SchedulingSummary summary = SchedulingSummary.of(input, result); + schools.finishScheduling(run.schoolId(), input, result); + completed = latest(run) + .completed( + result.optimal() ? "OPTIMAL" : "VALID", + result.optimal() + ? "Optimaal conceptrooster." + : "Geldig conceptrooster; optimaliteit niet bewezen.", + summary); + } catch (final ManagementException failure) { + completed = failed(run, failure); + } catch (final RuntimeException | LinkageError failure) { + completed = + latest(run).completed("SOLVER_FAILED", "Berekening mislukt. Bestaande indeling behouden.", null); + } + finish(completed); + } + + private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) { + final String outcome = failure instanceof final SchedulingFailure scheduling + ? scheduling.getOutcome().name() + : (failure.getKind() == ManagementExceptionKind.CONFLICT ? "STALE" : "INVALID_INPUT"); + return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null); + } + + private synchronized SchedulingRun latest(final SchedulingRun run) { + return runs.get(run.showId()); + } + + private synchronized void finish(final SchedulingRun completed) { + schools.endScheduling(completed.showId()); + publish(completed); + } + + @PreDestroy + public void close() { + executor.shutdownNow(); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java new file mode 100644 index 0000000..51ff952 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchedulingRun.java @@ -0,0 +1,61 @@ +package nl.agilecc.agiledancers.service.model; + +import java.time.Instant; +import java.util.UUID; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; +import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; + +/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */ +public record SchedulingRun( + UUID runId, + long schoolId, + long showId, + String showName, + Instant startedAt, + Instant finishedAt, + String state, + SchedulingProgress progress, + String message, + SchedulingSummary summary) { + public static SchedulingRun started(final long schoolId, final long showId, final String name) { + return new SchedulingRun( + UUID.randomUUID(), + schoolId, + showId, + name, + Instant.now(), + null, + "RUNNING", + new SchedulingProgress("PREPARING", false), + "Berekening gestart.", + null); + } + + public SchedulingRun progressed(final SchedulingProgress update) { + return new SchedulingRun( + runId, + schoolId, + showId, + showName, + startedAt, + null, + state, + new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()), + message, + null); + } + + public SchedulingRun completed(final String outcome, final String explanation, final SchedulingSummary scores) { + return new SchedulingRun( + runId, + schoolId, + showId, + showName, + startedAt, + Instant.now(), + outcome, + new SchedulingProgress(progress.phase(), progress.feasible() || scores != null), + explanation, + scores); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java index 592d499..0bce88d 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java @@ -1,9 +1,15 @@ package nl.agilecc.agiledancers.service.port; +import java.util.function.Consumer; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; public interface SchedulingEngine { /** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */ public SchedulingResult solve(final SchedulingInput input); + /** Reports phase changes and solution discovery while calculating. */ + public default SchedulingResult solve(final SchedulingInput input, final Consumer observer) { + return solve(input); + } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java index aa359c4..e3d169e 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java @@ -7,10 +7,12 @@ import com.google.ortools.sat.CpSolverStatus; import com.google.ortools.sat.LinearExpr; import jakarta.inject.Singleton; import java.util.List; +import java.util.function.Consumer; +import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; -import nl.agilecc.agiledancers.service.ManagementException; -import nl.agilecc.agiledancers.service.ManagementExceptionKind; +import nl.agilecc.agiledancers.service.SchedulingFailure; import nl.agilecc.agiledancers.service.port.SchedulingEngine; @Singleton @@ -28,6 +30,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { @Override public SchedulingResult solve(final SchedulingInput input) { + return solve(input, (final SchedulingProgress progress) -> {}); + } + + @Override + public SchedulingResult solve(final SchedulingInput input, final Consumer observer) { final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L); Loader.loadNativeLibraries(); final CpModel model = new CpModel(); @@ -38,7 +45,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { solver.getParameters().setCatchSigintSignal(false); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); SchedulingResult best = null; - for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) { + final List objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety); + final List phases = List.of("ATTENDANCE", "SPREAD", "VARIETY"); + for (int stage = 0; stage < objectives.size(); stage++) { + final LinearExpr objective = objectives.get(stage); + final String phase = phases.get(stage); + observer.accept(new SchedulingProgress(phase, best != null)); final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0; if (remaining <= 0) { if (best != null) { @@ -48,7 +60,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { } solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining)); model.minimize(objective); - final CpSolverStatus status = solver.solve(model); + final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase)); if (status == CpSolverStatus.UNKNOWN && best != null) { return new SchedulingResult(false, best.assignments()); } @@ -64,8 +76,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { private static void requireSolution(final CpSolverStatus status) { if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) { - throw new ManagementException( - ManagementExceptionKind.INVALID_INPUT, + throw new SchedulingFailure( + switch (status) { + case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE; + case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION; + default -> SchedulingFailureKind.SOLVER_FAILED; + }, switch (status) { case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; case UNKNOWN -> diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingSolutionObserver.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingSolutionObserver.java new file mode 100644 index 0000000..7204fa8 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/SchedulingSolutionObserver.java @@ -0,0 +1,25 @@ +package nl.agilecc.agiledancers.solver; + +import com.google.ortools.sat.CpSolverSolutionCallback; +import java.util.function.Consumer; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; + +/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */ +public class SchedulingSolutionObserver extends CpSolverSolutionCallback { + private final Consumer observer; + private final String phase; + private boolean reported; + + public SchedulingSolutionObserver(final Consumer observer, final String phase) { + this.observer = observer; + this.phase = phase; + } + + @Override + public void onSolutionCallback() { + if (!reported) { + reported = true; + observer.accept(new SchedulingProgress(phase, true)); + } + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java index 69a420f..0001090 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/S18ResourceTest.java @@ -92,7 +92,8 @@ class S18ResourceTest { final long dancer = dancer(school, "Coach"); group(school, dancer); final String show = show(school); - given().post(show + "/schedule").then().statusCode(200); + given().post(show + "/schedule").then().statusCode(202); + awaitScheduling(show); given().post(show + "/finalize").then().statusCode(200); final long coach = given().contentType("application/json") .body(Map.of("dancerId", dancer)) @@ -133,4 +134,13 @@ class S18ResourceTest { given().delete(school).then().statusCode(204); } } + + private static void awaitScheduling(final String show) { + final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15); + while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state")) + && System.nanoTime() < deadline) { + java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20)); + } + given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL")); + } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java new file mode 100644 index 0000000..40a62e6 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingEventsTest.java @@ -0,0 +1,141 @@ +package nl.agilecc.agiledancers; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +import nl.agilecc.agiledancers.service.SchoolManagementService; +import nl.agilecc.agiledancers.service.model.GroupDetails; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class SchedulingEventsTest { + private @Inject SchoolManagementService schools; + private @Inject ObjectMapper json; + private @TestHTTPResource URI base; + + public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception { + final long school = schools.createSchool("SSE school").getId(); + try { + final Show show = show(school); + final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule"; + given().get(path).then().statusCode(204); + try (final HttpClient client = HttpClient.newHttpClient(); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final InputStream stream = stream(client); + final BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + assertNotNull(next(reader).get("runs")); + final String runId = given().post(path) + .then() + .statusCode(202) + .extract() + .jsonPath() + .getString("runId"); + final JsonNode completion = + executor.submit(() -> completion(reader, runId)).get(15, TimeUnit.SECONDS); + assertEquals("OPTIMAL", completion.get("state").asText()); + assertTrue(completion.get("progress").get("feasible").asBoolean()); + given().get(path).then().statusCode(200); + } + assertReconnectContains(show.getId()); + } finally { + schools.deleteSchool(school); + } + } + + private void assertReconnectContains(final long showId) throws Exception { + try (final HttpClient client = HttpClient.newHttpClient(); + final InputStream stream = stream(client); + final BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + final JsonNode runs = next(reader).get("runs"); + boolean found = false; + for (final JsonNode run : runs) { + if (run.get("showId").asLong() == showId) { + assertEquals("OPTIMAL", run.get("state").asText()); + found = true; + } + } + assertTrue(found); + } + } + + private InputStream stream(final HttpClient client) throws Exception { + final HttpResponse response = client.send( + HttpRequest.newBuilder(base.resolve("/api/scheduling/events")) + .header("Accept", "text/event-stream") + .build(), + HttpResponse.BodyHandlers.ofInputStream()); + assertEquals(200, response.statusCode()); + assertTrue(response.headers().firstValue("content-type").orElse("").startsWith("text/event-stream")); + return response.body(); + } + + private JsonNode completion(final BufferedReader reader, final String runId) throws Exception { + while (true) { + final JsonNode event = next(reader); + if (event.get("runs").isNull()) { + continue; + } + for (final JsonNode run : event.get("runs")) { + if (run.get("runId").asText().equals(runId) + && !run.get("state").asText().equals("RUNNING")) { + return run; + } + } + } + } + + private JsonNode next(final BufferedReader reader) throws Exception { + String line = reader.readLine(); + while (line != null && !line.startsWith("data:")) { + line = reader.readLine(); + } + assertNotNull(line); + return json.readTree(line.substring(5).trim()); + } + + private Show show(final long school) { + final long group = schools.createGroup( + school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())) + .getId(); + schools.addGroupDancer( + school, + group, + schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId()); + return schools.createShow( + school, + "Show", + List.of(DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build()), + null); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingProgressTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingProgressTest.java new file mode 100644 index 0000000..1b7697b --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingProgressTest.java @@ -0,0 +1,60 @@ +package nl.agilecc.agiledancers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; +import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary; +import nl.agilecc.agiledancers.model.scheduling.VarietyScore; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.service.SchedulingFailure; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; + +class SchedulingProgressTest { + public @Test void realSolverReportsDiscoveryDuringEachPhase() { + final SchedulingInput input = new SchedulingInput(1, 0, 2, List.of(new GroupRequirement(2, 1))); + final List updates = new ArrayList<>(); + final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add); + assertTrue(result.optimal()); + assertEquals( + List.of("ATTENDANCE", "SPREAD", "VARIETY"), + updates.stream().map(SchedulingProgress::phase).distinct().toList()); + assertEquals(new SchedulingProgress("ATTENDANCE", false), updates.getFirst()); + assertTrue(updates.contains(new SchedulingProgress("ATTENDANCE", true))); + assertTrue(updates.contains(new SchedulingProgress("VARIETY", true))); + } + + public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() { + final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 1))); + assertEquals( + SchedulingFailureKind.NO_SOLUTION, + assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine(0).solve(input)) + .getOutcome()); + final SchedulingInput impossible = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 2))); + assertEquals( + SchedulingFailureKind.INFEASIBLE, + assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine().solve(impossible)) + .getOutcome()); + } + + public @Test void summaryMatchesTheThreeObjectiveDefinitionsAndCoachExemptions() { + final GroupRequirement showcase = new GroupRequirement(1, 1, true, Set.of(10L, 11L), Set.of(), 20); + final GroupRequirement regular = + new GroupRequirement(2, 2, false, Set.of(10L, 11L), Set.of(DanceGroupStyle.MODERN), 22); + final SchedulingInput input = new SchedulingInput(1, 0, 3, List.of(showcase, regular), 1, Set.of(11L)); + final SchedulingResult result = new SchedulingResult(false, List.of(List.of(1L, 2L), List.of(2L), List.of())); + final SchedulingSummary summary = SchedulingSummary.of(input, result); + assertEquals(1, summary.attendanceExcess()); + assertEquals(2, summary.performanceSpread()); + assertEquals(VarietyScore.penalty(showcase, regular), summary.varietyPenalty()); + } +} diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java index 5b5e662..47f53b5 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -175,7 +175,8 @@ class SchoolManagementResourceTest { given().put(school + "/dance-groups/" + group + "/dancers/" + dancer) .then() .statusCode(204); - given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true)); + given().post(show + "/schedule").then().statusCode(202).body("state", equalTo("RUNNING")); + awaitScheduling(show); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); final String before = given().get(show).asString(); for (final String count : new String[] {"0", "2", "1.5", "7"}) { @@ -291,4 +292,13 @@ class SchoolManagementResourceTest { private static ValidatableResponse put(final String path, final Object body) { return given().contentType("application/json").body(body).put(path).then(); } + + private static void awaitScheduling(final String show) { + final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15); + while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state")) + && System.nanoTime() < deadline) { + java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20)); + } + given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL")); + } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java new file mode 100644 index 0000000..bc91305 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingRunsTest.java @@ -0,0 +1,180 @@ +package nl.agilecc.agiledancers.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.model.Show; +import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +import nl.agilecc.agiledancers.service.model.GroupDetails; +import nl.agilecc.agiledancers.service.model.SchedulingRun; +import nl.agilecc.agiledancers.service.port.SchedulingEngine; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; + +class SchedulingRunsTest { + private final SchoolManagementService schools = new SchoolManagementService(); + private final long school = schools.createSchool("School").getId(); + + public @Test void startsInBackgroundRejectsDuplicatesAndRecoversCurrentStatus() throws Exception { + final Show show = show(); + final var entered = new CountDownLatch(1); + final var release = new CountDownLatch(1); + final var updates = new AtomicReference>(); + final var runs = new SchedulingRuns(schools, blocking(entered, release)); + try { + final Runnable unsubscribe = runs.subscribe(updates::set); + assertTrue(updates.get().isEmpty()); + final SchedulingRun started = runs.start(school, show.getId()); + assertEquals("RUNNING", started.state()); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + assertEquals(started, runs.status(school, show.getId())); + assertThrows(ManagementException.class, () -> runs.start(school, show.getId())); + unsubscribe.run(); + final var reconnect = new AtomicReference>(); + final Runnable disconnect = runs.subscribe(reconnect::set); + assertEquals(started, reconnect.get().getFirst()); + release.countDown(); + final SchedulingRun finished = await(runs, show); + assertEquals("OPTIMAL", finished.state()); + assertEquals(0, finished.summary().performanceSpread()); + assertTrue(finished.progress().feasible()); + assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0); + assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty()); + assertEquals("RUNNING", updates.get().getFirst().state()); + disconnect.run(); + assertNotEquals(started.runId(), runs.start(school, show.getId()).runId()); + await(runs, show); + } finally { + release.countDown(); + runs.close(); + } + } + + public @Test void differentShowsRunConcurrentlyAndEditsRejectStaleResults() throws Exception { + final Show first = show(); + final Show second = show(); + final var entered = new CountDownLatch(2); + final var release = new CountDownLatch(1); + final var runs = new SchedulingRuns(schools, blocking(entered, release)); + try { + runs.start(school, first.getId()); + runs.start(school, second.getId()); + assertTrue(entered.await(10, TimeUnit.SECONDS)); + schools.touch(first); + release.countDown(); + assertEquals("STALE", await(runs, first).state()); + assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty()); + assertEquals("OPTIMAL", await(runs, second).state()); + } finally { + release.countDown(); + runs.close(); + } + } + + public @Test void failuresRemainVisibleAndReleaseTheShowForRetry() { + final Show show = show(); + final List engines = new ArrayList<>(); + engines.add((final SchedulingInput input) -> { + throw new IllegalStateException("failure"); + }); + engines.add((final SchedulingInput input) -> { + throw new UnsatisfiedLinkError("native"); + }); + engines.add((final SchedulingInput input) -> null); + for (final SchedulingFailureKind kind : SchedulingFailureKind.values()) { + engines.add((final SchedulingInput input) -> { + throw new SchedulingFailure(kind, "Explained outcome"); + }); + } + for (final SchedulingEngine engine : engines) { + assertFailure(show, engine); + } + } + + private void assertFailure(final Show show, final SchedulingEngine engine) { + final var runs = new SchedulingRuns(schools, engine); + try { + runs.start(school, show.getId()); + final SchedulingRun failed = await(runs, show); + assertNotEquals("RUNNING", failed.state()); + assertTrue(failed.message().contains("behouden")); + assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty()); + schools.endScheduling(schools.beginScheduling(school, show.getId()).showId()); + } finally { + runs.close(); + } + } + + public @Test void validUnprovenResultIsAppliedAndShutdownRejectsNewWorkWithoutLeakingGuard() { + final Show show = show(); + final SchedulingEngine engine = (final SchedulingInput input) -> new SchedulingResult( + false, new OrToolsSchedulingEngine().solve(input).assignments()); + final var runs = new SchedulingRuns(schools, engine); + runs.start(school, show.getId()); + assertEquals("VALID", await(runs, show).state()); + runs.close(); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId())); + assertEquals("SOLVER_FAILED", runs.status(school, show.getId()).state()); + schools.endScheduling(schools.beginScheduling(school, show.getId()).showId()); + } + + private Show show() { + final long group = schools.createGroup( + school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of())) + .getId(); + schools.addGroupDancer( + school, + group, + schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId()); + return schools.createShow( + school, + "Show", + List.of(DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build()), + null); + } + + private SchedulingEngine blocking(final CountDownLatch entered, final CountDownLatch release) { + return (final SchedulingInput input) -> { + entered.countDown(); + try { + assertTrue(release.await(10, TimeUnit.SECONDS)); + } catch (final InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + // The real solver's callbacks are covered separately; exercise the default port overload here. + return new OrToolsSchedulingEngine().solve(input); + }; + } + + private SchedulingRun await(final SchedulingRuns runs, final Show show) { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + SchedulingRun status = runs.status(school, show.getId()); + while (status.state().equals("RUNNING") && System.nanoTime() < deadline) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10)); + status = runs.status(school, show.getId()); + } + assertNotEquals("RUNNING", status.state()); + return status; + } +} diff --git a/agiledancers-fe/e2e/school-management.spec.ts b/agiledancers-fe/e2e/school-management.spec.ts index 1f3ed07..e32d0a3 100644 --- a/agiledancers-fe/e2e/school-management.spec.ts +++ b/agiledancers-fe/e2e/school-management.spec.ts @@ -143,8 +143,13 @@ test('manages a school, shared memberships and shows through the real REST API', await page.getByRole('button', { name: /Schedule groups/ }).click(); await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible(); await expect( - page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }), + page.locator('.scheduling-progress').filter({ hasText: 'Optimaal conceptrooster.' }), ).toBeVisible(); + await expect(page.locator('.scheduling-snackbar')).toContainText('Zomershow'); + await expect(page.locator('.scheduling-progress')).toContainText('Spreiding:'); + await page.reload(); + await expect(page.locator('.scheduling-progress')).toContainText('Optimaal conceptrooster.'); + await expect(page.locator('.scheduling-snackbar')).toHaveCount(0); await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click(); const scheduleDialog = page.getByRole('dialog'); await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click(); diff --git a/agiledancers-fe/nginx/default.conf.template b/agiledancers-fe/nginx/default.conf.template index 082052e..4c5296b 100644 --- a/agiledancers-fe/nginx/default.conf.template +++ b/agiledancers-fe/nginx/default.conf.template @@ -5,6 +5,10 @@ server { index index.html; location /api/ { + proxy_buffering off; + proxy_cache off; + proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_read_timeout 1860s; proxy_send_timeout 1860s; proxy_pass http://${BACKEND_UPSTREAM}; diff --git a/agiledancers-fe/src/app/app.config.ts b/agiledancers-fe/src/app/app.config.ts index 9417602..72abddf 100644 --- a/agiledancers-fe/src/app/app.config.ts +++ b/agiledancers-fe/src/app/app.config.ts @@ -11,7 +11,10 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideHttpClient(), - provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })), + provideRouter( + routes, + withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }), + ), { provide: LOCALE_ID, useValue: 'nl-NL' }, ], }; diff --git a/agiledancers-fe/src/app/app.css b/agiledancers-fe/src/app/app.css index c3ffd89..fd4c3c5 100644 --- a/agiledancers-fe/src/app/app.css +++ b/agiledancers-fe/src/app/app.css @@ -237,3 +237,30 @@ main { margin: 0 20px; } } + +.scheduling-snackbar { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 1000; + display: flex; + align-items: center; + gap: 1rem; + max-width: min(40rem, calc(100vw - 3rem)); + padding: 1rem 1.5rem; + background: #193b33; + color: white; + border-radius: 0.75rem; + box-shadow: 0 0.25rem 1rem #0003; +} +.scheduling-snackbar a { + color: white; + text-decoration: underline; +} +.scheduling-snackbar button { + background: transparent; + color: white; + border: 0; + cursor: pointer; + font-size: 1.5rem; +} diff --git a/agiledancers-fe/src/app/app.html b/agiledancers-fe/src/app/app.html index 5ff0ead..1ce8710 100644 --- a/agiledancers-fe/src/app/app.html +++ b/agiledancers-fe/src/app/app.html @@ -44,3 +44,19 @@ + +@if (scheduling.notifications()[0]; as run) { + +} diff --git a/agiledancers-fe/src/app/app.spec.ts b/agiledancers-fe/src/app/app.spec.ts index 745ec80..019f5fc 100644 --- a/agiledancers-fe/src/app/app.spec.ts +++ b/agiledancers-fe/src/app/app.spec.ts @@ -1,12 +1,17 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { App } from './app'; +import { SchedulingStatus } from './core/scheduling-status'; +import { signal } from '@angular/core'; describe('Application shell', () => { it('provides navigation and a keyboard skip link', async () => { await TestBed.configureTestingModule({ imports: [App], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { provide: SchedulingStatus, useValue: { connect: vi.fn(), notifications: signal([]) } }, + ], }).compileComponents(); const fixture = TestBed.createComponent(App); await fixture.whenStable(); diff --git a/agiledancers-fe/src/app/app.ts b/agiledancers-fe/src/app/app.ts index 8e3eeeb..0698fc5 100644 --- a/agiledancers-fe/src/app/app.ts +++ b/agiledancers-fe/src/app/app.ts @@ -1,4 +1,5 @@ -import { Component } from '@angular/core'; +import { SchedulingStatus } from './core/scheduling-status'; +import { Component, inject, OnInit } from '@angular/core'; import { RouterLink, RouterOutlet } from '@angular/router'; @Component({ @@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router'; templateUrl: './app.html', styleUrl: './app.css', }) -export class App {} +export class App implements OnInit { + readonly scheduling = inject(SchedulingStatus); + + ngOnInit(): void { + this.scheduling.connect(); + } +} diff --git a/agiledancers-fe/src/app/core/scheduling-status.spec.ts b/agiledancers-fe/src/app/core/scheduling-status.spec.ts new file mode 100644 index 0000000..70acd93 --- /dev/null +++ b/agiledancers-fe/src/app/core/scheduling-status.spec.ts @@ -0,0 +1,95 @@ +import { TestBed } from '@angular/core/testing'; +import { SchedulingRun, SchedulingStatus } from './scheduling-status'; + +class FakeEventSource { + static current: FakeEventSource; + readonly close = vi.fn(); + onmessage?: (event: { data: string }) => void; + onerror?: () => void; + constructor(readonly url: string) { + FakeEventSource.current = this; + } + send(runs: SchedulingRun[] | null): void { + this.onmessage?.({ data: JSON.stringify({ runs }) }); + } +} + +function run(state: string, runId = 'run-1'): SchedulingRun { + return { + runId, + schoolId: 1, + showId: 2, + showName: 'Zomershow', + startedAt: '2026-11-03T12:00:00Z', + finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z', + state, + progress: { phase: 'VARIETY', feasible: true }, + message: 'Afgerond', + summary: null, + }; +} + +describe('Scheduling status over SSE', () => { + let status: SchedulingStatus; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('EventSource', FakeEventSource); + TestBed.configureTestingModule({}); + status = TestBed.inject(SchedulingStatus); + status.connect(); + }); + afterEach(() => { + TestBed.resetTestingModule(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('recovers an existing outcome without replaying old notifications', () => { + expect(FakeEventSource.current.url).toBe('/api/scheduling/events'); + FakeEventSource.current.send([run('OPTIMAL')]); + expect(status.run(1, 2)?.state).toBe('OPTIMAL'); + expect(status.notifications()).toEqual([]); + expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s'); + expect(status.phase(run('OPTIMAL'))).toBe('Variatie'); + }); + + it('announces a completion missed during disconnection exactly once', () => { + FakeEventSource.current.send([run('RUNNING')]); + FakeEventSource.current.onerror?.(); + expect(status.connected()).toBe(false); + const disconnected = FakeEventSource.current; + vi.advanceTimersByTime(3000); + expect(FakeEventSource.current).not.toBe(disconnected); + expect(disconnected.close).toHaveBeenCalledOnce(); + expect(status.run(1, 2)?.state).toBe('RUNNING'); + FakeEventSource.current.send([run('VALID')]); + expect(status.connected()).toBe(true); + expect(status.notifications()).toHaveLength(1); + status.dismiss(); + FakeEventSource.current.send([run('VALID')]); + expect(status.notifications()).toEqual([]); + }); + + it('does not treat heartbeats or phase changes as completions', () => { + FakeEventSource.current.send(null); + FakeEventSource.current.send([run('OPTIMAL')]); + expect(status.notifications()).toEqual([]); + FakeEventSource.current.send([run('RUNNING', 'run-2')]); + FakeEventSource.current.send(null); + expect(status.run(1, 2)?.runId).toBe('run-2'); + expect(status.notifications()).toEqual([]); + FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]); + expect(status.notifications()).toHaveLength(1); + }); + + it('retains a fast completion when the start response arrives after SSE', () => { + FakeEventSource.current.send([]); + FakeEventSource.current.send([run('OPTIMAL')]); + status.started(run('RUNNING')); + expect(status.run(1, 2)?.state).toBe('OPTIMAL'); + status.connect(); + const source = FakeEventSource.current; + TestBed.resetTestingModule(); + expect(source.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/agiledancers-fe/src/app/core/scheduling-status.ts b/agiledancers-fe/src/app/core/scheduling-status.ts new file mode 100644 index 0000000..514a43f --- /dev/null +++ b/agiledancers-fe/src/app/core/scheduling-status.ts @@ -0,0 +1,107 @@ +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; + +export interface SchedulingRun { + runId: string; + schoolId: number; + showId: number; + showName: string; + startedAt: string; + finishedAt: string | null; + state: string; + progress: { phase: string; feasible: boolean }; + message: string; + summary: { + attendanceExcess: number; + performanceSpread: number; + varietyPenalty: number; + } | null; +} + +@Injectable({ providedIn: 'root' }) +export class SchedulingStatus { + private readonly destroyRef = inject(DestroyRef); + readonly runs = signal([]); + readonly connected = signal(false); + readonly notifications = signal([]); + readonly now = signal(Date.now()); + private source?: EventSource; + private reconnectTimer?: ReturnType; + private initialized = false; + private readonly seen = new Set(); + + connect(): void { + if (this.source) return; + this.open(); + const timer = setInterval(() => this.now.set(Date.now()), 1000); + this.destroyRef.onDestroy(() => { + this.source?.close(); + clearTimeout(this.reconnectTimer); + clearInterval(timer); + }); + } + + private open(): void { + const source = new EventSource('/api/scheduling/events'); + this.source = source; + source.onmessage = (event: MessageEvent) => { + const eventData = JSON.parse(event.data) as { runs: SchedulingRun[] | null }; + if (eventData.runs !== null) this.accept(eventData.runs); + this.connected.set(true); + }; + source.onerror = () => { + this.connected.set(false); + // Retry also when a proxy error makes native EventSource reconnection stop permanently. + source.close(); + clearTimeout(this.reconnectTimer); + this.reconnectTimer = setTimeout(() => this.open(), 3000); + }; + } + + private accept(runs: SchedulingRun[]): void { + for (const run of runs) { + if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue; + this.seen.add(run.runId); + if (this.initialized) this.notifications.update((items) => [...items, run]); + } + this.initialized = true; + this.runs.set(runs); + } + + run(schoolId: number, showId: number): SchedulingRun | undefined { + return this.runs().find((run) => run.schoolId === schoolId && run.showId === showId); + } + + started(run: SchedulingRun): void { + // The SSE completion can arrive before the POST response. Never overwrite it with RUNNING. + if (this.runs().some((item) => item.runId === run.runId)) return; + this.runs.update((items) => [...items.filter((item) => item.showId !== run.showId), run]); + } + + elapsed(run: SchedulingRun): string { + const seconds = Math.max( + 0, + Math.floor( + ((run.finishedAt ? Date.parse(run.finishedAt) : this.now()) - Date.parse(run.startedAt)) / + 1000, + ), + ); + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + } + + phase(run: SchedulingRun): string { + return ( + ( + { + PREPARING: 'Voorbereiden', + ATTENDANCE: 'Aanwezigheid', + SPREAD: 'Spreiding', + VARIETY: 'Variatie', + } as Record + )[run.progress.phase] ?? run.progress.phase + ); + } + + dismiss(): void { + this.notifications.update((items) => items.slice(1)); + } +} diff --git a/agiledancers-fe/src/app/core/school-api.spec.ts b/agiledancers-fe/src/app/core/school-api.spec.ts index b892189..96613ea 100644 --- a/agiledancers-fe/src/app/core/school-api.spec.ts +++ b/agiledancers-fe/src/app/core/school-api.spec.ts @@ -73,11 +73,11 @@ describe('SchoolApi contract', () => { .flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' }); expect(failure).toHaveBeenCalledOnce(); }); - it('schedules synchronously with room for the 30-minute solver limit', () => { + it('starts a background calculation without a solver-length HTTP timeout', () => { 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); + expect(request.request.timeout).toBeUndefined(); request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' }); }); }); diff --git a/agiledancers-fe/src/app/core/school-api.ts b/agiledancers-fe/src/app/core/school-api.ts index 07cc84b..457337e 100644 --- a/agiledancers-fe/src/app/core/school-api.ts +++ b/agiledancers-fe/src/app/core/school-api.ts @@ -1,3 +1,4 @@ +import { SchedulingRun } from './scheduling-status'; import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { forkJoin, Observable } from 'rxjs'; @@ -47,12 +48,8 @@ export class SchoolApi { saveShow(schoolId: number, body: ShowRequest, id?: number): Observable { 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 }, - ); + schedule(schoolId: number, showId: number): Observable { + return this.http.post(`${this.base}/${schoolId}/shows/${showId}/schedule`, null); } saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable { return this.http.put(`${this.base}/${schoolId}/shows/${showId}/assignments`, { diff --git a/agiledancers-fe/src/app/schools/school-page.html b/agiledancers-fe/src/app/schools/school-page.html index c7e3d41..92c1fa8 100644 --- a/agiledancers-fe/src/app/schools/school-page.html +++ b/agiledancers-fe/src/app/schools/school-page.html @@ -344,7 +344,7 @@
@for (show of shows(); track show.id) { -
+
{{ firstDate(show) | date: 'dd' }}{{ firstDate(show) | date: 'MMM yyyy' }} @@ -372,6 +372,48 @@ : 'Geen geldige planning. Plan de groepen opnieuw in.' }}

+ @if (schedulingStatus.run(context.school.id, show.id); as run) { +
+ @if (!schedulingStatus.connected()) { +

+ Verbinding verbroken. Status mogelijk verouderd; de berekening loopt door. +

+ } + @if (run.state === 'RUNNING') { +

+ Bezig met inplannen · {{ schedulingStatus.phase(run) }} · + {{ schedulingStatus.elapsed(run) }} +

+

+ {{ + run.progress.feasible + ? 'Geldige oplossing gevonden; optimalisatie loopt door.' + : 'Nog geen geldige oplossing gevonden.' + }} +

+ } @else { +

{{ run.message }}

+

+ Afgerond: {{ run.finishedAt | date: 'dd-MM-yyyy HH:mm:ss' }} · Duur: + {{ schedulingStatus.elapsed(run) }} +

+ @if (run.summary; as summary) { +

+ Aanwezigheid: {{ summary.attendanceExcess }} extra dagdelen boven voorkeur + (showcasedansers, exclusief coaches). +

+

+ Spreiding: {{ summary.performanceSpread }} optredens verschil tussen drukste + en rustigste dagdeel. +

+

+ Variatie: {{ summary.varietyPenalty }} strafpunten voor opeenvolgende + groepen (lager is beter). +

+ } + } +
+ }