diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index a860dd74..18e25ab1 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -19,6 +19,11 @@ jobs: java-version: 21 - name: Setup Gradle uses: gradle/gradle-build-action@v2 + - name: Run unit tests + run: ./gradlew test --scan + - name: Run E2E tests (Linux) + if: ${{ inputs.runs-on == 'ubuntu-latest' }} + run: ./gradlew testE2e --scan - name: Execute Gradle build run: ./gradlew ${{ inputs.gradle-tasks }} --scan - name: Upload dists diff --git a/.gitignore b/.gitignore index fb6f41ea..7d7c43bc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ build /gen-src # mac stuff **/.DS_Store + +# KI / Agent stuff + +.agentbridge +opencode.jsonc +opencode.jsonc.tui-migration.bak +tui.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4ec3f6f0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTS.md - SimpleTimeTracking (STT) + +## Purpose + +SimpleTimeTracking (STT) is a cross-platform desktop application for **fast, unobtrusive time tracking**. +It prioritizes the individual user's workflow over management reporting — design goal is to let you start/stop tracking with minimal friction and copy results into other systems. + +## Use Cases + +- **Start/stop working** on a task with a single command or button click +- **Resume** the last or any previous activity +- **Search** across historical activities by comment text +- **Report** on time spent per activity/group (day, period, search filter) +- **Overtime** calculation against configured worktime rules +- **CSV/Jira export** for integration with other systems +- **Dual interface**: JavaFX GUI for desktop use, CLI for scripting/terminal use +- **Automatic backup** and item logging + +## Architecture + +### Tech Stack + +| Layer | Technology | +|---|---| +| Language | **Kotlin** (JVM), with some Java source files | +| UI | **JavaFX 21**, ControlsFX, RichTextFX | +| Build | **Gradle** (Kotlin DSL), JDK 21+ | +| DI | **Dagger 2** (annotation-based, `kapt`) | +| Parsing | **ANTLR 4** for command text parsing | +| Config | **YAML** (SnakeYAML) | +| Event Bus | **MBassador** (in-process pub/sub) | +| Testing | **JUnit 4**, **AssertJ**, **Mockito** (+ mockito-kotlin), TestFX | + +### Module / Package Layout + +All source under `src/main/kotlin/org/stt/`: + +``` +org.stt/ +├── cli/ # CLI entry points (Main, ReportPrinter, FormatConverter) +├── command/ # Command pattern: Commands.kt, CommandHandler.kt, CommandFormatter (ANTLR) +├── config/ # Configuration loading from YAML, config classes +├── connector/jira/ # Jira integration (REST client) +├── csv/ # CSV import/export +├── event/ # Event bus classes (NotifyUser, ShuttingDown, TimePassedEvent) +├── gui/ # JavaFX UI (MainWindow, ActivitiesController, ReportController, Settings) +├── model/ # Domain model (TimeTrackingItem, ReportingItem) +├── persistence/ # ItemReader / ItemWriter interfaces + STT file format implementation +├── query/ # Query/filter logic over time tracking items (Criteria, WorkTimeQueries) +├── reporting/ # Report generation (SummingReportGenerator, OvertimeReportGenerator) +├── text/ # Text completion, categorization, grouping (CommonPrefixGrouper, JiraExpansion) +├── time/ # Date/time utilities, duration rounding +├── update/ # Update check mechanism +├── validation/ # Input validation +``` + +> See [doc/ui-architecture.md](doc/ui-architecture.md) for a detailed breakdown of UI components, controllers, data objects, and event bus wiring. + +### Key Design Patterns + +- **Command pattern**: `Command` (sealed hierarchy) + `CommandHandler` interface (Visitor), parsed via ANTLR grammar +- **Dependency Injection**: Dagger `@Module` / `@Provides` / `@Inject`; components are `Dagger*Application` (e.g., `DaggerCLIApplication`, `DaggerUIApplication`) +- **Event-driven**: `MBassador` event bus for decoupled UI updates (time ticks, shutdown, notifications) +- **Repository**: `ItemReader` / `ItemWriter` interfaces abstract storage; `STTItemReader` / `STTItemWriter` implement the plain-text file format +- **Service lifecycle**: `Service` interface with `start()`/`stop()` for config, backup, and logging services +- **Data stored**: A plain-text file (`.stt/activities`) with one record per line + +## Code Style + +- **Language**: Kotlin (prefer immutable data classes, `val`, extension functions) +- **Naming**: `camelCase` for methods/variables, `PascalCase` for classes, no underscores +- **Test naming**: `should[Expectation]` — e.g., `shouldCreateItemWithoutEnd` +- **Test structure**: GIVEN / WHEN / THEN comment annotations, `sut` (system under test) variable +- **Imports**: explicit single imports (no wildcard `.*` except for standard lib / assertions) +- **Nullability**: explicit nullable types with `?`, prefer `?:` elvis operator +- **DI**: constructor injection via `@Inject`, module-provided bindings for platform/third-party types +- **Logging**: `java.util.logging.Logger` (`Logger.getLogger(...)`) +- **File format**: one time-tracking record per line, human-readable text + +## Build & Test + +```bash +./gradlew build # compile + test + assemble fat jar +./gradlew test # run all tests (JUnit 4) +./gradlew check # + static analysis (SonarQube if configured) +./gradlew dist # jlink + jpackage for native distribution +./gradlew run # compile and start the GUI application +``` + +Output fat jar: `build/libs/STT-.jar` + +## Commit Convention + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add resume-last-activity CLI command +fix: report crashes on empty activity list +refactor: extract DurationRounder from ReportGenerator +test: add overtime calculation edge cases +docs: update README with new CLI usage +chore: bump Dagger to 2.50 +``` + +Scopes (optional): `cli`, `gui`, `persistence`, `query`, `reporting`, `config`, `time`, `deps` \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8453a8cc..5eb5093f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,11 +17,14 @@ plugins { id("org.sonarqube") version "5.1.0.4882" id("com.github.ben-manes.versions") version "0.36.0" + // JavaFX distribution and packaging id("org.openjfx.javafxplugin") version "0.1.0" + // Java module support (required for jlink/jpackage) id("org.javamodularity.moduleplugin") version "1.8.12" id("org.beryx.jlink") version "3.0.1" + // Auto-versioning from git tags id("com.palantir.git-version") version "2.0.0" } @@ -33,7 +36,7 @@ repositories { } // -SNAPSHOT is added if the release task is not set -// jpackge for windows needs an 2 digit version, at least +// jpackage for windows needs a 2-digit version, at least val upcomingVersion = "4.0" val archivesBaseName = "STT" @@ -42,7 +45,7 @@ version = upcomingVersion application { mainModule.set("org.stt") mainClass.set("org.stt.StartWithJFX") - // add-opens, so we can access the file decoration-warning.png within this module + // Required so ControlsFX validation decoration can access its own internal PNG applicationDefaultJvmArgs = listOf("--add-opens=org.controlsfx.controls/impl.org.controlsfx.control.validation=org.stt") } @@ -64,7 +67,7 @@ configurations { val spek_version = "2.0.4" dependencies { - val daggerVersion = "2.50" // with dagger 2.52 they introduced an incomplete usage ofjakarta.inject + val daggerVersion = "2.50" // with dagger 2.52 they introduced an incomplete usage of jakarta.inject antlr(group = "org.antlr", name = "antlr4", version = "4.9.1") implementation(group = "org.antlr", name = "antlr4-runtime", version = "4.9.1") @@ -80,11 +83,18 @@ dependencies { implementation("com.jsoniter:jsoniter:0.9.23") implementation(kotlin("stdlib-jdk8")) + // ── Test dependencies ── testImplementation("commons-io:commons-io:2.8.0") testImplementation("org.mockito:mockito-core:5.12.0") testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") testImplementation("org.assertj:assertj-core:3.26.3") testImplementation("junit:junit-dep:4.11") + // TestFX for JavaFX UI end-to-end tests + testImplementation("org.testfx:testfx-core:4.0.18") + // Monocle headless platform, pinned to JDK 21.0.x to match the javafx plugin version + testRuntimeOnly("org.testfx:openjfx-monocle:21.0.2") { + exclude(group = "org.openjfx") + } } javafx { @@ -106,7 +116,7 @@ distributions.getByName("main") { } tasks.compileJava { - // workaround, to make kapt created classes available to java module source set + // Make kapt-generated sources available to the Java module source set sourceSets { main { java { @@ -118,9 +128,30 @@ tasks.compileJava { tasks.test { extensions.configure(TestModuleOptions::class) { - // disable java-module path for tests + // Tests run on the classpath (not the module path) to avoid JPMS issues + // with test frameworks and mocking libraries runOnClasspath = true } + // E2E tests are excluded from the default `test` task — run them separately + // via the `testE2e` task below. + exclude("**/*E2ETest*") +} + +/* + * Custom task for running TestFX end-to-end tests. + * These tests require Monocle (headless JavaFX) and are excluded from + * the regular `test` task because they are slow and need a display-less + * environment. + * + * Usage: ./gradlew testE2e + */ +tasks.register("testE2e") { + include("**/*E2ETest*") + useJUnit() + testLogging { + events("passed", "failed", "skipped") + showExceptions = true + } } tasks.withType { @@ -190,7 +221,7 @@ jlink { jvmArgs = application.applicationDefaultJvmArgs.plus( listOf( - // some classes in the merged-modules (see jlink plugin( need access to javafx modules + // some classes in the merged-modules (see jlink plugin) need access to javafx modules "--add-reads=simpleTimeTracking.merged.module=javafx.graphics", "--add-reads=simpleTimeTracking.merged.module=javafx.base", "--add-reads=simpleTimeTracking.merged.module=javafx.controls" @@ -223,5 +254,4 @@ jlink { icon = "src/main/resources/Logo.icns" } } -} - +} \ No newline at end of file diff --git a/doc/testfx-e2e-plan.md b/doc/testfx-e2e-plan.md new file mode 100644 index 00000000..acc5c991 --- /dev/null +++ b/doc/testfx-e2e-plan.md @@ -0,0 +1,284 @@ +# TestFX E2E Test Plan (revised) + +## Approach + +Type commands into the `StyleClassedTextArea` (rich text input), click the insert button to execute, then navigate to the Report tab and verify. This exercises the full: **ANTLR parse -> Command -> Activities -> persist -> event bus -> controller refresh -> UI render** pipeline. + +## Why no Cucumber/Gherkin + +The project's `// GIVEN // WHEN // THEN` test convention (per AGENTS.md) provides ~80% of BDD readability. JavaFX step definitions would be verbose (`clickOn("#foo")`, `type("bar")`) and the `.feature` files would mirror Java code too closely to justify the Cucumber overhead. If you want human-readable specs, apply Gherkin to the CLI layer -- the JavaFX layer is better served by plain TestFX + JUnit. + +## Build Changes (`build.gradle.kts`) + +Already in place: + +```kotlin +dependencies { + testImplementation("org.testfx:testfx-core:4.0.18") + testRuntimeOnly("org.testfx:openjfx-monocle:21.0.2") { + exclude(group = "org.openjfx") + } +} +``` + +The `test` task excludes `**/*E2ETest*` and a dedicated `testE2e` task includes them: + +```kotlin +tasks.test { + exclude("**/*E2ETest*") +} + +tasks.register("testE2e") { + include("**/*E2ETest*") + useJUnit() +} +``` + +No JVM arg changes needed -- tests already run on classpath (`runOnClasspath = true`). + +## Locale Consideration + +The `CommandTextParser` uses `DateTimeFormatterBuilder.appendLocalized(...)` with `FormatStyle.SHORT`/`MEDIUM`. In en_US locale, `FormatStyle.SHORT` for time is `"h:mm a"` (12h with AM/PM). But our test commands use `"09:00"` (24h without AM/PM). + +**Solution**: set `Locale.setDefault(Locale.GERMANY)` in the test's companion object init. German locale uses `"HH:mm"` for SHORT time, so `"09:00"` parses correctly. + +## Design Decision: Use Production Dagger Component Directly + +Rather than manually wiring controllers (15+ objects) or creating test-only Dagger modules, the test uses the **production `DaggerUIApplication` component directly** via its `@Component.Builder`. This avoids: + +- Duplication of constructor signatures +- Risk of test-production wiring drift +- Kapt-generated Java code in test sources (which causes module-system issues) + +Only two small production changes were needed: + +1. **`BaseModule.kt`**: Added constructor parameter `sttFileOverride: File? = null`. When non-null, `provideDatabaseFile` returns the temp file instead of `~/.stt/activities`. Dagger auto-instantiates with `null` in production. + +2. **`UIApplication.kt`**: Added `@Component.Builder` exposing `baseModule(module: BaseModule): Builder`. The test passes a `BaseModule(tempFile)` instance; production continues with `DaggerUIApplication.builder().build()`. + +3. **`MainWindowController.kt`**: Changed `activitiesController` from `private val` to `internal val` so the test can access it (it's the same instance Dagger injects into the main window). + +## How the Test Uses Dagger + +The test's `@Before setup()` method: +1. Creates a temp `activities` file +2. Builds `DaggerUIApplication.builder().baseModule(BaseModule(tempFile)).build()` +3. Calls `configService().start()` (initializes config; if no `~/.stt/stt.json` exists, defaults are created) +4. Gets `mainWindow = app.mainWindow()` and `activitiesController = mainWindow.activitiesController` +5. Shows the stage + +The Dagger graph resolves everything: `STTItemPersister` (backed by temp file), `Activities` (CommandHandler), `CommandFormatter`, `ActivitiesController`, `ReportController`, `MainWindowController`, event bus, etc. + +## Locale Handling + +The locale is set to `Locale.GERMANY` in the companion object `init` block, which runs at class-loading time, before any Dagger components are constructed. This ensures the `CommandTextParser` (created inside the `CommandModule`) uses 24-hour time format. + +## Stage Setup + +The test launches a `Stage` containing the `MainWindowController` with all 4 tabs. This means the FXML files (`MainWindow.fxml`, `ActivitiesPanel.fxml`, `ReportPanel.fxml`) are loaded through the real `FXMLLoader`, testing the `@FXML` annotation wiring. The `ActivitiesController`'s FXML is loaded lazily when `mainWindow.activitiesController.node` is first accessed (via `MainWindowController.initialize()` setting `activitiesTab.content`). + +## Concrete Testcase: Full Day via Command Input + +### Scenario + +User types 10 commands for 4 topics covering a full contiguous day, then views the report. + +#### Commands typed (in order) + +``` +fix login bug from 09:00 to 10:00 +code review from 10:00 to 10:30 +team meeting from 10:30 to 11:30 +fix login bug from 11:30 to 12:00 +lunch from 12:00 to 13:00 +code review from 13:00 to 14:00 +team meeting from 14:00 to 14:30 +fix login bug from 14:30 to 15:30 +team meeting from 15:30 to 16:00 +code review from 16:00 to 17:00 +``` + +#### What the test does + +1. Builds the Dagger component with a temp file for persistence +2. Shows the main window (all 4 tabs) +3. Focuses the command text area (`#commandText`) +4. Sets text via `interact { controller.commandText.replaceText("...") }` +5. Clicks insert button (`#insert`) to execute +6. Repeats for all 10 commands +7. Clicks the Report tab +8. Waits for async report panel loading (1 second sleep) +9. Verifies table has 4 rows +10. Verifies each row's duration text +11. Verifies summary labels + +#### Aggregation math + +| Activity | Entries | Duration | +|---|---|---| +| fix login bug | 09:00-10:00 (1h) + 11:30-12:00 (30m) + 14:30-15:30 (1h) | **2h30m** | +| code review | 10:00-10:30 (30m) + 13:00-14:00 (1h) + 16:00-17:00 (1h) | **2h30m** | +| team meeting | 10:30-11:30 (1h) + 14:00-14:30 (30m) + 15:30-16:00 (30m) | **2h00m** | +| lunch | 12:00-13:00 (1h) | **1h00m** | +| **Total** | | **8h00m** | +| Effective | 8h00m - 1h00m (lunch) | **7h00m** | +| Break | lunch | **1h00m** | +| Uncovered | contiguous, no gaps | **0h00m** | + +## Test Code: `ReportE2ETest.kt` + +```kotlin +package org.stt.gui.jfx + +import com.sun.javafx.application.PlatformImpl +import javafx.scene.control.Label +import javafx.scene.control.TableView +import javafx.stage.Stage +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.stt.BaseModule +import org.stt.gui.DaggerUIApplication +import org.testfx.api.FxRobot +import org.testfx.util.WaitForAsyncUtils +import java.nio.file.Files +import java.time.Duration +import java.util.* +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class ReportE2ETest { + + companion object { + init { + Locale.setDefault(Locale.GERMANY) + + System.setProperty("java.awt.headless", "true") + System.setProperty("testfx.headless", "true") + System.setProperty("glass.platform", "Monocle") + System.setProperty("monocle.platform", "Headless") + System.setProperty("prism.order", "sw") + System.setProperty("prism.text", "t2k") + } + } + + private lateinit var robot: FxRobot + private lateinit var stage: Stage + private lateinit var activitiesController: ActivitiesController + + @Before + fun setup() { + PlatformImpl.startup { } + + val setupLatch = CountDownLatch(1) + javafx.application.Platform.runLater { + try { + val tempFile = Files.createTempDirectory("stt-e2e").resolve("activities").toFile() + tempFile.createNewFile() + + val app = DaggerUIApplication.builder() + .baseModule(BaseModule(sttFileOverride = tempFile)) + .build() + app.configService().start() + val mainWindow = app.mainWindow() + activitiesController = mainWindow.activitiesController + + stage = Stage() + mainWindow.show(stage) + } finally { + setupLatch.countDown() + } + } + setupLatch.await() + + robot = FxRobot() + robot.targetWindow(stage) + } + + @After + fun teardown() { + javafx.application.Platform.runLater { stage.close() } + } + + @Test + fun shouldShowGroupedReportForDayWithFourTopics() { + val commands = listOf( + "fix login bug from 09:00 to 10:00", + "code review from 10:00 to 10:30", + "team meeting from 10:30 to 11:30", + "fix login bug from 11:30 to 12:00", + "lunch from 12:00 to 13:00", + "code review from 13:00 to 14:00", + "team meeting from 14:00 to 14:30", + "fix login bug from 14:30 to 15:30", + "team meeting from 15:30 to 16:00", + "code review from 16:00 to 17:00" + ) + + for (cmd in commands) { + robot.interact(Runnable { activitiesController.commandText.replaceText(cmd) }) + WaitForAsyncUtils.waitForFxEvents() + robot.clickOn("#insert") + WaitForAsyncUtils.waitForFxEvents() + } + + robot.clickOn("#reportTab") + WaitForAsyncUtils.waitForFxEvents() + WaitForAsyncUtils.sleep(1000L, TimeUnit.MILLISECONDS) + + val table = robot.lookup(".table-view").query>() + assertThat(table.items).hasSize(4) + + fun row(comment: String) = table.items.first { it.comment == comment } + + assertThat(row("fix login bug").duration).isEqualTo(Duration.ofHours(2).plusMinutes(30)) + assertThat(row("code review").duration).isEqualTo(Duration.ofHours(2).plusMinutes(30)) + assertThat(row("team meeting").duration).isEqualTo(Duration.ofHours(2)) + assertThat(row("lunch").duration).isEqualTo(Duration.ofHours(1)) + + assertThat(robot.lookup("#totalDuration").query