Implementation plan for a JavaFX desktop application that plans altar server (ministrant) assignments for a Catholic parish. This document is written so that a developer or another coding agent can execute it milestone by milestone.
MinDis manages a parish's altar servers and liturgical services, and automatically computes fair, constraint-respecting serving schedules.
Core use cases:
- Maintain a roster of altar servers (names, contact, family/siblings, qualifications, availability).
- Maintain liturgical services (Sunday/weekday masses, feasts, weddings, funerals) with required roles and headcounts.
- Automatically generate an assignment plan for a planning horizon (e.g. one month) using Timefold Solver, respecting hard rules and optimizing soft preferences.
- Manually pin/override assignments and re-solve around them.
- Export/print the resulting plan (PDF, later e-mail).
The application is multilingual from the start (German + English; parish context) — see §2.3.
| Concern | Choice | Version (as of 2026-07) | Notes |
|---|---|---|---|
| Language / JDK | Java (LTS) | 25 | Toolchain-managed via Gradle; GraalVM toolchain only in M7 |
| UI toolkit | JavaFX | 26.0.1 | Module path; platform jars via org.openjfx.javafxplugin (no Gradle metadata upstream) |
| Window/shell UI | Bespoke shell in org.mindis.gui.shell, wrapped in a GemsFX PowerPane for dialogs/notifications/drawer |
— | No WorkbenchFX dependency; see ADR 005 and §4.1. |
| Theme | AtlantaFX io.github.mkpaz:atlantafx-base |
2.x | Proper JPMS module atlantafx.base |
| View layer | Plain Java — views are JavaFX Parent subclasses built in their constructors |
— | No FXML, no view-layer reflection — see §2.1. |
| Dependency injection | Avaje Inject io.avaje:avaje-inject (+ avaje-inject-generator APT) |
12.6 | Compile-time DI: generated wiring, zero runtime reflection (§2.2-safe) — see §2.4. |
| Planning engine | Timefold Solver ai.timefold.solver:timefold-solver-core |
2.x | JPMS-supported since 2.0 |
| Build system | Gradle (Kotlin DSL) + GradleX plugins | Gradle 9.x | JabRef-style setup (see §5) |
| Native compilation | GraalVM Native Image via GluonFX Gradle plugin (or org.graalvm.buildtools.native + Gluon static JavaFX libs) |
latest | Final milestone (M7) only. Until then: just don't block it (§2.2) |
| Persistence | Jackson (JSON files in user data dir) | 2.x | Simple start; DB later if needed |
| Logging | SLF4J + Logback | latest | |
| Code style | JabRef code style, enforced via Checkstyle | — | See §8 |
| Testing | JUnit 5, TestFX (UI), Timefold test API | latest | org.gradlex.java-module-testing |
Views are ordinary JavaFX Parent subclasses assembled in their constructors, taking their
collaborators as parameters. There is no FXML in the project and no FXML library on the class path.
Every screen is a ShellModule, and the two shared base classes (CrudModule, AppShell) are
parameterised by behavior rather than layout — which columns, which toolbar buttons, which editor a
row gets — which is not something markup expresses. The screens with the most UI in them build
their structure from the data at runtime anyway (a tile per service, a slot row per role, a
checkbox per live role).
What this buys:
- No reflection in the view layer at all — §2.2's allowed consumers drop to two (Jackson, Timefold), and there is no FXML reachability metadata to maintain if M7 is revisited.
- A closed JPMS module —
org.mindis.guiopens only its root package, only tojavafx.graphics, only because the JavaFX launcher instantiatesApplicationreflectively. - One localization mechanism —
Localization.lang(...)everywhere, no%keymarkup attributes for the extraction task to also understand. - Layout errors at compile time rather than as load-time exceptions.
Accepted cost: no SceneBuilder, and no hot reload of a view without a restart.
docs/adr/001-view-layer.md records the decision, the alternatives (runtime FXML via FxmlKit;
compiled FXML via jfxcore FXML/2) and the revisit triggers.
Native image work happens exclusively in M7, the very last milestone. No metadata files, no tracing-agent tasks, no native CI jobs before that. During M0–M6 the only obligation is to avoid patterns that would make M7 hard, because GraalVM Native Image is a closed-world AOT compiler:
- No dynamic classloading /
Class.forNameon computed names. Service lookup via explicit registration, not classpath scanning. - Keep reflection confined to two known consumers: Jackson and Timefold. Nothing else may reflect — no hand-rolled reflective utilities.
- Prefer reflection-free alternatives where cheap: Jackson with explicit
@JsonCreator/records; Timefold constraint streams (no drools); avoidjava.util.ServiceLoaderwhere a direct call works. - No runtime code generation, no dynamic proxies. Build-time code generation is allowed only via the approved annotation processor (Avaje Inject, §2.4) — it emits plain Java that AOT-compiles like hand-written code.
Everything else (reachability metadata, tracing agent, GluonFX wiring, native CI) is M7 scope. jpackage/JLink is the primary shipping path until native image proves itself there.
The app is multilingual (initially en, de). The translation key is the full English
text, never an abstract key — both in code and in markup. This follows JabRef's
Localization.lang(...) pattern:
// yes
saveButton.setText(Localization.lang("Save plan"));
statusLabel.setText(Localization.lang("%0 of %1 slots assigned", assigned, total));
// no
saveButton.setText(bundle.getString("planning.toolbar.save"));Implementation (in mindis-core or a tiny org.mindis.l10n package in gui):
Localization.lang(String englishText, Object... params)— looks up the English text in the current locale's bundle; falls back to the English text itself when no translation exists, so the UI never shows raw keys.- Bundles:
MinDis_en.properties(identity mapping, generated/checked),MinDis_de.properties(English text as key — escape spaces/=/:per properties format, exactly as JabRef does). - Positional placeholders
%0,%1(JabRef convention) rather thanMessageFormatquirks. - A build check (Gradle task, like JabRef's localization tests) verifies: every
Localization.lang("...")literal exists in the bundles, no unused/duplicate entries, parameter counts match. - One mechanism only: every user-visible string goes through
Localization.lang(...)in Java. There is no markup layer with its own%keybinding (§2.1), so the extraction task has one syntax to understand. Fallback: set strings from the controller viaLocalization.lang(...). - Rule from M1 on: no hardcoded user-visible string outside
Localization.lang(...)calls. Locale switchable in Settings at runtime. Localizationlives inmindis-core— a future web module (§2.5) reuses it unchanged.
Avaje Inject is the DI framework from M0 on. Chosen because it is
the only mainstream option that satisfies §2.2: wiring is generated by an annotation processor
at compile time (plain Java source — inspectable, debuggable), no reflection, no classpath
scanning, no dynamic proxies at runtime; JPMS-friendly (module io.avaje.inject); tiny
runtime footprint; native-image safe by construction.
Usage pattern:
- Services, repositories,
PlanningService,Localization-backed helpers:@Singletoncomponents with constructor injection. External/config-driven objects via@Factory+@Beanmethods. - One
BeanScopecreated inMinDisApp.start(); closed on shutdown (AutoCloseablebeans get lifecycle for free). - No framework bridge: the composition root resolves what a screen needs with
beanScope.get(...)and passes it to the view's constructor. View models are plain@Singleton/@Prototypebeans with constructor injection; nothing resolves a dependency by reaching for a global. - Each JPMS module with beans declares the processor;
module-infogetsrequires io.avaje.inject;. Verify processor-on-module-path setup in the M0 spike. - Tests:
avaje-inject-testfor scope-per-test with mock overrides (@InjectTest). - Scope discipline: beans live in
mindis-core(services) andmindis-gui(controllers, view models). No JavaFX types in core beans (§2.5).
A browser-based UI is a future option, not current scope. Nothing web-related is built in
M0–M7 — but the module cut is chosen now so a web module can be added later without touching
mindis-core:
mindis-coremust not depend on JavaFX UI modules.javafx.base(properties, observable collections — headless-safe) is allowed;javafx.controls,javafx.graphics,javafx.sceneetc. are banned — enforced by CheckstyleIllegalImport(UI packages) and module-info review. Domain model stays plain Java/records — records are always immutable and never carry properties or observables;mindis-guiwraps domain objects in its own observable view models.- All business capability lives in core: domain, validation, repositories, solver
(
PlanningService), localization, PDF export. UI modules are thin adapters over core services. Litmus test: a CLI could be written againstmindis-corealone. - Async API shape: core services expose plain types,
CompletableFuture/callbacks or listener interfaces — not JavaFXObservableValue. GUI adapts these onto the FX thread; a web module would adapt them onto HTTP/WebSocket. - Future
mindis-web(when it comes): own JPMS module next tomindis-gui, same pattern —requires org.mindis.core, Avaje Inject reused server-side (it is a general DI container, not FX-bound). Candidate stacks recorded indocs/adr/003-web-ui-path.md(seeded in M0, decision deferred): lightweight Java server (Javalin/Helidon SE) + HTMX or REST+SPA; alternatively JPro (JavaFX-in-browser) as low-effort bridge reusingmindis-gui. - Persistence note: JSON-file store is single-user desktop scope. ADR-003 must revisit storage (server DB, multi-user, auth) — another reason repositories stay behind core interfaces.
User-editable settings (locale, theme, data directory, solver time budget, constraint weights, window geometry) are handled by a small core-owned mechanism — no preferences framework:
org.mindis.core.preferences: immutable recordMinDisPreferences(all settings, sensible defaults) +PreferencesService(Avaje@Singleton): load on first access,update(...)with atomic write (temp file + move), change listeners via plain core listener interface (§2.5 — noObservableValuein core).- Storage:
preferences.jsonvia Jackson in the user data dir (%APPDATA%/MinDis/ XDG) — same serializer, same directory as the M2 repositories. Corrupt/missing file ⇒ defaults + warning, never a crash. - GUI: thin adapter wraps
PreferencesServiceinto JavaFX properties for bindings; Settings shell edits them. Locale + theme are applied at startup before the first scene. - Versioning: record carries a
versionfield; migrations are explicit code, no magic.
Architecture refined (2026-07-06, ADR-006): gui side is a PreferenceValue<T> registry
(each setting defined once: getter + wither; load/write-through/re-sync generic) and enum
values are self-describing via PreferenceEnumValue (displayName/isSelectable, generic
choiceBox).
Rejected: java.util.prefs (Windows Registry backend — invisible, no backup, stringly-typed),
PreferencesFX (JavaFX-coupled + FormsFX baggage + stores via java.util.prefs anyway),
avaje-config (read-oriented app config, no user save-back),
JShepherd (closest contender: maintained, JPMS
module-infos, smart config merging, comment support — but annotation/reflection driven, which
would add a fourth reflection consumer against §2.2, uses ServiceLoader for format modules,
leaks a ConfigurablePojo base type into core, has no change-listener API so the GUI adapter
must be hand-written anyway, and is single-maintainer; its merge feature mainly solves a
problem our small versioned record does not have). Revisit JShepherd if preferences grow into
large user-edited config files where comments and smart merging pay off. Recorded in
docs/adr/004-preferences.md (written in M1).
Package: org.mindis.core.model (plain domain) and org.mindis.core.planning (solver types).
Plain Java/records only — no JavaFX property types (§2.5); GUI wraps domain in observable view
models on its side.
- Server — id, first/last name, contact, birth date,
familyId(siblings link),Set<Role> qualifications,List<UnavailabilityPeriod>(vacations, blocked weekdays), preferences (preferred mass times), active flag. - Role — enum or configurable entity: e.g.
ACOLYTE,CROSS_BEARER,THURIFER,BOAT_BEARER,MC. Start as enum; make configurable later. Display names viaLocalization.lang(...)(§2.3), nevername(). - LiturgicalService — id, date/time, duration, location (church), type
(
SUNDAY_MASS,WEEKDAY_MASS,FEAST,WEDDING,FUNERAL, …), list of required role slots (role + count), notes. - PlanningHorizon — date range being solved.
@PlanningEntity
public class Assignment {
@PlanningId String id;
LiturgicalService service; // fixed
Role role; // fixed
@PlanningVariable Server server; // assigned by solver (nullable = unassigned allowed)
boolean pinned; // manual override support (@PlanningPin)
}One Assignment per required role slot per service. Solution class ServicePlan with
@PlanningEntityCollectionProperty, @ValueRangeProvider over active servers, and
HardSoftScore.
Hard:
- Server must be qualified for the assigned role.
- Server must be available (no unavailability overlap with service time).
- No double-booking: one server, one assignment per overlapping service time.
- Server is active.
Soft (weights tunable in settings):
- Fairness — balance assignment count per server over the horizon (load balancing).
- Siblings together — prefer same-family servers assigned to the same service.
- Spacing — penalize assignments on consecutive services/days for the same server.
- Preference match — reward preferred mass times.
- Experience mix — prefer pairing experienced with new servers per service.
Each constraint gets a ConstraintVerifier unit test before UI integration.
Multi-project Gradle build, JabRef-style:
mindis/
├── settings.gradle.kts
├── build.gradle.kts # minimal root; real logic in build-logic
├── build-logic/ # included build: convention plugins
│ ├── settings.gradle.kts
│ ├── build.gradle.kts
│ └── src/main/kotlin/
│ ├── org.mindis.gradle.base.repositories.gradle.kts
│ ├── org.mindis.gradle.feature.compile.gradle.kts # toolchain, javac flags, checkstyle
│ ├── org.mindis.gradle.feature.test.gradle.kts # JUnit 5, module testing
│ ├── org.mindis.gradle.feature.native.gradle.kts # native-image wiring (created in M7)
│ └── org.mindis.gradle.module.gradle.kts # gradlex module plugins wiring
├── gradle/
│ ├── modules.properties # JPMS module name -> Maven GA mappings
│ └── wrapper/
├── versions/ # java-platform project: ALL dependency versions (§5)
├── config/
│ └── checkstyle/checkstyle.xml # JabRef-derived rules (§8)
├── docs/adr/ # architecture decision records
│ ├── 001-view-layer.md # FxmlKit primary, FXML/2 parked (§2.1)
│ └── 003-web-ui-path.md # future web module options, decision deferred (§2.5)
├── mindis-core/ # module: org.mindis.core — UI-AGNOSTIC (§2.5)
│ └── src/main/java/module-info.java
│ # exports model, planning, persistence API, localization, preferences
│ # requires ai.timefold.solver.core, com.fasterxml.jackson.databind, io.avaje.inject
│ # NO javafx.* requires — enforced (§2.5)
│ # opens org.mindis.core.model, .planning to timefold + jackson
├── mindis-gui/ # module: org.mindis.gui (main application)
│ └── src/main/java/module-info.java
│ # requires org.mindis.core, javafx.controls, atlantafx.base,
│ # com.dlsc.gemsfx, io.avaje.inject
│ # contains org.mindis.gui.shell (AppShell/ShellModule/CrudModule) and
│ # org.mindis.gui.data (LiveStore/CsvIO)
│ # opens only its root package to javafx.graphics (Application launcher)
│
└── (future, NOT created now: mindis-web — org.mindis.web, requires org.mindis.core; §2.5)
The shell is written in-repo rather than taken from a library. WorkbenchFX, the obvious candidate,
is unmaintained (last release Jan 2022, Java 11 era, no module-info) and MinDis needs a fraction
of it; GemsFX's HiddenSidesPane is an overlay tray, not a resizable persistent sidebar. Full
rationale and the rejected alternatives: ADR 005.
It lives in mindis-gui, not a module of its own: one consumer, no third-party code to isolate.
org.mindis.gui.shell—AppShellcontainer + builder,ShellModulelifecycle (activate/deactivate/destroy/dispose),CrudModule(shared table+editor screen),ShellOverlays.org.mindis.gui.data—LiveStore,CsvIO,CsvRowMapper(staging layer, shell-independent).
Constraints:
- Dialogs, notifications and the bottom drawer come from GemsFX's
PowerPane, which wraps the shell as the scene root.ShellOverlaysis the access path, constructed in the composition root and injected — never a static or an ambient lookup. - Icons via Ikonli (
org.kordamp.ikonli— modular, actively maintained), never FontAwesomeFX (unmaintained, JPMS-hostile). - CSS against AtlantaFX design tokens (CSS variables), so light/dark follows the AtlantaFX theme with no bridge layer.
- All user-visible strings through
Localization.lang(...)(§2.3) — in the module that owns the screen; the shell scaffolding itself carries no text. - No reflection, no resource-bundle magic (keeps §2.2 rules trivially satisfied).
- No WorkbenchFX code is used, so no Apache-2.0 attribution or NOTICE obligation. If any is ever lifted, keep the upstream license text, a NOTICE entry ("contains code derived from WorkbenchFX, © DLSC Software & Consulting GmbH, Apache-2.0") and per-file copyright headers on derived files.
Key elements copied from the JabRef approach:
- Versions platform (JabRef pattern, no version catalog): all dependency versions live in
versions/build.gradle.kts(java-platformproject — GAV constraints + BOM imports), consumed everywhere viajvmDependencyConflicts { consistentResolution { platform(":versions") } }. JavaFX version is thejavafxVersionGradle property (gradle.properties), read by both the platform andfeature.javafx. build-logicincluded build with convention plugins (org.mindis.gradle.*) applied by subprojects; rootbuild.gradle.ktsstays nearly empty.- GradleX plugins (applied in convention plugins):
org.gradlex.java-module-dependencies— derive Gradle dependencies frommodule-info.java(requires⇒ dependency); custom module-name→GA mappings ingradle/modules.properties. As built (M0): project-plugin mode — project names arecore/gui(dirsmindis-*) sogroup + name = module nameholds.org.gradlex.jvm-dependency-conflict-resolution— wires the:versionsplatform into all resolution (consistentResolution), applied in the module convention plugin.org.gradlex.extra-java-module-info— patch remaining non-modular jars.org.gradlex.java-module-testing— whitebox module testing with JUnit 5.org.gradlex.jvm-dependency-conflict-resolution— sane conflict handling.org.gradlex.java-module-packaging— jpackage-based platform installers (primary shipping path through M6).
- No view-layer library — views are plain Java (§2.1), so
mindis-guicarries no FXML or view-framework dependency at all. 4a. Avaje Inject —avaje-injectas dependency,avaje-inject-generatoronannotationProcessorpath ofmindis-coreandmindis-gui(wired in a convention plugin; mind processor-with-JPMS setup).avaje-inject-testfor tests. - Checkstyle — JabRef-derived
config/checkstyle/checkstyle.xml, wired intofeature.compileconvention plugin; build fails on violation (§8). IncludesIllegalImportrule banningjavafx.*inmindis-core(§2.5). - Localization check task — verifies
Localization.langliterals ↔ bundle entries (§2.3); part ofcheck. - Java toolchain —
java.toolchain.languageVersion = 25+org.gradle.toolchains.foojay-resolver-conventionin settings. - Native image (
org.mindis.gradle.feature.native) — does not exist before M7. Created then with: GraalVM toolchain (Liberica NIK or Gluon GraalVM with JavaFX static libs), GluonFX Gradle plugin (com.gluonhq.gluonfx-gradle-plugin) as the established JavaFX→native path, tracing-agent metadata task, VS Build Tools requirement documented indocs/dev-setup.md. - Run task — standard
applicationplugin with module path (mainModule = "org.mindis.gui",mainClass = "org.mindis.gui.MinDisApp").
| Risk | Impact | Mitigation |
|---|---|---|
| GemsFX single-vendor dependency (DLSC) | Abandonment would strand the date/time pickers, chips and the PowerPane overlays | Each is used behind a thin local wrapper (CalendarPickers, TimePickers, ShellOverlays), so a swap is contained; plain JavaFX equivalents exist for all of them. |
| Bespoke shell = own maintenance burden | Bug fixes on us forever | Keep it minimal (§4.1) — ~300 lines, no features beyond what a screen actually needs; TestFX coverage on shell behavior once the headless harness is solved. |
| GraalVM (all M7): JavaFX native fragility, Timefold AOT (Quarkus-tested, plain-Java less trodden), Jackson reflection | M7 fails or slips | Whole risk deferred to M7 by design — jpackage (M6) is the shipping path and stays regardless, so native is pure upside. In M7: GluonFX plugin, tracing-agent metadata, headless solver spike first. Views are plain Java (§2.1), so they contribute no reachability metadata. Rules §2.2 keep M0–M6 code from making it worse. |
Full-text keys clash with properties format (spaces, =, : need escaping) |
Messy bundle files | Exactly JabRef's trade-off — proven workable; localization check task (§5) catches drift; consider JabRef's tooling for bundle maintenance. |
| Timefold under JPMS | Reflection failures at runtime | opens org.mindis.core.model, org.mindis.core.planning to ai.timefold.solver.core; — covered by solver smoke test in CI. |
Timefold enterprise gating: SolutionManager.analyze(), diff(), recommendAssignment(), multithreaded solving are commercial-only in 2.x — fail at runtime with IllegalStateException, not at compile time |
Silent feature landmines | Discovered in M4. Community-safe substitutes in use: SolutionManager.update() for scores, own ViolationChecker (mirrors hard/medium constraints, shared name constants, unit-tested) for per-assignment display. Rule: any new SolutionManager/solver feature gets a runtime smoke test before UI wiring. |
| Avaje annotation processor + JPMS friction (processor on module path, generated sources in module) | M0 setup pain | Known-workable combo (Avaje documents JPMS use); part of M0 spike — DI-injected controller must work before M1. Fallback: manual composition root (Avaje removal is mechanical — constructor injection stays). |
| Web-readiness discipline erodes (JavaFX types leak into core) | Future web module blocked | module-info (core requires no javafx.*) + Checkstyle IllegalImport fail the build on first leak (§2.5). |
settings.gradle.kts, rootbuild.gradle.kts, wrapper (Gradle 9.x),gradle/libs.versions.toml,.gitignore,build-logicwith convention plugins (§5), JabRef-derived Checkstyle config. No native tooling of any kind.- Create
mindis-core,mindis-guiwithmodule-info.javastubs; wirejava-module-dependenciesmapping for all libraries. - Avaje spike (blocking, §2.4): hello-world view in
mindis-gui— JavaFX 25, module path,%full-text resource keys, hot reload, controller resolved fromBeanScopewith one injected@Singletonservice frommindis-core. Record outcome indocs/adr/001-view-layer.md. Localizationclass (inmindis-core) +MinDis_en/de.properties+ localization check task (§2.3).- Seed
docs/adr/003-web-ui-path.md: web UI deferred; §2.5 rules active from now; candidate stacks listed, decision postponed. - Done when:
./gradlew build runlaunches a stage showing one localized view with DI-injected controller from the module path;checkruns Checkstyle (incl. corejavafx.*import ban) + localization task.
- Build the shell in
org.mindis.gui.shell(§4.1):AppShellcontainer + builder,ShellModulelifecycle, sidebar navigation, Ikonli icons, AtlantaFX-token CSS. Dialogs, notifications and the drawer come from GemsFX'sPowerPanewrapped around it, reached through an injectedShellOverlays. Rationale + consequences:docs/adr/005-shell.md. - Rewrite shell CSS against AtlantaFX tokens; apply
PrimerLightuser-agent stylesheet; light/dark toggle. MinDisApp: build the shell with placeholder modules: Dashboard, Servers, Services, Planning, Settings. All strings viaLocalization.lang(...); language switch in Settings.- Preferences (§2.6):
MinDisPreferencesrecord +PreferencesServicein core (Jackson, atomic write); gui adapter; locale, theme and window geometry persisted and applied at startup. Writedocs/adr/004-preferences.md. - TestFX smoke tests for shell (open/close modules, drawer, dialog). Blocked on a headless-toolkit harness (Monocle) that fights JPMS; shell behavior is covered by plain JUnit tests driving the FX thread until that is solved.
- Done when: app starts, five modules open/close, theme + language switch (en↔de) work and survive restart, with no third-party shell library on the class path.
- Implement
mindis-coremodel (§3, without Timefold annotations yet) + Jackson JSON repository storing under user data dir (%APPDATA%/MinDis/ XDG equivalent), records + explicit creators (§2.2 rule 3), unit tests. - Build Servers module UI in Java (view class plus view model, both as Avaje beans): table, CRUD form (qualifications, family, availability editor). Observable view models in gui wrap plain core domain (§2.5).
- Build Services module UI: service list per horizon, CRUD form, role-slot editor, recurring-service templates (e.g. every Sunday 10:00).
- Done when: roster + services survive app restart (JSON round-trip), UI CRUD complete, both views fully localized. As built (2026-07-06): done — verified manually (create/save/restart/reload on a real run) plus unit tests for repositories and template generator. TestFX still deferred (see M1 note); template CRUD exercised via unit tests, not yet via UI click-through.
- Add Timefold annotations to planning types,
ServicePlansolution,ConstraintProviderwith all constraints (§3),SolverConfig(termination ~10s or best-score-unimproved). ConstraintVerifiertests per constraint; one end-to-end solver test with fixture data.PlanningServicein core (Avaje@Singleton): build problem from repositories for a horizon, solve async (SolverManager), expose progress + best solution via UI-agnostic listener/CompletableFutureAPI (§2.5 rule 3) — GUI adapts onto FX thread.- Done when: headless test produces feasible plan (0 hard violations) for realistic
month fixture (≈20 servers, ≈15 services) — proving core runs without any UI module.
As built (2026-07-06): done. Score is
HardMediumSoftScore(not HardSoft as sketched in §3): hard = rule violations, medium = unassigned slots (allowsUnassignedplanning variable — over-constrained months yield best partial plan instead of no plan), soft = quality. Constraints deferred until the model carries the data: preferred-mass-times reward, experienced/new pairing (no preference/experience fields yet — add fields + both constraints in M4/M5). Timefold 2.2 API drift vs. §3 sketch:PlanningIdinapi.domain.common, scores inapi.score, ConstraintVerifier merged into core (api.score.stream.test),SolverManager<Solution>single type param, best-solution events viawithBestSolutionEventConsumer. micrometer-core pinned to 1.15.x (1.16.5 class files crash javac on the module path).
- Planning module: pick horizon → generate assignments → live solving view (score, progress,
assignment grid: services × role slots). Solver time budget + constraint weights come from
PreferencesService(§2.6), editable in Settings. - Manual edit: swap server via combo, pin assignments (
pinned→@PlanningPin), re-solve. - Violation display: per-assignment indictment list (Timefold
ScoreAnalysis), messages localized. - Persist accepted plan as JSON next to roster data.
- Done when: full loop works — edit roster → solve → pin → re-solve → save → restart → plan restored.
As built (2026-07-06): done — loop verified on a real run (solve, manual pin via
editable server column, save, restart restore). Violation display uses the own
ViolationChecker(hard/medium constraints) instead of TimefoldScoreAnalysis— enterprise-gated, see risk table. Solver time budget in preferences (v2) + Settings; constraint-weight editing deferred (needsConstraintWeightOverrideswiring).
- PDF export of accepted plan (grouped by service; per-server view), via OpenPDF or similar; export honors the app language.
- Dashboard module: next services, unassigned slots, per-server load stats.
- Localization pass: complete
debundle, reviewentexts. - Done when: printable monthly plan PDF generated from the app in both languages.
As built (2026-07-06): done. OpenPDF 3.0.5 (packages
org.openpdf.*, automatic modulecom.github.librepdf.openpdf);PlanExportServicein core (services chronological + per-server summary, fully localized); export button in Planning with FileChooser. Dashboard = plain Java view (next services with staffing state from accepted plan, unassigned count, per-server load), rebuilt on every activation. M0 hello spike and GreetingService removed;EnumDisplaymoved to core l10n (shared by GUI + PDF).
org.gradlex.java-module-packaging: jpackage installers for Windows (primary), macOS, Linux.- GitHub Actions: build + test on push; package on tag.
- Done when: installable Windows build runs on a clean machine. App is shippable here —
M7 is optional optimization.
As built (2026-07-06): packaging plugin wired via
feature.packagingconvention; targets windows/linux/macos (host==target per CI runner; JavaFX via openjfx plugin classifier, not variant patches). jlink rejects automatic modules ⇒extra-java-module-infonow patches openpdf, micrometer (3 jars, one optional SPI ignored), HdrHistogram, LatencyUtils into real modules. Local verification: app-image (-PinstallerType=app-image, WiX-free); packagedMinDis.exeboots with bundled runtime. CI:build.yml(ubuntu, push/PR),release.yml(tagv*→ windows runner, WiX preinstalled → exe installer → GitHub release). Version in gradle.properties (0.6.0, MSI-compatible x.y.z).
All native work lives here; nothing before M6 depends on it, and failure leaves M6 as the
shipping path.
Reframed (2026-07-06): native does NOT replace the jpackage installer — a native binary
has no installer UX (Start menu, uninstall, upgrades), and Timefold's hot loops favor JIT
(same solver budget likely yields better plans on the JVM). M7's deliverable is an
additional portable single-file MinDis-portable.exe for no-install environments.
Go/no-go criteria for the spike: (a) Timefold AOT works at all, (b) native solver benchmark
vs. JIT is acceptable. Same release pipeline ships both artifacts.
- Create
org.mindis.gradle.feature.nativeconvention plugin: GraalVM toolchain (Liberica NIK / Gluon GraalVM with JavaFX static libs), GluonFX Gradle plugin. Windows: VS Build Tools (document indocs/dev-setup.md). - Spike first: headless native build of
mindis-core+ solver fixture run (Timefold AOT is the biggest unknown). Blocked → stop, record indocs/adr/002-packaging.md, stay on jpackage. - Generate reachability metadata with the tracing agent
(
-agentlib:native-image-agent=config-merge-dir=...) over a scripted flow covering every view + one solve + JSON round-trip; commit undermindis-gui/src/main/resources/META-INF/native-image/org.mindis/. Expect metadata for Jackson, Timefold, JavaFX internals — views are plain Java and contribute none. - Full-app native build; smoke test: launch, open all modules, solve, export PDF.
- CI: native job on tag (Windows first).
- Done when: native Windows binary passes the smoke test — or ADR-002 documents why
jpackage stays primary.
As built (2026-07-06): decided — no native artifact. Spike ran on CI (headless
core + solver, GraalVM for JDK 25, Windows runner): Timefold AOT works (criterion a),
but native throughput is 39k vs. 90k moves/sec under JIT — 2.3x slower on the product's
core path (criterion b failed). jpackage remains the only shipping path. Spike harness
(
native-spike/, manual workflow) stays for one-click re-evaluation. Full analysis and revisit triggers:docs/adr/002-packaging.md.
-
Server model:
preferredTimes+experienced(null-tolerant compact constructor migrates old JSON); the two deferred soft constraints now live: preferred-service-time reward, experienced-server-present reward (one per service). Roster form edits both. -
Soft constraint weights user-tunable: preferences v3 carries a name→weight map (defaults from
MinDisConstraintProvider.defaultSoftWeights()), Settings shows five spinners,buildProblemapplies them viaConstraintWeightOverrideson the solution. -
Sidebar icons via Ikonli (proper JPMS modules, jlink-safe):
ikonli-javafx+ materialdesign2 pack;ShellModulecarries an optional icon literal. -
Not built — reactive repository→UI layer: activation-refresh covers every observable case in a single-window app; an event/ObservableList layer would add machinery with no visible behavior change (YAGNI). Revisit only if multi-window or background imports arrive.
-
Still deferred: TestFX harness (user decision).
-
Sidebar collection switcher (2026-07-24, UX review #3): the sidebar top carries an account-switcher-style control for the open collection (a document = one parish). It shows the collection's logo + name and an inline save button (enabled only when dirty), with a dropdown of up to five recent collections and the document actions (Open other, Save as, Edit collection, New collection). The old global top toolbar is gone; Ctrl+N/O/S/Shift+S are the shortcuts. Collection identity (name + Base64 PNG logo) lives inside the document (
CollectionMeta, doc v2); recents live in preferences (RecentCollection, prefs v10, capped 5).CollectionMetaDialogedits the identity (PNG only, size-capped to keep the document small). -
Postponed — sidebar quick-search box (UX best-practice #10 from the sidebar review, 2026-07-24): a top-of-sidebar search/filter with a keyboard shortcut (e.g. Ctrl+K) to jump between modules. Not worth it at five fixed modules — omittable until the nav list grows (sub-items, dynamic/plugin modules, or a long entry count). Revisit when navigation stops fitting on screen at a glance. Applied from the same review: expanded-sidebar default width widened to the 240-300px UX band (#1) and an accent bar on the active entry for color-independent highlighting (#7).
-
Recurrence rules for service templates (2026-07-29): a template's date pattern is no longer a single
DayOfWeekbut aRecurrenceRule— a sealed interface of immutable records over one method,boolean matches(LocalDate). Atoms: weekday, day-of-month (negative counts from the month's end), n-th weekday of month (±1..5), month filter, fixed month-day, fixed date, every-n days/weeks/months (anchored, ISO weeks), feast-relative. Combined withAllOf/AnyOf/Notinstead of by special cases, so "every third Sunday", "every other Sunday", "every 13th", "the Saturday before Advent 1" and "every Sunday except Easter" are all the same three-line vocabulary. Dates are never clamped: a 31st simply does not occur in a 30-day month. Feast days (LiturgicalDay+LiturgicalCalendar) cover the Easter cycle (Meeus/Jones/Butcher Easter, checked against a published date table), the Advent Sundays and a few German-usage days (Erntedank, Buß- und Bettag, Totensonntag); every liturgical date is a pure function of the year, which is why rules need no calendar context passed in. Two serialized forms, both outside the model:RecurrenceRuleMixin(JSON,kindproperty, in an ownpersistence.jsonpackage so downstream modules need no Jackson annotations on their module path) andRecurrenceCodec(the one-line CSV column form,ALL(WEEKDAY:SUNDAY; NOT(FEAST:EASTER)), all-or-nothing parsing).ServiceGeneratorasks every template per day; idempotency is unchanged. The Templates editor is aRecurrenceEditor: a mode picker (weekly / monthly / yearly / feast day / custom) with that mode's fields, a plain-language summary (RecurrenceText, localized, also the table column) and a preview of the next five dates (RecurrenceRule#nextOccurrences, bounded to a ten-year search). Reading a rule back into the fields only recognizes the shapes the guided modes build; anything else opens in the custom mode with its exact text, which round-trips losslessly instead of being flattened. A template's "when" is aServiceSchedule— the rule plus a validity window (both bounds inclusive, either side open) plus individual skipped dates — so pausing a template over the summer or cancelling one occurrence never touches the pattern; encoding those asNot(FixedDay(...))would work but would make the rule unreadable and unrepresentable in the guided modes. Skipped dates are stored sorted so that saving an unchanged document produces an unchanged file. No document migration was written: the app is unreleased. Not built: ICS/RRULE export —PlanExportDocumentis a text-flattened view with no date, duration or location left on it, so a calendar export needs its own pipeline offLiturgicalServicerather than a sixthPlanExporter.
Browser UI as additional module per §2.5. Prerequisites already in place by then: UI-free core, async service API, core-hosted localization, Avaje DI reusable server-side. Open decisions for ADR-003 when the time comes: stack (Javalin/Helidon + HTMX, REST+SPA, or JPro bridge), multi-user storage, auth, deployment.
Commit messages — joelparkerhenderson/git-commit-message
- Subject: imperative mood, capitalized, ≤ 50 chars, no trailing period
(
Add availability editor to server form, notadded editor./feat: editor). - Blank line, then body wrapped at 72 chars explaining why (motivation, contrast with previous behavior), not restating the diff.
- Prefer verbs:
Add,Fix,Refactor,Remove,Rename,Update,Document,Optimize. - One logical change per commit.
Adopt JabRef's code style (see JabRef CONTRIBUTING.md / devdocs), enforced by the
Checkstyle config in config/checkstyle/checkstyle.xml (derived from JabRef's):
Design principles — SOLID:
- Single responsibility: one reason to change per class — repositories persist, services decide, controllers bind UI; never mixed (the existing core/gui cut enforces the biggest instance of this).
- Open/closed: extend via new
ShellModules, new constraints, new preferences fields — not by modifying shell/solver/persistence internals. - Liskov substitution: subtypes honor the contract of their base (e.g. every
ShellModulemust tolerate repeatedactivate()/deactivate()cycles). - Interface segregation: keep injected dependencies narrow; if a controller needs one query, don't hand it a fat service. Introduce interfaces when a second implementation or test double exists or is imminent — not speculatively.
- Dependency inversion: high-level code depends on abstractions wired via Avaje constructor
injection (§2.4); no
newof collaborators inside business logic, no service locators.
Effective Java (Bloch, 3rd ed.) — the items this codebase leans on most:
- Minimize mutability (Item 17): domain = records, defensive copies in compact constructors
(as in
Server,LiturgicalService). - Static factories over constructors where they clarify (Item 1); builders for many optional
params (Item 2, e.g.
AppShell.builder). - Enforce noninstantiability of utility classes with private constructors (Item 4).
- Prefer dependency injection to hardwiring resources (Item 5) — Avaje everywhere.
Optionalfor absent return values, never for fields/params (Item 55); empty collections, nevernullreturns (Item 54).- Design and document for inheritance or prohibit it (Item 19): classes
finalby default, lifecycle hooks documented (ShellModule). - Prefer enums +
switchexpressions with exhaustiveness over string/int codes (Items 34-38, e.g.EnumDisplay). - Fail fast with precondition checks in constructors/compact constructors (Item 49).
- Don't swallow exceptions silently (Item 77) — log with context (as in
JsonStore,PreferencesService) or propagate. - Return streams/collections judiciously; keep public API types plain (List/Set/Map).
Reviewers/agents: cite the violated principle or item when rejecting code.
- 4-space indent; braces always, even for single-statement
if. - No wildcard imports; import order per config.
- No abbreviations in identifiers; descriptive names over comments.
- Prefer
Optional<T>overnullreturns;finalwhere it clarifies. - JavaDoc for public API in
mindis-core; comments explain why, not what. - Modern Java: records, sealed types, pattern matching,
varwhere type is obvious. - Test naming:
methodUnderTest_condition_expectedResultstyle, JUnit 5, AssertJ-style assertions if adopted repo-wide.
- Every user-visible string:
Localization.lang("Full English sentence")— full text as key, never abstract keys (§2.3). Applies to code, dialogs and PDF export alike. - New strings land in
MinDis_en.propertiesin the same PR;detranslation may follow, the English fallback keeps the UI intact. - Localization check task must pass in
check.
- One ADR per significant decision in
docs/adr/NNN-title.md. Seeded:001-view-layer(plain Java vs FXML),002-packaging(native vs jpackage, written in M7),003-web-ui-path(web module deferred, §2.5),004-preferences(own JSON store, §2.6, written in M1). - New user-facing setting = new field in
MinDisPreferenceswith default value; bump the record'sversionand add explicit migration when shape changes. No ad-hoc config files, nojava.util.prefs. - DI via Avaje Inject (§2.4): constructor injection only (no field injection); services in
core, controllers/view models in gui; one
BeanScopeper app. No other DI mechanism. - Web-readiness (§2.5): no JavaFX UI packages in
mindis-core(build-enforced;javafx.baseallowed); records immutable, never propertyfied; prefer plain types and listeners in core service APIs. - All versions only in
versions/build.gradle.kts(plusjavafxVersionin gradle.properties); module-name ↔ coordinate mappings ingradle/modules.properties. No version catalog. - Don't-block-native rules (§2.2) apply to every PR: new reflection outside Jackson/Timefold needs justification. No native tooling before M7.
- No third-party source is vendored, so there is no NOTICE obligation; if any is ever lifted, upstream copyright headers and a NOTICE entry become mandatory (§4.1).
- Every Timefold constraint has a
ConstraintVerifiertest before it ships.