Skip to content

Latest commit

 

History

History
705 lines (607 loc) · 46.4 KB

File metadata and controls

705 lines (607 loc) · 46.4 KB

MinDis — Minister Dispatcher

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.


1. Product Overview

MinDis manages a parish's altar servers and liturgical services, and automatically computes fair, constraint-respecting serving schedules.

Core use cases:

  1. Maintain a roster of altar servers (names, contact, family/siblings, qualifications, availability).
  2. Maintain liturgical services (Sunday/weekday masses, feasts, weddings, funerals) with required roles and headcounts.
  3. Automatically generate an assignment plan for a planning horizon (e.g. one month) using Timefold Solver, respecting hard rules and optimizing soft preferences.
  4. Manually pin/override assignments and re-solve around them.
  5. Export/print the resulting plan (PDF, later e-mail).

The application is multilingual from the start (German + English; parish context) — see §2.3.


2. Technology Stack (pinned versions — verify at implementation time)

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

2.1 View layer: plain Java, no FXML

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:

  1. 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.
  2. A closed JPMS moduleorg.mindis.gui opens only its root package, only to javafx.graphics, only because the JavaFX launcher instantiates Application reflectively.
  3. One localization mechanismLocalization.lang(...) everywhere, no %key markup attributes for the extraction task to also understand.
  4. 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.

2.2 Don't-block-native rules (coding style only — no native tooling before M7)

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:

  1. No dynamic classloading / Class.forName on computed names. Service lookup via explicit registration, not classpath scanning.
  2. Keep reflection confined to two known consumers: Jackson and Timefold. Nothing else may reflect — no hand-rolled reflective utilities.
  3. Prefer reflection-free alternatives where cheap: Jackson with explicit @JsonCreator/records; Timefold constraint streams (no drools); avoid java.util.ServiceLoader where a direct call works.
  4. 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.

2.3 Localization: full-text keys, JabRef style

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 than MessageFormat quirks.
  • 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 %key binding (§2.1), so the extraction task has one syntax to understand. Fallback: set strings from the controller via Localization.lang(...).
  • Rule from M1 on: no hardcoded user-visible string outside Localization.lang(...) calls. Locale switchable in Settings at runtime.
  • Localization lives in mindis-core — a future web module (§2.5) reuses it unchanged.

2.4 Dependency injection: Avaje Inject

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: @Singleton components with constructor injection. External/config-driven objects via @Factory + @Bean methods.
  • One BeanScope created in MinDisApp.start(); closed on shutdown (AutoCloseable beans 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/@Prototype beans with constructor injection; nothing resolves a dependency by reaching for a global.
  • Each JPMS module with beans declares the processor; module-info gets requires io.avaje.inject;. Verify processor-on-module-path setup in the M0 spike.
  • Tests: avaje-inject-test for scope-per-test with mock overrides (@InjectTest).
  • Scope discipline: beans live in mindis-core (services) and mindis-gui (controllers, view models). No JavaFX types in core beans (§2.5).

2.5 Web-ready architecture: UI-agnostic core, future mindis-web module

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:

  1. mindis-core must not depend on JavaFX UI modules. javafx.base (properties, observable collections — headless-safe) is allowed; javafx.controls, javafx.graphics, javafx.scene etc. are banned — enforced by Checkstyle IllegalImport (UI packages) and module-info review. Domain model stays plain Java/records — records are always immutable and never carry properties or observables; mindis-gui wraps domain objects in its own observable view models.
  2. 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 against mindis-core alone.
  3. Async API shape: core services expose plain types, CompletableFuture/callbacks or listener interfaces — not JavaFX ObservableValue. GUI adapts these onto the FX thread; a web module would adapt them onto HTTP/WebSocket.
  4. Future mindis-web (when it comes): own JPMS module next to mindis-gui, same pattern — requires org.mindis.core, Avaje Inject reused server-side (it is a general DI container, not FX-bound). Candidate stacks recorded in docs/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 reusing mindis-gui.
  5. 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.

2.6 Preferences: own JSON store in core (no framework)

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 record MinDisPreferences (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 — no ObservableValue in core).
  • Storage: preferences.json via 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 PreferencesService into JavaFX properties for bindings; Settings shell edits them. Locale + theme are applied at startup before the first scene.
  • Versioning: record carries a version field; 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).


3. Domain Model (Timefold)

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.

Problem facts (immutable during solving)

  • 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 via Localization.lang(...) (§2.3), never name().
  • 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.

Planning entity

@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.

Constraints (ConstraintProvider)

Hard:

  1. Server must be qualified for the assigned role.
  2. Server must be available (no unavailability overlap with service time).
  3. No double-booking: one server, one assignment per overlapping service time.
  4. Server is active.

Soft (weights tunable in settings):

  1. Fairness — balance assignment count per server over the horizon (load balancing).
  2. Siblings together — prefer same-family servers assigned to the same service.
  3. Spacing — penalize assignments on consecutive services/days for the same server.
  4. Preference match — reward preferred mass times.
  5. Experience mix — prefer pairing experienced with new servers per service.

Each constraint gets a ConstraintVerifier unit test before UI integration.


4. Project / Module Layout (JPMS)

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)

4.1 Application shell — bespoke, in mindis-gui

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.shellAppShell container + builder, ShellModule lifecycle (activate/deactivate/destroy/dispose), CrudModule (shared table+editor screen), ShellOverlays.
  • org.mindis.gui.dataLiveStore, 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. ShellOverlays is 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.

5. Build Setup (Gradle Kotlin DSL + GradleX, JabRef-style)

Key elements copied from the JabRef approach:

  1. Versions platform (JabRef pattern, no version catalog): all dependency versions live in versions/build.gradle.kts (java-platform project — GAV constraints + BOM imports), consumed everywhere via jvmDependencyConflicts { consistentResolution { platform(":versions") } }. JavaFX version is the javafxVersion Gradle property (gradle.properties), read by both the platform and feature.javafx.
  2. build-logic included build with convention plugins (org.mindis.gradle.*) applied by subprojects; root build.gradle.kts stays nearly empty.
  3. GradleX plugins (applied in convention plugins):
    • org.gradlex.java-module-dependencies — derive Gradle dependencies from module-info.java (requires ⇒ dependency); custom module-name→GA mappings in gradle/modules.properties. As built (M0): project-plugin mode — project names are core/gui (dirs mindis-*) so group + name = module name holds.
    • org.gradlex.jvm-dependency-conflict-resolution — wires the :versions platform 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).
  4. No view-layer library — views are plain Java (§2.1), so mindis-gui carries no FXML or view-framework dependency at all. 4a. Avaje Injectavaje-inject as dependency, avaje-inject-generator on annotationProcessor path of mindis-core and mindis-gui (wired in a convention plugin; mind processor-with-JPMS setup). avaje-inject-test for tests.
  5. Checkstyle — JabRef-derived config/checkstyle/checkstyle.xml, wired into feature.compile convention plugin; build fails on violation (§8). Includes IllegalImport rule banning javafx.* in mindis-core (§2.5).
  6. Localization check task — verifies Localization.lang literals ↔ bundle entries (§2.3); part of check.
  7. Java toolchainjava.toolchain.languageVersion = 25 + org.gradle.toolchains.foojay-resolver-convention in settings.
  8. 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 in docs/dev-setup.md.
  9. Run task — standard application plugin with module path (mainModule = "org.mindis.gui", mainClass = "org.mindis.gui.MinDisApp").

6. Known Risks & Mitigations

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).

7. Milestones (execution order for a coding agent)

M0 — Build scaffold (no UI)

  1. settings.gradle.kts, root build.gradle.kts, wrapper (Gradle 9.x), gradle/libs.versions.toml, .gitignore, build-logic with convention plugins (§5), JabRef-derived Checkstyle config. No native tooling of any kind.
  2. Create mindis-core, mindis-gui with module-info.java stubs; wire java-module-dependencies mapping for all libraries.
  3. Avaje spike (blocking, §2.4): hello-world view in mindis-gui — JavaFX 25, module path, % full-text resource keys, hot reload, controller resolved from BeanScope with one injected @Singleton service from mindis-core. Record outcome in docs/adr/001-view-layer.md.
  4. Localization class (in mindis-core) + MinDis_en/de.properties + localization check task (§2.3).
  5. Seed docs/adr/003-web-ui-path.md: web UI deferred; §2.5 rules active from now; candidate stacks listed, decision postponed.
  6. Done when: ./gradlew build run launches a stage showing one localized view with DI-injected controller from the module path; check runs Checkstyle (incl. core javafx.* import ban) + localization task.

M1 — Shell + theme

  1. Build the shell in org.mindis.gui.shell (§4.1): AppShell container + builder, ShellModule lifecycle, sidebar navigation, Ikonli icons, AtlantaFX-token CSS. Dialogs, notifications and the drawer come from GemsFX's PowerPane wrapped around it, reached through an injected ShellOverlays. Rationale + consequences: docs/adr/005-shell.md.
  2. Rewrite shell CSS against AtlantaFX tokens; apply PrimerLight user-agent stylesheet; light/dark toggle.
  3. MinDisApp: build the shell with placeholder modules: Dashboard, Servers, Services, Planning, Settings. All strings via Localization.lang(...); language switch in Settings.
  4. Preferences (§2.6): MinDisPreferences record + PreferencesService in core (Jackson, atomic write); gui adapter; locale, theme and window geometry persisted and applied at startup. Write docs/adr/004-preferences.md.
  5. 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.
  6. 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.

M2 — Domain model + persistence + first real views

  1. Implement mindis-core model (§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.
  2. 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).
  3. Build Services module UI: service list per horizon, CRUD form, role-slot editor, recurring-service templates (e.g. every Sunday 10:00).
  4. 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.

M3 — Timefold integration

  1. Add Timefold annotations to planning types, ServicePlan solution, ConstraintProvider with all constraints (§3), SolverConfig (termination ~10s or best-score-unimproved).
  2. ConstraintVerifier tests per constraint; one end-to-end solver test with fixture data.
  3. PlanningService in core (Avaje @Singleton): build problem from repositories for a horizon, solve async (SolverManager), expose progress + best solution via UI-agnostic listener/CompletableFuture API (§2.5 rule 3) — GUI adapts onto FX thread.
  4. 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 (allowsUnassigned planning 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: PlanningId in api.domain.common, scores in api.score, ConstraintVerifier merged into core (api.score.stream.test), SolverManager<Solution> single type param, best-solution events via withBestSolutionEventConsumer. micrometer-core pinned to 1.15.x (1.16.5 class files crash javac on the module path).

M4 — Planning UI

  1. 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.
  2. Manual edit: swap server via combo, pin assignments (pinned@PlanningPin), re-solve.
  3. Violation display: per-assignment indictment list (Timefold ScoreAnalysis), messages localized.
  4. Persist accepted plan as JSON next to roster data.
  5. 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 Timefold ScoreAnalysis — enterprise-gated, see risk table. Solver time budget in preferences (v2) + Settings; constraint-weight editing deferred (needs ConstraintWeightOverrides wiring).

M5 — Export & polish

  1. PDF export of accepted plan (grouped by service; per-server view), via OpenPDF or similar; export honors the app language.
  2. Dashboard module: next services, unassigned slots, per-server load stats.
  3. Localization pass: complete de bundle, review en texts.
  4. 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 module com.github.librepdf.openpdf); PlanExportService in 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; EnumDisplay moved to core l10n (shared by GUI + PDF).

M6 — Packaging & CI (jpackage)

  1. org.gradlex.java-module-packaging: jpackage installers for Windows (primary), macOS, Linux.
  2. GitHub Actions: build + test on push; package on tag.
  3. 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.packaging convention; targets windows/linux/macos (host==target per CI runner; JavaFX via openjfx plugin classifier, not variant patches). jlink rejects automatic modules ⇒ extra-java-module-info now patches openpdf, micrometer (3 jars, one optional SPI ignored), HdrHistogram, LatencyUtils into real modules. Local verification: app-image (-PinstallerType=app-image, WiX-free); packaged MinDis.exe boots with bundled runtime. CI: build.yml (ubuntu, push/PR), release.yml (tag v* → windows runner, WiX preinstalled → exe installer → GitHub release). Version in gradle.properties (0.6.0, MSI-compatible x.y.z).

M7 — GraalVM Native Image (last, self-contained)

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.

  1. Create org.mindis.gradle.feature.native convention plugin: GraalVM toolchain (Liberica NIK / Gluon GraalVM with JavaFX static libs), GluonFX Gradle plugin. Windows: VS Build Tools (document in docs/dev-setup.md).
  2. Spike first: headless native build of mindis-core + solver fixture run (Timefold AOT is the biggest unknown). Blocked → stop, record in docs/adr/002-packaging.md, stay on jpackage.
  3. 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 under mindis-gui/src/main/resources/META-INF/native-image/org.mindis/. Expect metadata for Jackson, Timefold, JavaFX internals — views are plain Java and contribute none.
  4. Full-app native build; smoke test: launch, open all modules, solve, export PDF.
  5. CI: native job on tag (Windows first).
  6. 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.

Post-M7 polish (as built, 2026-07-06)

  • 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, buildProblem applies them via ConstraintWeightOverrides on the solution.

  • Sidebar icons via Ikonli (proper JPMS modules, jlink-safe): ikonli-javafx + materialdesign2 pack; ShellModule carries 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). CollectionMetaDialog edits 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 DayOfWeek but a RecurrenceRule — 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 with AllOf/AnyOf/Not instead 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, kind property, in an own persistence.json package so downstream modules need no Jackson annotations on their module path) and RecurrenceCodec (the one-line CSV column form, ALL(WEEKDAY:SUNDAY; NOT(FEAST:EASTER)), all-or-nothing parsing). ServiceGenerator asks every template per day; idempotency is unchanged. The Templates editor is a RecurrenceEditor: 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 a ServiceSchedule — 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 as Not(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 — PlanExportDocument is a text-flattened view with no date, duration or location left on it, so a calendar export needs its own pipeline off LiturgicalService rather than a sixth PlanExporter.

Future (explicitly out of scope for M0–M7): mindis-web

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.


8. Conventions for Contributors / Agents

  • Subject: imperative mood, capitalized, ≤ 50 chars, no trailing period (Add availability editor to server form, not added 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.

Code style — JabRef

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 ShellModule must tolerate repeated activate()/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 new of 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.
  • Optional for absent return values, never for fields/params (Item 55); empty collections, never null returns (Item 54).
  • Design and document for inheritance or prohibit it (Item 19): classes final by default, lifecycle hooks documented (ShellModule).
  • Prefer enums + switch expressions 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> over null returns; final where it clarifies.
  • JavaDoc for public API in mindis-core; comments explain why, not what.
  • Modern Java: records, sealed types, pattern matching, var where type is obvious.
  • Test naming: methodUnderTest_condition_expectedResult style, JUnit 5, AssertJ-style assertions if adopted repo-wide.

Localization

  • 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.properties in the same PR; de translation may follow, the English fallback keeps the UI intact.
  • Localization check task must pass in check.

General

  • 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 MinDisPreferences with default value; bump the record's version and add explicit migration when shape changes. No ad-hoc config files, no java.util.prefs.
  • DI via Avaje Inject (§2.4): constructor injection only (no field injection); services in core, controllers/view models in gui; one BeanScope per app. No other DI mechanism.
  • Web-readiness (§2.5): no JavaFX UI packages in mindis-core (build-enforced; javafx.base allowed); records immutable, never propertyfied; prefer plain types and listeners in core service APIs.
  • All versions only in versions/build.gradle.kts (plus javafxVersion in gradle.properties); module-name ↔ coordinate mappings in gradle/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 ConstraintVerifier test before it ships.