From 44817eac626c2b104a4c0147092a2227cef51a20 Mon Sep 17 00:00:00 2001 From: Robert Kleinschmager Date: Sat, 1 Aug 2026 20:22:24 +0200 Subject: [PATCH 1/7] feat(submit tracking): frist step of managing submissions of timetracking-items to remote timetracking services --- AGENTS.md | 129 ++++++++- doc/stories/submit/plan.md | 167 +++++++++++ new-prompts.md | 5 + src/main/java/module-info.java | 1 + src/main/kotlin/org/stt/cli/CLIApplication.kt | 3 +- src/main/kotlin/org/stt/command/Activities.kt | 16 +- .../kotlin/org/stt/command/CommandModule.kt | 5 +- .../kotlin/org/stt/config/ConfigModule.kt | 6 + src/main/kotlin/org/stt/config/ConfigRoot.kt | 3 + src/main/kotlin/org/stt/event/Event.kt | 1 + src/main/kotlin/org/stt/gui/UIApplication.kt | 6 +- src/main/kotlin/org/stt/gui/UIMain.kt | 1 + .../org/stt/gui/jfx/ActivitiesController.kt | 94 ++++++- .../org/stt/gui/jfx/ReportController.kt | 163 ++++++++++- .../org/stt/gui/jfx/STTOptionDialogs.kt | 8 + .../jfx/TimeTrackingItemCellWithActions.kt | 32 ++- .../kotlin/org/stt/model/ReportingItem.kt | 2 +- .../stt/reporting/SummingReportGenerator.kt | 11 +- .../kotlin/org/stt/submit/SubmitConfig.kt | 10 + .../kotlin/org/stt/submit/SubmitConnector.kt | 11 + .../kotlin/org/stt/submit/SubmitModule.kt | 26 ++ .../org/stt/submit/SubmitSelectionManager.kt | 40 +++ .../org/stt/submit/SubmitStatusTracker.kt | 87 ++++++ .../stt/submit/json/JsonSubmitConnector.kt | 99 +++++++ .../org/stt/gui/Application.properties | 7 + src/test/kotlin/org/stt/IntRangeTest.kt | 40 +++ src/test/kotlin/org/stt/StatesTest.kt | 28 ++ src/test/kotlin/org/stt/StringsTest.kt | 110 ++++++++ src/test/kotlin/org/stt/cli/MainTest.kt | 4 +- .../kotlin/org/stt/command/ActivitiesTest.kt | 4 +- .../org/stt/command/CommandFormatterTest.kt | 4 +- .../stt/gui/jfx/ActivitiesControllerTest.kt | 28 +- .../stt/gui/jfx/TimeTrackingItemCellTest.kt | 8 +- .../reporting/SummingReportGeneratorTest.kt | 9 +- .../stt/submit/SubmitSelectionManagerTest.kt | 159 +++++++++++ .../org/stt/submit/SubmitStatusTrackerTest.kt | 262 ++++++++++++++++++ .../submit/json/JsonSubmitConnectorTest.kt | 192 +++++++++++++ src/test/kotlin/org/stt/time/DateTimesTest.kt | 251 +++++++++++++++++ src/test/kotlin/org/stt/time/IntervalTest.kt | 114 ++++++++ .../validation/ItemAndDateValidatorTest.kt | 77 +++++ 40 files changed, 2189 insertions(+), 34 deletions(-) create mode 100644 doc/stories/submit/plan.md create mode 100644 new-prompts.md create mode 100644 src/main/kotlin/org/stt/submit/SubmitConfig.kt create mode 100644 src/main/kotlin/org/stt/submit/SubmitConnector.kt create mode 100644 src/main/kotlin/org/stt/submit/SubmitModule.kt create mode 100644 src/main/kotlin/org/stt/submit/SubmitSelectionManager.kt create mode 100644 src/main/kotlin/org/stt/submit/SubmitStatusTracker.kt create mode 100644 src/main/kotlin/org/stt/submit/json/JsonSubmitConnector.kt create mode 100644 src/test/kotlin/org/stt/IntRangeTest.kt create mode 100644 src/test/kotlin/org/stt/StatesTest.kt create mode 100644 src/test/kotlin/org/stt/StringsTest.kt create mode 100644 src/test/kotlin/org/stt/submit/SubmitSelectionManagerTest.kt create mode 100644 src/test/kotlin/org/stt/submit/SubmitStatusTrackerTest.kt create mode 100644 src/test/kotlin/org/stt/submit/json/JsonSubmitConnectorTest.kt create mode 100644 src/test/kotlin/org/stt/time/IntervalTest.kt create mode 100644 src/test/kotlin/org/stt/validation/ItemAndDateValidatorTest.kt diff --git a/AGENTS.md b/AGENTS.md index 4ec3f6f0..98f614d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,14 +69,139 @@ org.stt/ - **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 +## Testing + +### Framework & Dependencies + +| Tool | Dependency | Purpose | +|------|-----------|---------| +| **JUnit 4** | `junit:junit-dep:4.11` | Runner (`@Test`, `@Before`, `@Theory`, `@RunWith(Theories::class)`, `@DataPoints`, `@TestedOn`, `@Rule`) | +| **AssertJ** | `assertj-core:3.26.3` | Fluent assertions (`assertThat(...)`) | +| **Mockito** | `mockito-core:5.12.0` | Mocking (`@Mock`, `MockitoAnnotations.initMocks()`, `given()`/`verify()`) | +| **Mockito Kotlin** | `mockito-kotlin:5.4.0` | Kotlin-friendly matchers (`any()`, `anyOrNull()`) | +| **TestFX** | via monocle | Headless JavaFX testing | +| **Commons IO** | `commons-io:2.8.0` | Temp file I/O in tests (`TemporaryFolder`) | + +> Mockito inline mock maker is enabled via `src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker` (`mock-maker-inline`) for mocking final classes. + +### Test Location & Layout + +Tests mirror `src/main/kotlin/org/stt/` one-to-one under `src/test/kotlin/org/stt/`: + +``` +src/test/kotlin/org/stt/ +├── cli/ # MainTest, ReportPrinterTest +├── command/ # ActivitiesTest, CommandFormatterTest +├── config/ # PasswordSettingTest +├── connector/jira/ # JiraClientTest +├── csv/importer/ # CsvImporterTest +├── gui/jfx/ # ActivitiesControllerTest, TimeTrackingItemCellTest +│ └── binding/ # ReportBindingTest +├── importer/ # STTItemReaderTest, STTItemWriterTest, TiImporterTest +├── model/ # TimeTrackingItemTest +├── persistence/ # BackupCreatorTest, IOUtilTest +│ └── stt/ # STTItemConverterTest +├── query/ # TimeTrackingItemQueriesTest +├── reporting/ # SummingReportGeneratorTest, OvertimeReportGeneratorTest, WorkingtimeItemProviderTest +├── text/ # CommonPrefixGrouperTest, JiraExpansionProviderTest +├── time/ # DateTimesTest, DurationRounderTest, IntervalTest +├── update/ # VersionComparatorTest +├── validation/ # ItemAndDateValidatorTest +├── IntRangeTest.kt # (root-level utility) +├── StatesTest.kt # (root-level utility) +├── StringsTest.kt # (root-level utility) +├── Tests.kt # Mockito matcher helpers (Matchers.argThat, Matchers.any) +└── ItemReaderTestHelper.kt # Stubbing helper (givenReaderReturns) +``` + +### Naming Convention + +``` +should[Expectation] → e.g., shouldReturnTrueForSameDate, shouldCreateItemWithoutEnd +``` + +### Test Structure Pattern + +Every test follows **GIVEN / WHEN / THEN** comments. The System Under Test is always named `sut`: + +```kotlin +@Test +fun shouldDoSomething() { + // GIVEN + ... + // WHEN + val result = sut.method() + // THEN + assertThat(result).isEqualTo(...) +} +``` + +### Mock Setup Pattern + +```kotlin +@Mock private lateinit var dependency: SomeClass +private lateinit var sut: ClassUnderTest + +@Before +fun setup() { + MockitoAnnotations.initMocks(this) + sut = ClassUnderTest(dependency) +} +``` + +### Assertion Style + +Pure AssertJ chaining — no JUnit `assertEquals`/`assertTrue`: + +```kotlin +assertThat(result).isEqualTo(expected) +assertThat(result).isTrue() +assertThat(list).hasSize(3).containsExactly(a, b, c) +``` + +### Parameterized Tests + +Use JUnit 4 `Theories` with `@DataPoints` or `@TestedOn` for data-driven tests (see `CommandFormatterTest`, `TimeTrackingItemQueriesTest`, `STTItemWriterTest`): + +```kotlin +@RunWith(Theories::class) +class SomeTheoryTest { + @Theory + fun shouldHandleCase(@TestedOn(ints = [0, 1, 42]) input: Int) { ... } +} +``` + +### Coverage Landscape (as of July 2026) + +| Area | Coverage | Details | +|------|----------|---------| +| `model/` | Full | TimeTrackingItem (22 tests), ReportingItem tested via reporting | +| `query/` | Full | TimeTrackingItemQueries (20+ tests with theories) | +| `command/` | Full | Activities (8 tests), CommandFormatter (theory-based) | +| `reporting/` | Full | Summing, Overtime, WorkingtimeItemProvider all covered | +| `time/` | Good | DurationRounder (5 tests), DateTimes (18 tests), Interval (8 tests) | +| `text/` | Partial | CommonPrefixGrouper, JiraExpansionProvider tested; ItemCategorizer via reporting | +| `persistence/` | Partial | BackupCreator, STTItemConverter, STTItemReader/Writer tested; ItemPersister untested | +| `cli/` | Partial | Main, ReportPrinter tested; CLIApplication, FormatConverter untested | +| `config/` | Minimal | Only PasswordSetting tested (2 tests); YamlConfigService etc. untested | +| `gui/jfx/` | Minimal | ActivitiesController, TimeTrackingItemCell, ReportBinding tested; most controllers untested | +| `event/` | None | ItemLogService untested | +| `submit/` | None | Entire submit package untested | +| `root/` | Added | Strings, States, IntRange tested; StopWatch, Streams, Service untested | + +### Test Helpers + +- **`Tests.kt`** (`org.stt.Matchers`) — `argThat(lambda)` and `any()` wrappers for Mockito-Kotlin compatibility +- **`ItemReaderTestHelper.kt`** — `givenReaderReturns(reader, item1, item2, ...)` chains stubs to return items then `null` +- **`IOUtil.kt`** (`org.stt.importer`) — `readAll(reader)` collects all items from an `ItemReader` +- **`TestFX.kt`** (`org.stt.gui.jfx`) — `installTK()` mocks JavaFX `Toolkit` for headless UI testing + ## Build & Test ```bash diff --git a/doc/stories/submit/plan.md b/doc/stories/submit/plan.md new file mode 100644 index 00000000..45b3d42b --- /dev/null +++ b/doc/stories/submit/plan.md @@ -0,0 +1,167 @@ +# External Submit Feature + +Submit TimeTrackingItems (and their summarizations as ReportingItems) to external systems. +Triggers from both Activities view and Report view in the GUI. +Pluggable connector architecture configured via YAML. + +## Requirements + +1. **Pluggable connector architecture** — new submit targets added as separate implementations +2. **Global YAML config** — connectors configured once in `~/.stt/stt.yaml` +3. **GUI only** (CLI TBD) +4. **Submit status tracking** — persisted to `~/.stt/submit-status` so it survives restarts +5. **ReportListItem carries backing items** — so partial-submit fraction can be computed for summarized rows +6. **Active-connector dropdown** in both Activities and Report view toolbars — checkbox states and submit action are relative to the selected connector +7. **Initial connector**: JSON +8. **Submitted items become immutable** — once an item is submitted to any connector, it CANNOT be modified or deleted. Editing, deleting, stopping, continuing, bulk-renaming, and gap-closing are all blocked. This is enforced at two levels: (a) command handler rejects mutations, (b) UI disables action buttons. + +## Architecture + +``` +org.stt.submit/ +├── SubmitConnector.kt # interface +├── SubmitConfig.kt # config bean (in ConfigRoot.submit) +├── SubmitStatusTracker.kt # per-item per-connector submit status persistence +├── SubmitSelectionManager.kt # shared observable CheckBox state (per connector) +├── SubmitModule.kt # Dagger bindings +└── json/ + └── JsonSubmitConnector.kt # writes JSON file +``` + +### SubmitConnector interface (`SubmitConnector.kt`) + +```kotlin +interface SubmitConnector { + val id: String + fun submitItems(items: List) + fun submitSummary(report: Report, selectedItems: List) +} +``` + +### Config model (`SubmitConfig.kt`) + +```yaml +submit: + connectors: + - type: json + file: /path/to/submit.json +``` + +### Active Connector + +Both views hold an `ObjectProperty` bound to a **ComboBox in the toolbar**. All checkbox and submit behavior is relative to this active connector. + +Switching the dropdown recomputes every checkbox's state against the new connector. Selection sets are kept per connector, so switching away and back preserves the user's previous choices. + +Runtime after config services start: +- `SubmitStatusTracker` loads `~/.stt/submit-status` into a `Map>` +- Connector instances are created from config and provided via Dagger `@IntoSet` +- `SubmitSelectionManager` holds a `Map>` — selection state keyed by connector + +### Submit Status Persistence + +File: `~/.stt/submit-status` (parsed as key-value per line): + +``` +itemKey|connectorId|ISO-8601-timestamp +``` + +ItemKey = composite of `activity|start|end` (base64-encoded to avoid delimiter issues). + +Methods on `SubmitStatusTracker`: +- `isSubmitted(item: TimeTrackingItem): Boolean` — submitted to ANY connector +- `isSubmitted(item: TimeTrackingItem, connectorId: String): Boolean` +- `markSubmitted(item: TimeTrackingItem, connectorId: String)` +- `getSubmitFraction(items: List, connectorId: String): Float` — used by Report view for partial-indicator +- `requireNotSubmitted(item: TimeTrackingItem)` — throws if submitted (used by command handler) + +### Item Locking — Command Handler Enforcement + +The `Activities` class (`command/Activities.kt`) is the central command handler that all mutations pass through. It is injected with `SubmitStatusTracker`. + +All mutation methods add a submit check before proceeding: + +| Method | Guard | Behaviour | +|--------|-------|-----------| +| `addNewActivity` | If command replaces an existing item (`itemWithEditedActivity`), check that item is not submitted to ANY connector | Reject if submitted | +| `endCurrentActivity` | Check that ongoing item start is not submitted to ANY connector | Reject if submitted | +| `removeActivity` | Check item to delete is not submitted to ANY connector | Reject if submitted | +| `removeActivityAndCloseGap` | Check item to delete AND adjacent items are not submitted to ANY connector | Reject if any is submitted | +| `resumeActivity` | Creates new item; original unchanged — **no guard needed** | — | +| `resumeLastActivity` | Creates new item; original unchanged — **no guard needed** | — | +| `bulkChangeActivity` | Check ALL items in collection are not submitted to ANY connector | Reject if any is submitted | + +### Item Locking — UI Enforcement + +In `TimeTrackingItemCellWithActions`, action buttons (edit/delete/continue/stop) are disabled for items submitted to ANY connector. The disabled state is bound to `SubmitStatusTracker.isSubmitted(item)`. + +In `ActivitiesController.ValidatingCommandHandler`, submit checks are added before delegating to `activities` (defence-in-depth alongside the `Activities` class enforcement). + +### UI: Activities View — Checkbox Behavior + +Each `TimeTrackingItemCellWithActions` gets a checkbox. Its state depends on the **active connector** (selected in the toolbar ComboBox): + +| Item's submit status (for active connector) | Checkbox | User can toggle? | +|---|---|---| +| Not submitted | Unchecked | Yes | +| Already submitted | Checked, disabled | No | + +- **Toolbar**: Connector ComboBox + "Submit Selected" button +- Switching the connector dropdown recomputes all checkbox states against the new connector +- Selection state (`SubmitSelectionManager`) is keyed by connector — switching away and back preserves checked items +- **"Submit Selected"** sends all checked items to the active connector via `SubmitConnector.submitItems()`, then marks them via `SubmitStatusTracker.markSubmitted()`, publishes `ItemsSubmitted` event on the bus, and calls `activityList.refresh()` +- Action buttons are disabled if the item is submitted to **any** connector (not just the active one) + +### UI: Report View — Checkbox Behavior + +Each `tableForReport` row gets a checkbox column. State depends on the **active connector**: + +| Backing items (for active connector) | Checkbox | User can toggle? | On submit | +|---|---|---|---| +| All submitted | Checked, disabled | No | — | +| Some submitted, some not | Indeterminate, disabled | No | Submits remaining | +| None submitted | Unchecked | Yes | Submits all | + +- **Toolbar**: Connector ComboBox + "Submit Selected" button +- Switching the connector dropdown recomputes all row states against the new connector +- Selection state per connector (checked rows + how many of the checked row's backing items remain to submit) +- **"Submit Selected"** sends remaining (unsubmitted) backing items for checked rows to the active connector via `SubmitConnector.submitSummary()`, then marks them, publishes `ItemsSubmitted` event on the bus, and calls `tableForReport.refresh()` + +### Cross-View Sync via `ItemsSubmitted` Event + +When items are submitted in one view, the other view's checkbox state becomes stale. An `ItemsSubmitted` event (`org.stt.event.ItemsSubmitted`) is published on the event bus after every successful submit. Both controllers subscribe to it: + +| View | Handler | Behaviour | +|------|---------|-----------| +| Activities | `onItemsSubmitted` | Calls `activityList.refresh()` to recompute cell checkbox states | +| Report | `onItemsSubmitted` | Calls `tableForReport.refresh()` to recompute column checkbox states | + +The event is published via `eventBus.publish(ItemsSubmitted())` in: +- `ActivitiesController.submitSelectedItems()` — after marking items and clearing selection +- `ReportController.submitSelectedRows()` — after marking items and clearing selection + +## Implementation Order + +| Step | Description | Files | +|------|-------------|-------| +| 1 | Create `SubmitConnector` interface | `submit/SubmitConnector.kt` | +| 2 | Create `SubmitConfig` + wire into `ConfigRoot` + `ConfigModule` | `submit/SubmitConfig.kt`, `ConfigRoot.kt`, `ConfigModule.kt` | +| 3 | Create `SubmitStatusTracker` with persistence (includes `isSubmitted()`, `requireNotSubmitted()`) | `submit/SubmitStatusTracker.kt` | +| 4 | Create `SubmitSelectionManager` for per-connector checkbox state | `submit/SubmitSelectionManager.kt` | +| 5 | Implement `JsonSubmitConnector` | `submit/json/JsonSubmitConnector.kt` | +| 6 | Create `SubmitModule` (Dagger bindings) | `submit/SubmitModule.kt` | +| 7 | Add `backingItems` to `ReportListItem` | `ReportController.kt` | +| 8 | **Lock submitted items in command handler** — inject `SubmitStatusTracker` into `Activities`, guard all mutation methods | `command/Activities.kt` | +| 9 | **Lock submitted items in UI** — disable action buttons in cell, add validation to `ValidatingCommandHandler` | `TimeTrackingItemCellWithActions.kt`, `ActivitiesController.kt` | +| 10 | Add connector dropdown, checkbox, and submit button to Activities view | `TimeTrackingItemCellWithActions.kt`, `ActivitiesController.kt`, `ActivitiesPanel.fxml` | +| 11 | Add connector dropdown, checkbox column, and submit button to Report view | `ReportController.kt`, `ReportPanel.fxml` | +| 12 | Register `SubmitModule` in `UIApplication` component | `UIApplication.kt` | +| 13 | **Cross-view sync** — publish `ItemsSubmitted` event after submit; both views subscribe to refresh | `event/Event.kt`, `ActivitiesController.kt`, `ReportController.kt` | + +## Future Possibilities + +- CSV connector +- Jira connector (reusing existing `JiraClient`) +- CLI commands (`stt submit --target json --since 7 days`) +- Submit presets (per-connector configuration in YAML, e.g., CSV delimiter, date format) +- Drag-and-drop submit to Finder/Explorer \ No newline at end of file diff --git a/new-prompts.md b/new-prompts.md new file mode 100644 index 00000000..6e594512 --- /dev/null +++ b/new-prompts.md @@ -0,0 +1,5 @@ + +# Feature Request: Reporting TimeTrackingItems to External System + +i'd like to build a feature, that allows me to report TimeTrackingItems (but also their summarizations as ReportingItems) to external system. the user should be allowed to trigger this from the + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 4fcdff09..f5b37570 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -27,5 +27,6 @@ // export is needed so mbassy works (using reflection) opens org.stt.event; opens org.stt.query; + opens org.stt.submit to jsoniter; opens org.stt.gui.jfx; } \ No newline at end of file diff --git a/src/main/kotlin/org/stt/cli/CLIApplication.kt b/src/main/kotlin/org/stt/cli/CLIApplication.kt index 55467f3b..e11d78b9 100644 --- a/src/main/kotlin/org/stt/cli/CLIApplication.kt +++ b/src/main/kotlin/org/stt/cli/CLIApplication.kt @@ -7,13 +7,14 @@ import org.stt.config.ConfigModule import org.stt.config.ConfigServiceFacade import org.stt.persistence.BackupCreator import org.stt.persistence.stt.STTPersistenceModule +import org.stt.submit.SubmitModule import org.stt.text.TextModule import org.stt.time.TimeUtilModule import javax.inject.Singleton @Singleton -@Component(modules = [STTPersistenceModule::class, ConfigModule::class, BaseModule::class, TextModule::class, CommandModule::class, TimeUtilModule::class]) +@Component(modules = [STTPersistenceModule::class, ConfigModule::class, BaseModule::class, TextModule::class, CommandModule::class, TimeUtilModule::class, SubmitModule::class]) interface CLIApplication { fun backupCreator(): BackupCreator diff --git a/src/main/kotlin/org/stt/command/Activities.kt b/src/main/kotlin/org/stt/command/Activities.kt index b072e2b0..27e50f17 100644 --- a/src/main/kotlin/org/stt/command/Activities.kt +++ b/src/main/kotlin/org/stt/command/Activities.kt @@ -11,6 +11,7 @@ import org.stt.model.TimeTrackingItem import org.stt.persistence.ItemPersister import org.stt.query.Criteria import org.stt.query.TimeTrackingItemQueries +import org.stt.submit.SubmitStatusTracker import org.stt.time.DateTimes import java.util.* import javax.inject.Inject @@ -23,7 +24,8 @@ import javax.inject.Singleton class Activities @Inject constructor(private val persister: ItemPersister, private val queries: TimeTrackingItemQueries, - publisher: Optional>) : CommandHandler { + publisher: Optional>, + private val submitStatusTracker: SubmitStatusTracker) : CommandHandler { private val publisher: PubSubSupport = publisher.map { it as PubSubSupport }.orElseGet { DoNotPublish() } @@ -32,6 +34,7 @@ constructor(private val persister: ItemPersister, val potentialItemToReplace = ongoingItemThatWouldEnd(newItem) ?: itemWithEditedActivity(newItem) if (potentialItemToReplace != null) { + submitStatusTracker.requireNotSubmitted(potentialItemToReplace) persister.replace(potentialItemToReplace, command.newItem) publisher.publish(ItemReplaced(potentialItemToReplace, command.newItem)) } else { @@ -55,6 +58,7 @@ constructor(private val persister: ItemPersister, override fun endCurrentActivity(command: EndCurrentItem) { queries.ongoingItem?.let { + submitStatusTracker.requireNotSubmitted(it) val derivedItem = it.withEnd(command.endAt) persister.replace(it, derivedItem) publisher.publish(ItemReplaced(it, derivedItem)) @@ -62,15 +66,24 @@ constructor(private val persister: ItemPersister, } override fun removeActivity(command: RemoveActivity) { + submitStatusTracker.requireNotSubmitted(command.itemToDelete) persister.delete(command.itemToDelete) publisher.publish(ItemDeleted(command.itemToDelete)) } override fun removeActivityAndCloseGap(command: RemoveActivity) { + submitStatusTracker.requireNotSubmitted(command.itemToDelete) val adjacentItems = queries.getAdjacentItems(command.itemToDelete) val previous = adjacentItems.previousItem val next = adjacentItems.nextItem + if (previous != null) { + submitStatusTracker.requireNotSubmitted(previous) + } + if (next != null) { + submitStatusTracker.requireNotSubmitted(next) + } + if (previous != null) { if (next != null && previousAndNextActivitiesMatch(previous, next)) { val replaceAllWith = next.end?.let { previous.withEnd(it) } ?: previous.withPendingEnd() @@ -110,6 +123,7 @@ constructor(private val persister: ItemPersister, } override fun bulkChangeActivity(itemsToChange: Collection, activity: String) { + itemsToChange.forEach { submitStatusTracker.requireNotSubmitted(it) } val updatedItems = persister.updateActivitities(itemsToChange, activity) updatedItems.map { updatedItem -> ItemReplaced(updatedItem.original, updatedItem.updated) } .forEach { publisher.publish(it) } diff --git a/src/main/kotlin/org/stt/command/CommandModule.kt b/src/main/kotlin/org/stt/command/CommandModule.kt index 8149aec1..6854efb2 100644 --- a/src/main/kotlin/org/stt/command/CommandModule.kt +++ b/src/main/kotlin/org/stt/command/CommandModule.kt @@ -5,6 +5,7 @@ import dagger.Provides import net.engio.mbassy.bus.MBassador import org.stt.persistence.ItemPersister import org.stt.query.TimeTrackingItemQueries +import org.stt.submit.SubmitStatusTracker import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle @@ -15,8 +16,8 @@ import javax.inject.Named class CommandModule { @Provides - fun provideCommandHandler(persister: ItemPersister, queries: TimeTrackingItemQueries, eventBus: Optional>): CommandHandler { - return Activities(persister, queries, eventBus) + fun provideCommandHandler(persister: ItemPersister, queries: TimeTrackingItemQueries, eventBus: Optional>, submitStatusTracker: SubmitStatusTracker): CommandHandler { + return Activities(persister, queries, eventBus, submitStatusTracker) } @Provides diff --git a/src/main/kotlin/org/stt/config/ConfigModule.kt b/src/main/kotlin/org/stt/config/ConfigModule.kt index ee8bc31e..3b05a3fa 100644 --- a/src/main/kotlin/org/stt/config/ConfigModule.kt +++ b/src/main/kotlin/org/stt/config/ConfigModule.kt @@ -5,6 +5,7 @@ import dagger.Module import dagger.Provides import java.io.File import javax.inject.Named +import org.stt.submit.SubmitConfig @Module class ConfigModule { @@ -49,6 +50,11 @@ class ConfigModule { return configRoot.jira } + @Provides + fun provideSubmitConfig(configRoot: ConfigRoot): SubmitConfig { + return configRoot.submit + } + @Provides @Named("homePath") fun provideHomePath(): String { diff --git a/src/main/kotlin/org/stt/config/ConfigRoot.kt b/src/main/kotlin/org/stt/config/ConfigRoot.kt index 7be17fac..3dbe63dc 100644 --- a/src/main/kotlin/org/stt/config/ConfigRoot.kt +++ b/src/main/kotlin/org/stt/config/ConfigRoot.kt @@ -1,5 +1,7 @@ package org.stt.config +import org.stt.submit.SubmitConfig + class ConfigRoot : ConfigurationContainer { var activities = ActivitiesConfig() var prefixGrouper = CommonPrefixGrouperConfig() @@ -9,4 +11,5 @@ class ConfigRoot : ConfigurationContainer { var sttFile = PathSetting("\$HOME$/.stt/activities") var cli = CliConfig() var jira = JiraConfig() + var submit = SubmitConfig() } diff --git a/src/main/kotlin/org/stt/event/Event.kt b/src/main/kotlin/org/stt/event/Event.kt index ce6f98a4..078dcffd 100644 --- a/src/main/kotlin/org/stt/event/Event.kt +++ b/src/main/kotlin/org/stt/event/Event.kt @@ -4,4 +4,5 @@ package org.stt.event class NotifyUser(val message: String) class ShuttingDown class TimePassedEvent +class ItemsSubmitted diff --git a/src/main/kotlin/org/stt/gui/UIApplication.kt b/src/main/kotlin/org/stt/gui/UIApplication.kt index 062ae6bc..006f72e3 100644 --- a/src/main/kotlin/org/stt/gui/UIApplication.kt +++ b/src/main/kotlin/org/stt/gui/UIApplication.kt @@ -13,13 +13,15 @@ import org.stt.gui.jfx.JFXModule import org.stt.gui.jfx.MainWindowController import org.stt.persistence.BackupCreator import org.stt.persistence.stt.STTPersistenceModule +import org.stt.submit.SubmitModule +import org.stt.submit.SubmitStatusTracker import org.stt.text.TextModule import org.stt.time.TimeUtilModule import java.util.concurrent.ExecutorService import javax.inject.Singleton @Singleton -@Component(modules = [(TimeUtilModule::class), (STTPersistenceModule::class), (I18NModule::class), (EventBusModule::class), (TextModule::class), (JFXModule::class), (BaseModule::class), (ConfigModule::class), (CommandModule::class)]) +@Component(modules = [(TimeUtilModule::class), (STTPersistenceModule::class), (I18NModule::class), (EventBusModule::class), (TextModule::class), (JFXModule::class), (BaseModule::class), (ConfigModule::class), (CommandModule::class), (SubmitModule::class)]) interface UIApplication { fun eventBus(): MBassador @@ -33,6 +35,8 @@ interface UIApplication { fun executorService(): ExecutorService + fun submitStatusTracker(): SubmitStatusTracker + @Component.Builder interface Builder { fun baseModule(module: BaseModule): Builder diff --git a/src/main/kotlin/org/stt/gui/UIMain.kt b/src/main/kotlin/org/stt/gui/UIMain.kt index 5f233cb3..67fdf884 100644 --- a/src/main/kotlin/org/stt/gui/UIMain.kt +++ b/src/main/kotlin/org/stt/gui/UIMain.kt @@ -40,6 +40,7 @@ class UIMain : Application() { startService(uiApplication.configService()) startService(uiApplication.backupCreator()) startService(uiApplication.itemLogService()) + startService(uiApplication.submitStatusTracker()) LOG.info("init() done") mainWindowController = uiApplication.mainWindow() diff --git a/src/main/kotlin/org/stt/gui/jfx/ActivitiesController.kt b/src/main/kotlin/org/stt/gui/jfx/ActivitiesController.kt index e2727ede..d94ff046 100644 --- a/src/main/kotlin/org/stt/gui/jfx/ActivitiesController.kt +++ b/src/main/kotlin/org/stt/gui/jfx/ActivitiesController.kt @@ -27,6 +27,7 @@ import org.stt.Streams import org.stt.Strings.commonPrefix import org.stt.command.* import org.stt.config.ActivitiesConfig +import org.stt.event.ItemsSubmitted import org.stt.event.ShuttingDown import org.stt.gui.jfx.STTOptionDialogs.Result import org.stt.gui.jfx.TimeTrackingItemCellWithActions.ActionsHandler @@ -38,6 +39,9 @@ import org.stt.model.ItemReplaced import org.stt.model.TimeTrackingItem import org.stt.query.Criteria import org.stt.query.TimeTrackingItemQueries +import org.stt.submit.SubmitConnector +import org.stt.submit.SubmitSelectionManager +import org.stt.submit.SubmitStatusTracker import org.stt.text.ExpansionProvider import org.stt.validation.ItemAndDateValidator import java.awt.Desktop @@ -74,7 +78,10 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR @param:Named("glyph") private val fontAwesome: Font, private val worktimePane: WorktimePane, @param:Named("activityToText") private val labelToNodeMapper: @JvmSuppressWildcards ActivityTextDisplayProcessor, - private val commandHighlighterFactory: CommandHighlighter.Factory) : ActionsHandler { + private val commandHighlighterFactory: CommandHighlighter.Factory, + private val submitStatusTracker: SubmitStatusTracker, + private val submitSelectionManager: SubmitSelectionManager, + private val submitConnectors: Set<@JvmSuppressWildcards SubmitConnector>) : ActionsHandler { internal val allItems = FXCollections .observableArrayList() private val filterDuplicatesWhenSearching = activitiesConfig.isFilterDuplicatesWhenSearching @@ -89,6 +96,10 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR @FXML private lateinit var activityListToolbar: ToolBar + private val activeConnectors = FXCollections.observableArrayList() + private lateinit var activeConnectorCombo: ComboBox + private var activeConnector: SubmitConnector? = null + private val suggestedContinuations: List get() { val textToExpand = textFromStartToCaret @@ -110,6 +121,11 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR updateItems() } + @Handler + fun onItemsSubmitted(event: ItemsSubmitted) { + activityList.refresh() + } + private fun setCommandText(textToSet: String, selectionStart: Int = textToSet.length, selectionEnd: Int = textToSet.length) { with(commandText) { replaceText(textToSet) @@ -154,6 +170,12 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR override fun continueItem(item: TimeTrackingItem) { LOG.fine { "Continuing item: $item" } + try { + submitStatusTracker.requireNotSubmitted(item) + } catch (e: IllegalStateException) { + sttOptionDialogs.showWarning(localization.getString("activities.submit.itemLocked")) + return + } activities.resumeActivity(ResumeActivity(item, LocalDateTime.now())) clearCommand() @@ -168,11 +190,23 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR override fun edit(item: TimeTrackingItem) { LOG.fine { "Editing item: $item" } + try { + submitStatusTracker.requireNotSubmitted(item) + } catch (e: IllegalStateException) { + sttOptionDialogs.showWarning(localization.getString("activities.submit.itemLocked")) + return + } setCommandText(commandFormatter.asNewItemCommandText(item), 0, item.activity.length) } override fun delete(item: TimeTrackingItem) { LOG.fine { "Deleting item: $item" } + try { + submitStatusTracker.requireNotSubmitted(item) + } catch (e: IllegalStateException) { + sttOptionDialogs.showWarning(localization.getString("activities.submit.itemLocked")) + return + } if (!activitiesConfig.isAskBeforeDeleting || sttOptionDialogs.showDeleteOrKeepDialog(item) == Result.PERFORM_ACTION) { val command = RemoveActivity(item) if (activitiesConfig.isDeleteClosesGaps) { @@ -185,6 +219,12 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR override fun stop(item: TimeTrackingItem) { LOG.fine { "Stopping item: $item" } + try { + submitStatusTracker.requireNotSubmitted(item) + } catch (e: IllegalStateException) { + sttOptionDialogs.showWarning(localization.getString("activities.submit.itemLocked")) + return + } States.requireThat(item.end == null, "Item to finish is already finished") activities.endCurrentActivity(EndCurrentItem(LocalDateTime.now())) @@ -193,6 +233,28 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR } } + private fun submitSelectedItems() { + val connector = activeConnector ?: return + val connectorId = connector.javaClass.simpleName.lowercase() + val selectedKeys = submitSelectionManager.getSelectedItems(connectorId) + val selectedItems = allItems.filter { item -> + val raw = "${item.activity}|${item.start}|${item.end}" + val key = Base64.getEncoder().encodeToString(raw.toByteArray()) + selectedKeys.contains(key) + } + if (selectedItems.isEmpty()) return + try { + connector.submitItems(selectedItems) + selectedItems.forEach { submitStatusTracker.markSubmitted(it, connectorId) } + submitSelectionManager.clearSelection(connectorId) + activityList.refresh() + eventBus.publish(ItemsSubmitted()) + } catch (e: Exception) { + LOG.log(Level.SEVERE, "Failed to submit items", e) + sttOptionDialogs.showWarning(localization.getString("activities.submit.failed")) + } + } + @FXML fun initialize() { @@ -200,6 +262,7 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR addCommandText() addInsertButton() addNavigationButtonsForActivitiesList() + addSubmitToolbar() val filteredList = TimeTrackingListFilter(allItems, commandText.textProperty(), filterDuplicatesWhenSearching) @@ -221,6 +284,31 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR } } + private fun addSubmitToolbar() { + if (submitConnectors.isEmpty()) return + + activeConnectors.addAll(submitConnectors) + activeConnectorCombo = ComboBox(activeConnectors) + activeConnectorCombo.converter = object : javafx.util.StringConverter() { + override fun toString(obj: SubmitConnector?): String = obj?.id ?: "" + override fun fromString(string: String): SubmitConnector? = activeConnectors.find { it.id == string } + } + activeConnectorCombo.selectionModel.selectFirst() + activeConnector = activeConnectorCombo.value + activeConnectorCombo.valueProperty().addListener { _, _, newValue -> + activeConnector = newValue + activityList.refresh() + } + + val submitButton = Button(localization.getString("activities.submit.button")) + submitButton.setOnAction { submitSelectedItems() } + + val spacer = Region() + HBox.setHgrow(spacer, Priority.ALWAYS) + + activityListToolbar.items.addAll(spacer, Label(localization.getString("activities.submit.connector")), activeConnectorCombo, submitButton) + } + private fun addNavigationButtonsForActivitiesList() { val space = Region() HBox.setHgrow(space, Priority.ALWAYS) @@ -341,7 +429,7 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR private fun setupCellFactory(lastItemOfDay: Predicate) { activityList.cellFactory = object : Callback, javafx.util.Callback, ListCell> { override fun call(p0: ListView?): ListCell { - return TimeTrackingItemCellWithActions(fontAwesome, localization, lastItemOfDay, this@ActivitiesController, labelToNodeMapper) + return TimeTrackingItemCellWithActions(fontAwesome, localization, lastItemOfDay, this@ActivitiesController, labelToNodeMapper, submitStatusTracker, submitSelectionManager, { activeConnector?.id }) } } } @@ -458,4 +546,4 @@ internal constructor(private val sttOptionDialogs: STTOptionDialogs, // NOSONAR .name) private const val WIKI_URL = "https://github.com/SimpleTimeTracking/StandaloneClient/wiki/CLI" } -} +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/gui/jfx/ReportController.kt b/src/main/kotlin/org/stt/gui/jfx/ReportController.kt index 727a2058..43dfeb81 100644 --- a/src/main/kotlin/org/stt/gui/jfx/ReportController.kt +++ b/src/main/kotlin/org/stt/gui/jfx/ReportController.kt @@ -4,11 +4,14 @@ import javafx.scene.control.skin.DatePickerSkin import javafx.animation.PauseTransition import javafx.application.Platform import javafx.beans.binding.* +import javafx.beans.property.SimpleObjectProperty import javafx.beans.property.SimpleStringProperty import javafx.beans.value.ObservableValue +import javafx.collections.FXCollections import javafx.collections.ObservableList import javafx.fxml.FXML import javafx.fxml.FXMLLoader +import javafx.geometry.Pos import javafx.scene.Node import javafx.scene.control.* import javafx.scene.control.TableColumn.CellDataFeatures @@ -17,9 +20,7 @@ import javafx.scene.control.cell.PropertyValueFactory import javafx.scene.input.Clipboard import javafx.scene.input.ClipboardContent import javafx.scene.input.MouseEvent -import javafx.scene.layout.Pane -import javafx.scene.layout.Region -import javafx.scene.layout.VBox +import javafx.scene.layout.* import javafx.scene.paint.Color import javafx.scene.text.Font import javafx.scene.text.Text @@ -33,9 +34,14 @@ import org.stt.config.ActivitiesConfig import org.stt.gui.jfx.binding.MappedListBinding import org.stt.gui.jfx.binding.ReportBinding import org.stt.gui.jfx.binding.STTBindings +import org.stt.event.ItemsSubmitted import org.stt.model.ItemModified +import org.stt.model.TimeTrackingItem import org.stt.query.TimeTrackingItemQueries import org.stt.reporting.SummingReportGenerator.Report +import org.stt.submit.SubmitConnector +import org.stt.submit.SubmitSelectionManager +import org.stt.submit.SubmitStatusTracker import org.stt.text.ItemCategorizer import org.stt.text.ItemGrouper import org.stt.time.DateTimes @@ -49,6 +55,8 @@ import java.util.* import java.util.concurrent.Callable import java.util.function.BiConsumer import java.util.function.Consumer +import java.util.logging.Level +import java.util.logging.Logger import java.util.stream.Collectors import javax.inject.Inject import javax.inject.Named @@ -61,7 +69,10 @@ internal constructor(private val localization: ResourceBundle, private val itemCategorizer: ItemCategorizer, private val activitiesConfig: ActivitiesConfig, @param:Named("glyph") private val fontaweSome: Font, - private val eventBus: MBassador) { + private val eventBus: MBassador, + private val submitStatusTracker: SubmitStatusTracker, + private val submitSelectionManager: SubmitSelectionManager, + private val submitConnectors: Set<@JvmSuppressWildcards SubmitConnector>) { @FXML private lateinit var columnForRoundedDuration: TableColumn @FXML @@ -86,8 +97,11 @@ internal constructor(private val localization: ResourceBundle, private lateinit var totalDuration: Label @FXML private lateinit var effectiveDuration: Label + @FXML + private lateinit var borderPane: BorderPane private lateinit var datePicker: DatePicker + private var columnForCheckbox: TableColumn? = null internal val panel: NotificationPane by lazy { loadAndInjectFXML() @@ -95,6 +109,12 @@ internal constructor(private val localization: ResourceBundle, private val notificationPause = PauseTransition(javafx.util.Duration.seconds(2.0)) private lateinit var trackedDays: Set + private val activeConnectors = FXCollections.observableArrayList() + private lateinit var activeConnectorCombo: ComboBox + private var activeConnector: SubmitConnector? = null + + private var latestReport: Report? = null + private fun loadAndInjectFXML(): NotificationPane { val loader = FXMLLoader(javaClass.getResource( "/org/stt/gui/jfx/ReportPanel.fxml"), localization) @@ -112,6 +132,7 @@ internal constructor(private val localization: ResourceBundle, @FXML fun initialize() { + eventBus.subscribe(this) setupNavigation() tableForReport.selectionModel.selectedIndexProperty().addListener { _ -> Platform.runLater { tableForReport.selectionModel.clearSelection() } } @@ -180,6 +201,11 @@ internal constructor(private val localization: ResourceBundle, applyClipboardTooltip(Consumer { columnForRoundedDuration.setGraphic(it) }, "report.tooltips.copyRow") applyClipboardTooltip(Consumer { startOfReport.graphic = it }, "report.tooltips.copy") applyClipboardTooltip(Consumer { endOfReport.graphic = it }, "report.tooltips.copy") + + addSubmitCheckboxColumn() + addSubmitToolbar() + + reportModel.addListener { _, _, newReport -> latestReport = newReport } } private fun applyClipboardTooltip(on: Consumer, tooltipKey: String) { @@ -229,7 +255,8 @@ internal constructor(private val localization: ResourceBundle, reportingItem.comment, reportingItem.isBreak, reportingItem.duration, - rounder.roundDuration(reportingItem.duration)) + rounder.roundDuration(reportingItem.duration), + reportingItem.backingItems) } }, report) } @@ -427,7 +454,127 @@ internal constructor(private val localization: ResourceBundle, } class ReportListItem internal constructor(val comment: String, val isBreak: Boolean, val duration: Duration, - internal val roundedDuration: Duration) + internal val roundedDuration: Duration, + internal val backingItems: List = emptyList()) + + private fun addSubmitCheckboxColumn() { + if (submitConnectors.isEmpty()) return + columnForCheckbox = TableColumn() + columnForCheckbox!!.prefWidth = 40.0 + columnForCheckbox!!.maxWidth = 40.0 + columnForCheckbox!!.isResizable = false + columnForCheckbox!!.setCellValueFactory { cellData -> + val item = cellData.value + val connectorId = activeConnector?.id + if (connectorId == null) { + SimpleObjectProperty(false) + } else if (item.backingItems.isEmpty()) { + SimpleObjectProperty(false) + } else { + val allSubmitted = item.backingItems.all { submitStatusTracker.isSubmitted(it, connectorId) } + val someSubmitted = item.backingItems.any { submitStatusTracker.isSubmitted(it, connectorId) } + val selected = submitSelectionManager.isSelected(connectorId, item.backingItems.first()) + SimpleObjectProperty(allSubmitted || someSubmitted || selected) + } + } + columnForCheckbox!!.setCellFactory { + object : TableCell() { + private val checkBox = CheckBox() + + init { + graphic = checkBox + checkBox.setOnAction { + val rowItem = tableRow.item ?: return@setOnAction + val connectorId = activeConnector?.id ?: return@setOnAction + if (checkBox.isSelected) { + rowItem.backingItems.forEach { submitSelectionManager.setSelected(connectorId, it, true) } + } else { + rowItem.backingItems.forEach { submitSelectionManager.setSelected(connectorId, it, false) } + } + } + } + + override fun updateItem(item: Boolean?, empty: Boolean) { + super.updateItem(item, empty) + if (empty || item == null) { + graphic = null + } else { + val rowItem = tableRow.item ?: return + val connectorId = activeConnector?.id ?: return + val allSubmitted = rowItem.backingItems.isNotEmpty() && rowItem.backingItems.all { submitStatusTracker.isSubmitted(it, connectorId) } + val someSubmitted = rowItem.backingItems.any { submitStatusTracker.isSubmitted(it, connectorId) } + checkBox.isSelected = allSubmitted || someSubmitted || rowItem.backingItems.any { submitSelectionManager.isSelected(connectorId, it) } + checkBox.isDisable = allSubmitted + checkBox.isIndeterminate = someSubmitted && !allSubmitted + graphic = checkBox + } + } + } + } + tableForReport.columns.add(columnForCheckbox!!) + } + + private fun addSubmitToolbar() { + if (submitConnectors.isEmpty()) return + + activeConnectors.addAll(submitConnectors) + activeConnectorCombo = ComboBox(activeConnectors) + activeConnectorCombo.converter = object : javafx.util.StringConverter() { + override fun toString(obj: SubmitConnector?): String = obj?.id ?: "" + override fun fromString(string: String): SubmitConnector? = activeConnectors.find { it.id == string } + } + activeConnectorCombo.selectionModel.selectFirst() + activeConnector = activeConnectorCombo.value + activeConnectorCombo.valueProperty().addListener { _, _, newValue -> + activeConnector = newValue + tableForReport.refresh() + } + + val submitButton = Button(localization.getString("report.submit.button")) + submitButton.setOnAction { submitSelectedRows() } + + val toolbar = ToolBar( + Label(localization.getString("report.submit.connector")), + activeConnectorCombo, + Region().apply { HBox.setHgrow(this, Priority.ALWAYS) }, + submitButton + ) + + borderPane.bottom = toolbar + } + + private fun submitSelectedRows() { + val connector = activeConnector ?: return + val connectorId = connector.id + val report = latestReport ?: return + + val selectedBackingItems = tableForReport.items.flatMap { item -> + item.backingItems.filter { submitSelectionManager.isSelected(connectorId, it) } + }.distinct().filter { !submitStatusTracker.isSubmitted(it, connectorId) } + + if (selectedBackingItems.isEmpty()) return + + val selectedReportItems = tableForReport.items.filter { item -> + item.backingItems.any { submitSelectionManager.isSelected(connectorId, it) } + }.toList() + + try { + connector.submitSummary(report, selectedReportItems) + selectedBackingItems.forEach { submitStatusTracker.markSubmitted(it, connectorId) } + selectedReportItems.forEach { item -> + item.backingItems.forEach { submitSelectionManager.setSelected(connectorId, it, false) } + } + tableForReport.refresh() + eventBus.publish(ItemsSubmitted()) + } catch (e: Exception) { + LOG.log(Level.SEVERE, "Failed to submit items", e) + } + } + + @Handler + fun onItemsSubmitted(event: ItemsSubmitted) { + tableForReport.refresh() + } @Listener(references = References.Strong) private class OnItemChangeListener internal constructor(private val binding: ObjectBinding<*>) { @@ -437,4 +584,8 @@ internal constructor(private val localization: ResourceBundle, binding.invalidate() } } + + companion object { + private val LOG = Logger.getLogger(ReportController::class.java.name) + } } diff --git a/src/main/kotlin/org/stt/gui/jfx/STTOptionDialogs.kt b/src/main/kotlin/org/stt/gui/jfx/STTOptionDialogs.kt index bf95fb24..605d55c7 100644 --- a/src/main/kotlin/org/stt/gui/jfx/STTOptionDialogs.kt +++ b/src/main/kotlin/org/stt/gui/jfx/STTOptionDialogs.kt @@ -87,6 +87,14 @@ constructor(private val localization: ResourceBundle, .orElse(Result.ABORT) } + internal fun showWarning(message: String) { + val dialog = Dialog() + dialog.headerText = localization.getString("warning") + dialog.contentText = message + dialog.dialogPane.buttonTypes.addAll(ButtonType.OK) + dialog.showAndWait() + } + enum class Result { PERFORM_ACTION, ABORT } diff --git a/src/main/kotlin/org/stt/gui/jfx/TimeTrackingItemCellWithActions.kt b/src/main/kotlin/org/stt/gui/jfx/TimeTrackingItemCellWithActions.kt index 4c987575..49b53092 100644 --- a/src/main/kotlin/org/stt/gui/jfx/TimeTrackingItemCellWithActions.kt +++ b/src/main/kotlin/org/stt/gui/jfx/TimeTrackingItemCellWithActions.kt @@ -13,6 +13,8 @@ import javafx.scene.text.Font import org.stt.gui.jfx.Glyph.Companion.GLYPH_SIZE_MEDIUM import org.stt.gui.jfx.Glyph.Companion.glyph import org.stt.model.TimeTrackingItem +import org.stt.submit.SubmitSelectionManager +import org.stt.submit.SubmitStatusTracker import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -23,13 +25,17 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, localization: ResourceBundle, private val lastItemOfDay: Predicate, actionsHandler: ActionsHandler, - labelToNodeMapper: ActivityTextDisplayProcessor) : ListCell() { + labelToNodeMapper: ActivityTextDisplayProcessor, + private val submitStatusTracker: SubmitStatusTracker, + private val submitSelectionManager: SubmitSelectionManager, + private val connectorIdProvider: () -> String?) : ListCell() { private val cellPane = HBox(2.0) val editButton: Button val continueButton: Button val deleteButton: Button val stopButton: Button + private val submitCheckBox: CheckBox private val lastItemOnDayPane: BorderPane private val newDayNode: Node @@ -37,11 +43,12 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, private val actions: HBox init { - itemNodes = TimeTrackingItemNodes(labelToNodeMapper, TIME_FORMATTER, fontAwesome, 450, 180, localization) + itemNodes = TimeTrackingItemNodes(labelToNodeMapper, TIME_FORMATTER, fontAwesome, 450, 220, localization) editButton = FramelessButton(glyph(fontAwesome, Glyph.PENCIL, GLYPH_SIZE_MEDIUM)) continueButton = FramelessButton(glyph(fontAwesome, Glyph.PLAY_CIRCLE, GLYPH_SIZE_MEDIUM, Color.DARKGREEN)) deleteButton = FramelessButton(glyph(fontAwesome, Glyph.TRASH, GLYPH_SIZE_MEDIUM, Color.web("e26868"))) stopButton = FramelessButton(glyph(fontAwesome, Glyph.STOP_CIRCLE, GLYPH_SIZE_MEDIUM, Color.GOLDENROD)) + submitCheckBox = CheckBox() setupTooltips(localization) continueButton.setOnAction { actionsHandler.continueItem(item) } @@ -49,6 +56,12 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, deleteButton.setOnAction { actionsHandler.delete(item) } stopButton.setOnAction { actionsHandler.stop(item) } + submitCheckBox.setOnAction { + val connectorId = connectorIdProvider() ?: return@setOnAction + val currentItem = item ?: return@setOnAction + submitSelectionManager.setSelected(connectorId, currentItem, submitCheckBox.isSelected) + } + actions = HBox(continueButton, editButton, deleteButton) StackPane.setAlignment(actions, Pos.CENTER) val timeOrActions = StackPaneWithoutResize() @@ -62,6 +75,7 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, itemNodes.bindTimePaneOpacity(timePaneOpacity) timeOrActions.children.add(actions) + cellPane.children.add(submitCheckBox) cellPane.alignment = Pos.CENTER_LEFT lastItemOnDayPane = BorderPane() @@ -125,6 +139,18 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, graphic = null } else { actions.children[0] = if (item.end != null) continueButton else stopButton + + val isSubmitted = submitStatusTracker.isSubmitted(item) + editButton.isDisable = isSubmitted + deleteButton.isDisable = isSubmitted + continueButton.isDisable = isSubmitted + stopButton.isDisable = isSubmitted + + val connectorId = connectorIdProvider() + val isSubmittedToActive = connectorId?.let { submitStatusTracker.isSubmitted(item, it) } ?: false + submitCheckBox.isSelected = isSubmittedToActive || (connectorId?.let { submitSelectionManager.isSelected(it, item) } ?: false) + submitCheckBox.isDisable = isSubmittedToActive + itemNodes.setItem(item) graphic = if (lastItemOfDay.test(item)) { setupLastItemOfDayPane() @@ -161,4 +187,4 @@ internal open class TimeTrackingItemCellWithActions(fontAwesome: Font, private val TIME_FORMATTER = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM) private val DATE_FORMATTER = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) } -} +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/model/ReportingItem.kt b/src/main/kotlin/org/stt/model/ReportingItem.kt index 7ddc4fc6..d3229500 100644 --- a/src/main/kotlin/org/stt/model/ReportingItem.kt +++ b/src/main/kotlin/org/stt/model/ReportingItem.kt @@ -3,6 +3,6 @@ package org.stt.model import java.time.Duration -data class ReportingItem(val duration: Duration, val roundedDuration: Duration, val comment: String, val isBreak: Boolean) { +data class ReportingItem(val duration: Duration, val roundedDuration: Duration, val comment: String, val isBreak: Boolean, val backingItems: List = emptyList()) { override fun toString() = "$duration $comment ${if (isBreak) "(break)" else ""}" } diff --git a/src/main/kotlin/org/stt/reporting/SummingReportGenerator.kt b/src/main/kotlin/org/stt/reporting/SummingReportGenerator.kt index 1c14010c..7c637e31 100644 --- a/src/main/kotlin/org/stt/reporting/SummingReportGenerator.kt +++ b/src/main/kotlin/org/stt/reporting/SummingReportGenerator.kt @@ -31,8 +31,8 @@ class SummingReportGenerator( var startOfReport: LocalDateTime? = null var endOfReport: LocalDateTime? = null - val reportItems = HashMap() + val backingItems = HashMap>() var uncoveredDuration = Duration.ZERO var lastItem: TimeTrackingItem? = null itemsToRead.use { items -> @@ -65,9 +65,9 @@ class SummingReportGenerator( if (duration.isNegative) { duration = Duration.ZERO } - // assemble val comment = item.activity + backingItems.computeIfAbsent(comment) { mutableListOf() }.add(item) val currentItem = reportItems.getOrPut(comment) { ReportingItem( @@ -77,12 +77,12 @@ class SummingReportGenerator( ItemCategorizer.ItemCategory.BREAK == itemCategorizer.getCategory(comment) ) } - // overwrite currentItem in the collection and update values val newDuration = currentItem.duration.plus(duration) reportItems.put( comment, currentItem.copy( duration = newDuration, - roundedDuration = rounder.roundDuration(newDuration) + roundedDuration = rounder.roundDuration(newDuration), + backingItems = backingItems[comment]?.toList() ?: emptyList() ) ) } @@ -97,7 +97,6 @@ class SummingReportGenerator( ) } - class Report( val reportingItems: List, val start: LocalDateTime?, @@ -105,4 +104,4 @@ class SummingReportGenerator( val uncoveredDuration: Duration, val roundedUncoveredDuration: Duration ) -} +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/SubmitConfig.kt b/src/main/kotlin/org/stt/submit/SubmitConfig.kt new file mode 100644 index 00000000..ef89d64e --- /dev/null +++ b/src/main/kotlin/org/stt/submit/SubmitConfig.kt @@ -0,0 +1,10 @@ +package org.stt.submit + +data class SubmitConfig( + val connectors: List = emptyList() +) + +data class ConnectorConfig( + val type: String = "", + val file: String = "" +) \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/SubmitConnector.kt b/src/main/kotlin/org/stt/submit/SubmitConnector.kt new file mode 100644 index 00000000..7f4b90bb --- /dev/null +++ b/src/main/kotlin/org/stt/submit/SubmitConnector.kt @@ -0,0 +1,11 @@ +package org.stt.submit + +import org.stt.model.TimeTrackingItem +import org.stt.reporting.SummingReportGenerator.Report +import org.stt.gui.jfx.ReportController.ReportListItem + +interface SubmitConnector { + val id: String + fun submitItems(items: List) + fun submitSummary(report: Report, selectedItems: List) +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/SubmitModule.kt b/src/main/kotlin/org/stt/submit/SubmitModule.kt new file mode 100644 index 00000000..4a8f8776 --- /dev/null +++ b/src/main/kotlin/org/stt/submit/SubmitModule.kt @@ -0,0 +1,26 @@ +package org.stt.submit + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import org.stt.submit.json.JsonSubmitConnector +import javax.inject.Named +import javax.inject.Singleton + +@Module +class SubmitModule { + + @Provides + @Singleton + fun provideSubmitSelectionManager(): SubmitSelectionManager { + return SubmitSelectionManager() + } + + @Provides + @IntoSet + fun provideJsonSubmitConnector(configRoot: org.stt.config.ConfigRoot, @Named("homePath") homePath: String): SubmitConnector { + val connectorConfig = configRoot.submit.connectors.firstOrNull { it.type == "json" } + ?: ConnectorConfig(type = "json", file = ".stt/submit.json") + return JsonSubmitConnector(connectorConfig, homePath) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/SubmitSelectionManager.kt b/src/main/kotlin/org/stt/submit/SubmitSelectionManager.kt new file mode 100644 index 00000000..94fa068c --- /dev/null +++ b/src/main/kotlin/org/stt/submit/SubmitSelectionManager.kt @@ -0,0 +1,40 @@ +package org.stt.submit + +import org.stt.model.TimeTrackingItem +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SubmitSelectionManager @Inject +constructor() { + + private val selections: MutableMap> = ConcurrentHashMap() + + fun isSelected(connectorId: String, item: TimeTrackingItem): Boolean { + return selections[connectorId]?.contains(itemKey(item)) == true + } + + fun setSelected(connectorId: String, item: TimeTrackingItem, selected: Boolean) { + val key = itemKey(item) + if (selected) { + selections.computeIfAbsent(connectorId) { mutableSetOf() }.add(key) + } else { + selections[connectorId]?.remove(key) + } + } + + fun getSelectedItems(connectorId: String): Set { + return selections[connectorId]?.toSet() ?: emptySet() + } + + fun clearSelection(connectorId: String) { + selections.remove(connectorId) + } + + private fun itemKey(item: TimeTrackingItem): String { + val raw = "${item.activity}|${item.start}|${item.end}" + return Base64.getEncoder().encodeToString(raw.toByteArray()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/SubmitStatusTracker.kt b/src/main/kotlin/org/stt/submit/SubmitStatusTracker.kt new file mode 100644 index 00000000..2eb4f91d --- /dev/null +++ b/src/main/kotlin/org/stt/submit/SubmitStatusTracker.kt @@ -0,0 +1,87 @@ +package org.stt.submit + +import org.stt.Service +import org.stt.model.TimeTrackingItem +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardOpenOption +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SubmitStatusTracker @Inject +constructor(@Named("homePath") homePath: String) : Service { + + private val statusFile: File = File("$homePath/.stt", "submit-status") + private val submitted: MutableMap> = ConcurrentHashMap() + + override fun start() { + statusFile.parentFile.mkdirs() + if (statusFile.exists()) { + Files.readAllLines(statusFile.toPath()).forEach { line -> + val parts = line.split("|") + if (parts.size >= 3) { + val itemKey = parts[0] + val connectorId = parts[1] + val timestamp = parts[2] + submitted.computeIfAbsent(itemKey) { ConcurrentHashMap() }[connectorId] = timestamp + } + } + } + LOG.info("SubmitStatusTracker loaded ${submitted.size} item statuses") + } + + override fun stop() { + save() + } + + private fun save() { + val lines = submitted.flatMap { (itemKey, connectors) -> + connectors.map { (connectorId, timestamp) -> + "$itemKey|$connectorId|$timestamp" + } + } + Files.write(statusFile.toPath(), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + fun isSubmitted(item: TimeTrackingItem): Boolean { + val key = itemKey(item) + return submitted.containsKey(key) + } + + fun isSubmitted(item: TimeTrackingItem, connectorId: String): Boolean { + val key = itemKey(item) + return submitted[key]?.containsKey(connectorId) == true + } + + fun markSubmitted(item: TimeTrackingItem, connectorId: String) { + val key = itemKey(item) + submitted.computeIfAbsent(key) { ConcurrentHashMap() }[connectorId] = LocalDateTime.now().toString() + } + + fun getSubmitFraction(items: List, connectorId: String): Float { + if (items.isEmpty()) return 1.0f + val submittedCount = items.count { isSubmitted(it, connectorId) } + return submittedCount.toFloat() / items.size + } + + fun requireNotSubmitted(item: TimeTrackingItem) { + if (isSubmitted(item)) { + throw IllegalStateException("Item is already submitted and cannot be modified: $item") + } + } + + private fun itemKey(item: TimeTrackingItem): String { + val raw = "${item.activity}|${item.start}|${item.end}" + return Base64.getEncoder().encodeToString(raw.toByteArray()) + } + + companion object { + private val LOG = Logger.getLogger(SubmitStatusTracker::class.java.name) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/stt/submit/json/JsonSubmitConnector.kt b/src/main/kotlin/org/stt/submit/json/JsonSubmitConnector.kt new file mode 100644 index 00000000..0e35ab9e --- /dev/null +++ b/src/main/kotlin/org/stt/submit/json/JsonSubmitConnector.kt @@ -0,0 +1,99 @@ +package org.stt.submit.json + +import org.stt.model.TimeTrackingItem +import org.stt.reporting.SummingReportGenerator.Report +import org.stt.submit.ConnectorConfig +import org.stt.submit.SubmitConnector +import org.stt.gui.jfx.ReportController.ReportListItem +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardOpenOption +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.logging.Logger +import javax.inject.Inject +import javax.inject.Named + +class JsonSubmitConnector @Inject +constructor(config: ConnectorConfig, @Named("homePath") homePath: String) : SubmitConnector { + + private val outputFile: File + + init { + val filePath = config.file + outputFile = if (filePath.startsWith("/")) { + File(filePath) + } else { + File(homePath, filePath) + } + outputFile.parentFile.mkdirs() + } + + override val id: String = "json" + + override fun submitItems(items: List) { + val json = buildJsonArray(items.map { itemToMap(it) }) + writeJson(json) + LOG.info("Submitted ${items.size} items to $outputFile") + } + + override fun submitSummary(report: Report, selectedItems: List) { + val items = mutableListOf>() + for (reportItem in selectedItems) { + items.add(mapOf( + "comment" to reportItem.comment, + "isBreak" to reportItem.isBreak, + "duration" to reportItem.duration.toString(), + "roundedDuration" to reportItem.roundedDuration.toString() + )) + } + val json = buildJsonArray(items) + writeJson(json) + LOG.info("Submitted ${selectedItems.size} summary items to $outputFile") + } + + private fun itemToMap(item: TimeTrackingItem): Map { + return mapOf( + "activity" to item.activity, + "start" to item.start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + "end" to item.end?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + "submittedAt" to LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + } + + private fun buildJsonArray(items: List>): String { + val sb = StringBuilder() + sb.appendLine("[") + for (i in items.indices) { + val item = items[i] + sb.appendLine(" {") + val entries = item.entries.toList() + for (j in entries.indices) { + val (key, value) = entries[j] + sb.append(" \"$key\": ") + when (value) { + null -> sb.append("null") + is String -> sb.append("\"$value\"") + is Number -> sb.append(value) + is Boolean -> sb.append(value) + else -> sb.append("\"$value\"") + } + if (j < entries.size - 1) sb.append(",") + sb.appendLine() + } + sb.append(" }") + if (i < items.size - 1) sb.append(",") + sb.appendLine() + } + sb.append("]") + return sb.toString() + } + + private fun writeJson(json: String) { + Files.write(outputFile.toPath(), json.toByteArray(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + companion object { + private val LOG = Logger.getLogger(JsonSubmitConnector::class.java.name) + } +} \ No newline at end of file diff --git a/src/main/resources/org/stt/gui/Application.properties b/src/main/resources/org/stt/gui/Application.properties index 1b39a389..71226bd2 100644 --- a/src/main/resources/org/stt/gui/Application.properties +++ b/src/main/resources/org/stt/gui/Application.properties @@ -54,6 +54,13 @@ report.backAWeek.tooltip=First day of week/One week back report.backADay.tooltip=One day back report.forwardAWeek.tooltip=First day of next week/One week forward report.forwardADay.tooltip=One day forward +warning=Warning +activities.submit.connector=Connector: +activities.submit.button=Submit Selected +activities.submit.itemLocked=This item has already been submitted and cannot be modified. +activities.submit.failed=Failed to submit items. Please try again. +report.submit.connector=Connector: +report.submit.button=Submit Selected bulkRename.title=Rename activities bulkRename.text=There are %d other activities with '%s'.\nRename those to '%s' as well? rename=Rename diff --git a/src/test/kotlin/org/stt/IntRangeTest.kt b/src/test/kotlin/org/stt/IntRangeTest.kt new file mode 100644 index 00000000..f14be262 --- /dev/null +++ b/src/test/kotlin/org/stt/IntRangeTest.kt @@ -0,0 +1,40 @@ +package org.stt + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class IntRangeTest { + @Test + fun shouldStoreStartAndEnd() { + // GIVEN + + // WHEN + val sut = IntRange(5, 10) + + // THEN + assertThat(sut.start).isEqualTo(5) + assertThat(sut.end).isEqualTo(10) + } + + @Test + fun shouldAllowStartEqualToEnd() { + // GIVEN + + // WHEN + val sut = IntRange(7, 7) + + // THEN + assertThat(sut.start).isEqualTo(sut.end) + } + + @Test + fun shouldAllowStartGreaterThanEnd() { + // GIVEN + + // WHEN + val sut = IntRange(10, 5) + + // THEN + assertThat(sut.start).isGreaterThan(sut.end) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/StatesTest.kt b/src/test/kotlin/org/stt/StatesTest.kt new file mode 100644 index 00000000..49e913b3 --- /dev/null +++ b/src/test/kotlin/org/stt/StatesTest.kt @@ -0,0 +1,28 @@ +package org.stt + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class StatesTest { + @Test + fun shouldNotThrowWhenConditionIsTrue() { + // GIVEN + + // WHEN + States.requireThat(true, "should not fail") + + // THEN no exception thrown + } + + @Test + fun shouldThrowIllegalStateExceptionWhenConditionIsFalse() { + // GIVEN + val message = "condition failed" + + // WHEN / THEN + assertThatThrownBy { States.requireThat(false, message) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage(message) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/StringsTest.kt b/src/test/kotlin/org/stt/StringsTest.kt new file mode 100644 index 00000000..a2562429 --- /dev/null +++ b/src/test/kotlin/org/stt/StringsTest.kt @@ -0,0 +1,110 @@ +package org.stt + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class StringsTest { + @Test + fun shouldReturnFullStringWhenCommonPrefixIsWholeString() { + // GIVEN + val a = "hello" + val b = "hello world" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEqualTo("hello") + } + + @Test + fun shouldReturnEmptyStringWhenNoCommonPrefix() { + // GIVEN + val a = "abc" + val b = "xyz" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun shouldReturnPartialCommonPrefix() { + // GIVEN + val a = "abcdef" + val b = "abcxyz" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEqualTo("abc") + } + + @Test + fun shouldReturnShorterStringWhenOneIsPrefixOfOther() { + // GIVEN + val a = "test" + val b = "testing" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEqualTo("test") + } + + @Test + fun shouldReturnFullStringWhenStringsAreEqual() { + // GIVEN + val a = "same" + val b = "same" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEqualTo("same") + } + + @Test + fun shouldReturnFirstStringWhenSecondStringIsEmpty() { + // GIVEN + val a = "nonempty" + val b = "" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEqualTo("nonempty") + } + + @Test + fun shouldReturnEmptyStringWhenFirstStringIsEmpty() { + // GIVEN + val a = "" + val b = "nonempty" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun shouldReturnEmptyStringWhenBothStringsAreEmpty() { + // GIVEN + val a = "" + val b = "" + + // WHEN + val result = Strings.commonPrefix(a, b) + + // THEN + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/cli/MainTest.kt b/src/test/kotlin/org/stt/cli/MainTest.kt index e0d16ff9..64dd3288 100644 --- a/src/test/kotlin/org/stt/cli/MainTest.kt +++ b/src/test/kotlin/org/stt/cli/MainTest.kt @@ -6,6 +6,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.stt.command.Activities import org.stt.command.CommandFormatter @@ -16,6 +17,7 @@ import org.stt.persistence.stt.STTItemPersister import org.stt.persistence.stt.STTItemReader import org.stt.query.TimeTrackingItemQueries import org.stt.reporting.WorkingtimeItemProvider +import org.stt.submit.SubmitStatusTracker import org.stt.text.WorktimeCategorizer import org.stt.time.DurationRounder import java.io.* @@ -71,7 +73,7 @@ class MainTest { val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) val commandFormatter = CommandFormatter(CommandTextParser(listOf(timeFormatter, dateTimeFormatter)), dateTimeFormatter, timeFormatter) - val activities = Activities(persister, queries, Optional.empty()) + val activities = Activities(persister, queries, Optional.empty(), Mockito.mock(SubmitStatusTracker::class.java)) sut = Main(queries, reportPrinter, commandFormatter, activities) } diff --git a/src/test/kotlin/org/stt/command/ActivitiesTest.kt b/src/test/kotlin/org/stt/command/ActivitiesTest.kt index e55f7b69..aa4cbc4e 100644 --- a/src/test/kotlin/org/stt/command/ActivitiesTest.kt +++ b/src/test/kotlin/org/stt/command/ActivitiesTest.kt @@ -4,6 +4,7 @@ import org.junit.Before import org.junit.Test import org.mockito.BDDMockito.given import org.mockito.Mock +import org.mockito.Mockito import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.MockitoAnnotations @@ -11,6 +12,7 @@ import org.stt.Matchers.any import org.stt.model.TimeTrackingItem import org.stt.persistence.ItemPersister import org.stt.query.TimeTrackingItemQueries +import org.stt.submit.SubmitStatusTracker import java.time.LocalDateTime import java.util.* import java.util.stream.Stream @@ -25,7 +27,7 @@ class ActivitiesTest { @Before fun setup() { MockitoAnnotations.initMocks(this) - sut = Activities(persister, queries, Optional.empty()) + sut = Activities(persister, queries, Optional.empty(), Mockito.mock(SubmitStatusTracker::class.java)) } @Test diff --git a/src/test/kotlin/org/stt/command/CommandFormatterTest.kt b/src/test/kotlin/org/stt/command/CommandFormatterTest.kt index 231b18d2..1e5c2996 100644 --- a/src/test/kotlin/org/stt/command/CommandFormatterTest.kt +++ b/src/test/kotlin/org/stt/command/CommandFormatterTest.kt @@ -11,12 +11,14 @@ import org.junit.experimental.theories.Theory import org.junit.experimental.theories.suppliers.TestedOn import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.stt.model.TimeTrackingItem import org.stt.persistence.ItemReader import org.stt.persistence.stt.STTItemPersister import org.stt.persistence.stt.STTItemReader import org.stt.query.TimeTrackingItemQueries +import org.stt.submit.SubmitStatusTracker import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStreamReader @@ -53,7 +55,7 @@ class CommandFormatterTest { itemWriter = STTItemPersister(Provider { readerSupplier.get() }, Provider { OutputStreamWriter(FileOutputStream(tempFile), StandardCharsets.UTF_8) }) timeTrackingItemQueries = TimeTrackingItemQueries(itemReaderProvider, Optional.empty()) - activities = Activities(itemWriter, timeTrackingItemQueries, Optional.empty()) + activities = Activities(itemWriter, timeTrackingItemQueries, Optional.empty(), Mockito.mock(SubmitStatusTracker::class.java)) sut = CommandFormatter(CommandTextParser(listOf(TIME_FORMATTER, DATE_TIME_FORMATTER)), DATE_TIME_FORMATTER, TIME_FORMATTER) } diff --git a/src/test/kotlin/org/stt/gui/jfx/ActivitiesControllerTest.kt b/src/test/kotlin/org/stt/gui/jfx/ActivitiesControllerTest.kt index d5f3aa74..6e0bab6f 100644 --- a/src/test/kotlin/org/stt/gui/jfx/ActivitiesControllerTest.kt +++ b/src/test/kotlin/org/stt/gui/jfx/ActivitiesControllerTest.kt @@ -1,5 +1,6 @@ package org.stt.gui.jfx +import javafx.scene.control.ListView import javafx.scene.text.Font import net.engio.mbassy.bus.MBassador import net.engio.mbassy.listener.Handler @@ -12,6 +13,7 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.BDDMockito.given import org.mockito.BDDMockito.willAnswer import org.mockito.Mock +import org.mockito.Mockito import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.stt.Matchers.any @@ -21,13 +23,18 @@ import org.stt.command.CommandHandler import org.stt.command.DoNothing import org.stt.command.NewActivity import org.stt.config.ActivitiesConfig +import org.stt.event.ItemsSubmitted import org.stt.event.ShuttingDown import org.stt.gui.jfx.text.CommandHighlighter import org.stt.model.TimeTrackingItem import org.stt.query.TimeTrackingItemQueries import org.stt.query.WorkTimeQueries +import org.stt.submit.SubmitConnector +import org.stt.submit.SubmitSelectionManager +import org.stt.submit.SubmitStatusTracker import org.stt.text.ExpansionProvider import org.stt.validation.ItemAndDateValidator +import java.lang.reflect.Modifier import java.time.LocalDateTime import java.util.* import java.util.concurrent.ExecutorService @@ -73,7 +80,10 @@ class ActivitiesControllerTest { sut = ActivitiesController(STTOptionDialogs(resourceBundle, fontAwesome, labelToNodeMapper), eventBus, commandFormatter, setOf(expansionProvider), resourceBundle, activitiesConfig, itemValidator, timeTrackingItemQueries, executorService, commandHandler, fontAwesome, - worktimePane, labelToNodeMapper, CommandHighlighter.Factory({ emptyList() })) + worktimePane, labelToNodeMapper, CommandHighlighter.Factory({ emptyList() }), + Mockito.mock(SubmitStatusTracker::class.java), + Mockito.mock(SubmitSelectionManager::class.java), + emptySet()) sut.commandText = StyleClassedTextArea() } @@ -188,6 +198,22 @@ class ActivitiesControllerTest { assertThat(shutdownCalled).isFalse() } + @Test + fun shouldRefreshActivityListOnItemsSubmitted() { + // GIVEN + val activityList = Mockito.spy(ListView()) + sut.javaClass.getDeclaredField("activityList").apply { + isAccessible = true + set(sut, activityList) + } + + // WHEN + sut.onItemsSubmitted(ItemsSubmitted()) + + // THEN + verify(activityList).refresh() + } + @Handler fun shutdownWasCalled(event: ShuttingDown) { shutdownCalled = true diff --git a/src/test/kotlin/org/stt/gui/jfx/TimeTrackingItemCellTest.kt b/src/test/kotlin/org/stt/gui/jfx/TimeTrackingItemCellTest.kt index a5f3796e..780545be 100644 --- a/src/test/kotlin/org/stt/gui/jfx/TimeTrackingItemCellTest.kt +++ b/src/test/kotlin/org/stt/gui/jfx/TimeTrackingItemCellTest.kt @@ -4,10 +4,13 @@ import javafx.scene.text.Font import org.junit.Before import org.junit.Test import org.mockito.Mock +import org.mockito.Mockito import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.stt.gui.jfx.TimeTrackingItemCellWithActions.ActionsHandler import org.stt.model.TimeTrackingItem +import org.stt.submit.SubmitSelectionManager +import org.stt.submit.SubmitStatusTracker import java.time.LocalDateTime import java.util.* import java.util.function.Predicate @@ -27,7 +30,10 @@ class TimeTrackingItemCellTest { val resourceBundle = ResourceBundle .getBundle("org.stt.gui.Application") - sut = TimeTrackingItemCellWithActions(fontAwesome!!, resourceBundle, Predicate { false }, actionsHandler!!, { it }) + sut = TimeTrackingItemCellWithActions(fontAwesome!!, resourceBundle, Predicate { false }, actionsHandler!!, { it }, + Mockito.mock(SubmitStatusTracker::class.java), + Mockito.mock(SubmitSelectionManager::class.java), + { null }) } @Test diff --git a/src/test/kotlin/org/stt/reporting/SummingReportGeneratorTest.kt b/src/test/kotlin/org/stt/reporting/SummingReportGeneratorTest.kt index 0e8aa0a3..477a04b1 100644 --- a/src/test/kotlin/org/stt/reporting/SummingReportGeneratorTest.kt +++ b/src/test/kotlin/org/stt/reporting/SummingReportGeneratorTest.kt @@ -95,9 +95,11 @@ class SummingReportGeneratorTest { // THEN assertThat(items).contains( ReportingItem( - Duration.ofMillis((60 * 1000 + 2 * 1000).toLong()), Duration.ofDays(1), "first comment", false + Duration.ofMillis((60 * 1000 + 2 * 1000).toLong()), Duration.ofDays(1), "first comment", false, + listOf(expectedItem, expectedItem2) ), - ReportingItem(Duration.ofMillis((3 * 1000).toLong()), Duration.ofDays(1), "first comment?", false) + ReportingItem(Duration.ofMillis((3 * 1000).toLong()), Duration.ofDays(1), "first comment?", false, + listOf(expectedItem3)) ) } @@ -131,7 +133,8 @@ class SummingReportGeneratorTest { ReportingItem( Duration.ofMillis( (60 * 1000 + 2 * 1000).toLong() - ), Duration.ofDays(1), "", false + ), Duration.ofDays(1), "", false, + listOf(expectedItem, expectedItem2) ) ) } diff --git a/src/test/kotlin/org/stt/submit/SubmitSelectionManagerTest.kt b/src/test/kotlin/org/stt/submit/SubmitSelectionManagerTest.kt new file mode 100644 index 00000000..ec3cddbb --- /dev/null +++ b/src/test/kotlin/org/stt/submit/SubmitSelectionManagerTest.kt @@ -0,0 +1,159 @@ +package org.stt.submit + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.stt.model.TimeTrackingItem +import java.time.LocalDateTime + +/** + * Tests for [SubmitSelectionManager]. + * + * Verifies selection/deselection of items per connector, isolation between connectors, + * same-item recognition by content (Base64-encoded key), and clear/get operations. + * Single purpose: validate the in-memory selection state management. + */ + +class SubmitSelectionManagerTest { + + private lateinit var sut: SubmitSelectionManager + + @Before + fun setup() { + sut = SubmitSelectionManager() + } + + @Test + fun shouldReturnFalseForUnselectedItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0)) + + // WHEN + val result = sut.isSelected("connector1", item) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnTrueAfterSelectingItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item, true) + + // WHEN + val result = sut.isSelected("connector1", item) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseAfterDeselectingItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item, true) + + // WHEN + sut.setSelected("connector1", item, false) + val result = sut.isSelected("connector1", item) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldIsolateSelectionsByConnectorId() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item, true) + + // WHEN + val resultOther = sut.isSelected("connector2", item) + val resultOriginal = sut.isSelected("connector1", item) + + // THEN + assertThat(resultOther).isFalse() + assertThat(resultOriginal).isTrue() + } + + @Test + fun shouldRecognizeSameItemByContent() { + // GIVEN + val item1 = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item1, true) + + // WHEN + val result = sut.isSelected("connector1", item2) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldGetSelectedItemsReturnsKeys() { + // GIVEN + val item1 = TimeTrackingItem("a", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("b", LocalDateTime.of(2020, 1, 1, 12, 0), LocalDateTime.of(2020, 1, 1, 13, 0)) + sut.setSelected("connector1", item1, true) + sut.setSelected("connector1", item2, true) + + // WHEN + val keys = sut.getSelectedItems("connector1") + + // THEN + assertThat(keys).hasSize(2) + } + + @Test + fun shouldReturnEmptySetForUnknownConnector() { + // WHEN + val result = sut.getSelectedItems("nonexistent") + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun shouldClearAllSelectionsForConnector() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item, true) + + // WHEN + sut.clearSelection("connector1") + val result = sut.isSelected("connector1", item) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldHandleItemWithoutEnd() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0)) + + // WHEN + sut.setSelected("connector1", item, true) + val result = sut.isSelected("connector1", item) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldNotAffectOtherConnectorsOnClear() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.setSelected("connector1", item, true) + sut.setSelected("connector2", item, true) + + // WHEN + sut.clearSelection("connector1") + val result = sut.isSelected("connector2", item) + + // THEN + assertThat(result).isTrue() + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/submit/SubmitStatusTrackerTest.kt b/src/test/kotlin/org/stt/submit/SubmitStatusTrackerTest.kt new file mode 100644 index 00000000..bc66ed56 --- /dev/null +++ b/src/test/kotlin/org/stt/submit/SubmitStatusTrackerTest.kt @@ -0,0 +1,262 @@ +package org.stt.submit + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.stt.model.TimeTrackingItem +import java.io.File +import java.time.LocalDateTime + +/** + * Tests for [SubmitStatusTracker]. + * + * Two main concerns: + * 1. In-memory tracking (isSubmitted, markSubmitted, getSubmitFraction, requireNotSubmitted) + * 2. Persistence (start/stop round-trip via the submit-status file) + * + * Verifies connector-level isolation, same-item content matching, empty-list edge cases, + * and guard behavior when modifying already-submitted items. + */ + +class SubmitStatusTrackerTest { + + @field:Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var homePath: String + private lateinit var sut: SubmitStatusTracker + + @Before + fun setup() { + homePath = tempFolder.newFolder("home").absolutePath + sut = SubmitStatusTracker(homePath) + } + + @Test + fun shouldReturnFalseForUnsubmittedItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + val result = sut.isSubmitted(item) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnFalseForUnsubmittedItemWithConnectorId() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + val result = sut.isSubmitted(item, "json") + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnTrueAfterMarkSubmitted() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + sut.markSubmitted(item, "json") + val result = sut.isSubmitted(item) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnTrueForConnectorAfterMarkSubmitted() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + sut.markSubmitted(item, "json") + val result = sut.isSubmitted(item, "json") + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseForDifferentConnector() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.markSubmitted(item, "json") + + // WHEN + val result = sut.isSubmitted(item, "csv") + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldRecognizeSameItemByContent() { + // GIVEN + val item1 = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.markSubmitted(item1, "json") + + // WHEN + val result = sut.isSubmitted(item2) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldHandleItemWithoutEnd() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0)) + + // WHEN + sut.markSubmitted(item, "json") + val result = sut.isSubmitted(item) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldCalculateFullSubmitFraction() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.markSubmitted(item, "json") + + // WHEN + val fraction = sut.getSubmitFraction(listOf(item), "json") + + // THEN + assertThat(fraction).isEqualTo(1.0f) + } + + @Test + fun shouldCalculateZeroSubmitFraction() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + val fraction = sut.getSubmitFraction(listOf(item), "json") + + // THEN + assertThat(fraction).isEqualTo(0.0f) + } + + @Test + fun shouldCalculatePartialSubmitFraction() { + // GIVEN + val item1 = TimeTrackingItem("a", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("b", LocalDateTime.of(2020, 1, 1, 12, 0), LocalDateTime.of(2020, 1, 1, 13, 0)) + sut.markSubmitted(item1, "json") + + // WHEN + val fraction = sut.getSubmitFraction(listOf(item1, item2), "json") + + // THEN + assertThat(fraction).isEqualTo(0.5f) + } + + @Test + fun shouldReturnOneForEmptyList() { + // WHEN + val fraction = sut.getSubmitFraction(emptyList(), "json") + + // THEN + assertThat(fraction).isEqualTo(1.0f) + } + + @Test + fun requireNotSubmittedShouldNotThrowForFreshItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + sut.requireNotSubmitted(item) + + // THEN does not throw + } + + @Test + fun requireNotSubmittedShouldThrowForSubmittedItem() { + // GIVEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.markSubmitted(item, "json") + + // WHEN / THEN + assertThatThrownBy { sut.requireNotSubmitted(item) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("already submitted") + } + + @Test + fun shouldPersistAndReloadOnStart() { + // GIVEN + sut.start() + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + sut.markSubmitted(item, "json") + sut.stop() + + // WHEN + val reloaded = SubmitStatusTracker(homePath) + reloaded.start() + val result = reloaded.isSubmitted(item, "json") + + // THEN + assertThat(result).isTrue() + reloaded.stop() + } + + @Test + fun shouldCreateStatusDirectoryOnStart() { + // WHEN + sut.start() + + // THEN + val statusDir = File(homePath, ".stt") + assertThat(statusDir).exists().isDirectory() + sut.stop() + } + + @Test + fun shouldHandleMultipleItemsAndConnectorsInPersistence() { + // GIVEN + sut.start() + val item1 = TimeTrackingItem("a", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("b", LocalDateTime.of(2020, 1, 1, 12, 0), LocalDateTime.of(2020, 1, 1, 13, 0)) + sut.markSubmitted(item1, "json") + sut.markSubmitted(item1, "csv") + sut.markSubmitted(item2, "json") + sut.stop() + + // WHEN + val reloaded = SubmitStatusTracker(homePath) + reloaded.start() + + // THEN + assertThat(reloaded.isSubmitted(item1, "json")).isTrue() + assertThat(reloaded.isSubmitted(item1, "csv")).isTrue() + assertThat(reloaded.isSubmitted(item2, "json")).isTrue() + assertThat(reloaded.isSubmitted(item2, "csv")).isFalse() + reloaded.stop() + } + + @Test + fun shouldStartCleanWhenNoStatusFileExists() { + // WHEN + sut.start() + + // THEN + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + assertThat(sut.isSubmitted(item)).isFalse() + sut.stop() + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/submit/json/JsonSubmitConnectorTest.kt b/src/test/kotlin/org/stt/submit/json/JsonSubmitConnectorTest.kt new file mode 100644 index 00000000..533d8525 --- /dev/null +++ b/src/test/kotlin/org/stt/submit/json/JsonSubmitConnectorTest.kt @@ -0,0 +1,192 @@ +package org.stt.submit.json + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.stt.gui.jfx.ReportController +import org.stt.model.TimeTrackingItem +import org.stt.reporting.SummingReportGenerator +import org.stt.submit.ConnectorConfig +import java.io.File +import java.nio.file.Files +import java.time.Duration +import java.time.LocalDateTime + +/** + * Tests for [JsonSubmitConnector]. + * + * Two concerns: + * 1. [JsonSubmitConnector.submitItems] — serializes [TimeTrackingItem]s to a JSON file + * 2. [JsonSubmitConnector.submitSummary] — serializes report list items to a JSON file + * + * Verifies correct JSON structure, field values, null handling, absolute vs relative paths, + * file overwrite behaviour, and the connector id property. + */ + +class JsonSubmitConnectorTest { + + @field:Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var homePath: String + private lateinit var sut: JsonSubmitConnector + + @Before + fun setup() { + homePath = tempFolder.newFolder("home").absolutePath + val config = ConnectorConfig(type = "json", file = ".stt/submit.json") + sut = JsonSubmitConnector(config, homePath) + } + + @Test + fun shouldCreateJsonFileOnSubmitItems() { + // GIVEN + val item = TimeTrackingItem("test activity", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + sut.submitItems(listOf(item)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + assertThat(outputFile).exists() + } + + @Test + fun shouldWriteCorrectJsonContentForItems() { + // GIVEN + val item = TimeTrackingItem("test activity", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + sut.submitItems(listOf(item)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"activity\": \"test activity\"") + assertThat(content).contains("\"start\": \"2020-01-01T10:00:00\"") + assertThat(content).contains("\"end\": \"2020-01-01T11:00:00\"") + assertThat(content).contains("\"submittedAt\"") + } + + @Test + fun shouldWriteMultipleItemsAsArray() { + // GIVEN + val item1 = TimeTrackingItem("a", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("b", LocalDateTime.of(2020, 1, 1, 12, 0), LocalDateTime.of(2020, 1, 1, 13, 0)) + + // WHEN + sut.submitItems(listOf(item1, item2)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).startsWith("[") + assertThat(content).endsWith("]") + assertThat(content).contains("\"activity\": \"a\"") + assertThat(content).contains("\"activity\": \"b\"") + } + + @Test + fun shouldHandleItemWithoutEnd() { + // GIVEN + val item = TimeTrackingItem("ongoing", LocalDateTime.of(2020, 1, 1, 10, 0)) + + // WHEN + sut.submitItems(listOf(item)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"end\": null") + } + + @Test + fun shouldCreateJsonFileOnSubmitSummary() { + // GIVEN + val report = SummingReportGenerator.Report(emptyList(), null, null, Duration.ZERO, Duration.ZERO) + val reportItem = ReportController.ReportListItem("summary item", false, Duration.ofHours(2), Duration.ofHours(2)) + + // WHEN + sut.submitSummary(report, listOf(reportItem)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + assertThat(outputFile).exists() + } + + @Test + fun shouldWriteCorrectJsonForSummary() { + // GIVEN + val report = SummingReportGenerator.Report(emptyList(), null, null, Duration.ZERO, Duration.ZERO) + val reportItem = ReportController.ReportListItem("summary item", false, Duration.ofHours(2), Duration.ofMinutes(119)) + + // WHEN + sut.submitSummary(report, listOf(reportItem)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"comment\": \"summary item\"") + assertThat(content).contains("\"isBreak\": false") + assertThat(content).contains("\"duration\": \"PT2H\"") + assertThat(content).contains("\"roundedDuration\": \"PT1H59M\"") + } + + @Test + fun shouldWriteBreakItemInSummary() { + // GIVEN + val report = SummingReportGenerator.Report(emptyList(), null, null, Duration.ZERO, Duration.ZERO) + val reportItem = ReportController.ReportListItem("break comment", true, Duration.ofMinutes(30), Duration.ofMinutes(30)) + + // WHEN + sut.submitSummary(report, listOf(reportItem)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"isBreak\": true") + } + + @Test + fun shouldUseAbsolutePathWhenFileStartsWithSlash() { + // GIVEN + val absolutePath = File(tempFolder.newFolder("custom"), "output.json").absolutePath + val config = ConnectorConfig(type = "json", file = absolutePath) + val connector = JsonSubmitConnector(config, homePath) + val item = TimeTrackingItem("test", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + + // WHEN + connector.submitItems(listOf(item)) + + // THEN + val outputFile = File(absolutePath) + assertThat(outputFile).exists() + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"activity\": \"test\"") + } + + @Test + fun shouldOverwriteExistingFileOnEachSubmit() { + // GIVEN + val item1 = TimeTrackingItem("first", LocalDateTime.of(2020, 1, 1, 10, 0), LocalDateTime.of(2020, 1, 1, 11, 0)) + val item2 = TimeTrackingItem("second", LocalDateTime.of(2020, 1, 1, 12, 0), LocalDateTime.of(2020, 1, 1, 13, 0)) + sut.submitItems(listOf(item1)) + + // WHEN + sut.submitItems(listOf(item2)) + + // THEN + val outputFile = File(homePath, ".stt/submit.json") + val content = String(Files.readAllBytes(outputFile.toPath())) + assertThat(content).contains("\"activity\": \"second\"") + assertThat(content).doesNotContain("\"activity\": \"first\"") + } + + @Test + fun shouldHaveCorrectId() { + assertThat(sut.id).isEqualTo("json") + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/time/DateTimesTest.kt b/src/test/kotlin/org/stt/time/DateTimesTest.kt index 4b08b07f..02886bf1 100644 --- a/src/test/kotlin/org/stt/time/DateTimesTest.kt +++ b/src/test/kotlin/org/stt/time/DateTimesTest.kt @@ -2,6 +2,8 @@ package org.stt.time import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import java.time.Duration +import java.time.LocalDate import java.time.LocalDateTime class DateTimesTest { @@ -18,4 +20,253 @@ class DateTimesTest { assertThat(result).isTrue() } + @Test + fun shouldReturnFalseForDifferentDates() { + // GIVEN + val a = LocalDateTime.of(2024, 1, 1, 10, 0) + val b = LocalDateTime.of(2024, 1, 2, 10, 0) + + // WHEN + val result = DateTimes.isOnSameDay(a, b) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnFalseWhenFirstIsNull() { + // GIVEN + val b = LocalDateTime.now() + + // WHEN + val result = DateTimes.isOnSameDay(null, b) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnFalseWhenSecondIsNull() { + // GIVEN + val a = LocalDateTime.now() + + // WHEN + val result = DateTimes.isOnSameDay(a, null) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnTrueForToday() { + // GIVEN + val now = LocalDateTime.now() + + // WHEN + val result = DateTimes.isToday(now) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseForNonToday() { + // GIVEN + val yesterday = LocalDateTime.now().minusDays(1) + + // WHEN + val result = DateTimes.isToday(yesterday) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnTrueForTodayDate() { + // GIVEN + val today = LocalDate.now() + + // WHEN + val result = DateTimes.isToday(today) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseForNonTodayDate() { + // GIVEN + val yesterday = LocalDate.now().minusDays(1) + + // WHEN + val result = DateTimes.isToday(yesterday) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnTrueWhenSourceIsBetweenFromAndTo() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 31) + val source = LocalDate.of(2024, 1, 15) + + // WHEN + val result = DateTimes.isBetween(source, from, to) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnTrueWhenSourceEqualsFrom() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 31) + val source = LocalDate.of(2024, 1, 1) + + // WHEN + val result = DateTimes.isBetween(source, from, to) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnTrueWhenSourceEqualsTo() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 31) + val source = LocalDate.of(2024, 1, 31) + + // WHEN + val result = DateTimes.isBetween(source, from, to) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseWhenSourceIsBeforeFrom() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 31) + val source = LocalDate.of(2023, 12, 31) + + // WHEN + val result = DateTimes.isBetween(source, from, to) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun shouldReturnFalseWhenSourceIsAfterTo() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 31) + val source = LocalDate.of(2024, 2, 1) + + // WHEN + val result = DateTimes.isBetween(source, from, to) + + // THEN + assertThat(result).isFalse() + } + + @Test + fun prettyPrintTimeShouldUseTimeOnlyForToday() { + // GIVEN + val now = LocalDateTime.now() + + // WHEN + val result = DateTimes.prettyPrintTime(now) + + // THEN + assertThat(result).matches("\\d{2}:\\d{2}:\\d{2}") + } + + @Test + fun prettyPrintTimeShouldUseFullFormatForNonToday() { + // GIVEN + val date = LocalDateTime.of(2023, 6, 15, 10, 30, 0) + + // WHEN + val result = DateTimes.prettyPrintTime(date) + + // THEN + assertThat(result).isEqualTo("2023.06.15 10:30:00") + } + + @Test + fun prettyPrintTimeShouldReturnBeginningOfTimeForMinDate() { + // GIVEN + val date = LocalDateTime.of(LocalDate.MIN, java.time.LocalTime.MIN) + + // WHEN + val result = DateTimes.prettyPrintTime(date) + + // THEN + assertThat(result).isEqualTo("beginning of time") + } + + @Test + fun prettyPrintDateShouldReturnBeginningOfTimeForMinDate() { + // GIVEN + + // WHEN + val result = DateTimes.prettyPrintDate(LocalDate.MIN) + + // THEN + assertThat(result).isEqualTo("beginning of time") + } + + @Test + fun prettyPrintDateShouldReturnFormattedDate() { + // GIVEN + val date = LocalDate.of(2024, 3, 15) + + // WHEN + val result = DateTimes.prettyPrintDate(date) + + // THEN + assertThat(result).isEqualTo("2024-03-15") + } + + @Test + fun prettyPrintDurationShouldFormatPositiveDuration() { + // GIVEN + val duration = Duration.ofHours(2).plusMinutes(30).plusSeconds(15) + + // WHEN + val result = DateTimes.prettyPrintDuration(duration) + + // THEN + assertThat(result).isEqualTo(" 2:30:15") + } + + @Test + fun prettyPrintDurationShouldFormatZeroDuration() { + // GIVEN + val duration = Duration.ZERO + + // WHEN + val result = DateTimes.prettyPrintDuration(duration) + + // THEN + assertThat(result).isEqualTo(" 0:00:00") + } + + @Test + fun prettyPrintDurationShouldFormatNegativeDuration() { + // GIVEN + val duration = Duration.ofMinutes(-45) + + // WHEN + val result = DateTimes.prettyPrintDuration(duration) + + // THEN + assertThat(result).isEqualTo("-0:45:00") + } } \ No newline at end of file diff --git a/src/test/kotlin/org/stt/time/IntervalTest.kt b/src/test/kotlin/org/stt/time/IntervalTest.kt new file mode 100644 index 00000000..313f552f --- /dev/null +++ b/src/test/kotlin/org/stt/time/IntervalTest.kt @@ -0,0 +1,114 @@ +package org.stt.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +class IntervalTest { + @Test + fun shouldStoreStartAndEnd() { + // GIVEN + val start = LocalDateTime.of(2024, 1, 1, 8, 0) + val end = LocalDateTime.of(2024, 1, 1, 17, 0) + + // WHEN + val sut = Interval(start, end) + + // THEN + assertThat(sut.start).isEqualTo(start) + assertThat(sut.end).isEqualTo(end) + } + + @Test + fun withEndShouldReturnNewIntervalWithUpdatedEnd() { + // GIVEN + val sut = Interval( + LocalDateTime.of(2024, 1, 1, 8, 0), + LocalDateTime.of(2024, 1, 1, 17, 0) + ) + val newEnd = LocalDateTime.of(2024, 1, 1, 18, 0) + + // WHEN + val result = sut.withEnd(newEnd) + + // THEN + assertThat(result.start).isEqualTo(sut.start) + assertThat(result.end).isEqualTo(newEnd) + } + + @Test + fun withEndShouldNotMutateOriginalInterval() { + // GIVEN + val start = LocalDateTime.of(2024, 1, 1, 8, 0) + val end = LocalDateTime.of(2024, 1, 1, 17, 0) + val sut = Interval(start, end) + + // WHEN + sut.withEnd(LocalDateTime.of(2024, 1, 1, 18, 0)) + + // THEN + assertThat(sut.end).isEqualTo(end) + } +} + +class LocalDateAsIntervalTest { + @Test + fun asIntervalShouldCreateIntervalFromStartOfDayToStartOfNextDay() { + // GIVEN + val date = LocalDate.of(2024, 6, 15) + + // WHEN + val result = date.asInterval() + + // THEN + assertThat(result.start).isEqualTo(LocalDateTime.of(2024, 6, 15, 0, 0)) + assertThat(result.end).isEqualTo(LocalDateTime.of(2024, 6, 16, 0, 0)) + } +} + +class LocalDateUntilExtensionTest { + @Test + fun untilShouldCreateIntervalFromStartOfDayToDayAfterEnd() { + // GIVEN + val from = LocalDate.of(2024, 1, 1) + val to = LocalDate.of(2024, 1, 5) + + // WHEN + val result = from until to + + // THEN + assertThat(result.start).isEqualTo(LocalDateTime.of(2024, 1, 1, 0, 0)) + assertThat(result.end).isEqualTo(LocalDateTime.of(2024, 1, 6, 0, 0)) + } + + @Test + fun untilShouldCreateSingleDayIntervalWhenFromEqualsTo() { + // GIVEN + val from = LocalDate.of(2024, 6, 15) + val to = LocalDate.of(2024, 6, 15) + + // WHEN + val result = from until to + + // THEN + assertThat(result.start).isEqualTo(LocalDateTime.of(2024, 6, 15, 0, 0)) + assertThat(result.end).isEqualTo(LocalDateTime.of(2024, 6, 16, 0, 0)) + } +} + +class LocalDateTimeUntilExtensionTest { + @Test + fun untilShouldCreateIntervalBetweenTwoDateTimes() { + // GIVEN + val from = LocalDateTime.of(2024, 3, 10, 8, 30, 0) + val to = LocalDateTime.of(2024, 3, 10, 17, 45, 0) + + // WHEN + val result = from until to + + // THEN + assertThat(result.start).isEqualTo(from) + assertThat(result.end).isEqualTo(to) + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/stt/validation/ItemAndDateValidatorTest.kt b/src/test/kotlin/org/stt/validation/ItemAndDateValidatorTest.kt new file mode 100644 index 00000000..b0f48f95 --- /dev/null +++ b/src/test/kotlin/org/stt/validation/ItemAndDateValidatorTest.kt @@ -0,0 +1,77 @@ +package org.stt.validation + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.BDDMockito.given +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.stt.query.TimeTrackingItemQueries +import java.time.LocalDateTime +import java.util.stream.Stream + +class ItemAndDateValidatorTest { + @Mock + private lateinit var timeTrackingItemQueries: TimeTrackingItemQueries + + private lateinit var sut: ItemAndDateValidator + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + sut = ItemAndDateValidator(timeTrackingItemQueries) + } + + @Test + fun shouldReturnTrueForNonTodayDate() { + // GIVEN + val yesterday = LocalDateTime.now().minusDays(1) + + // WHEN + val result = sut.validateItemIsFirstItemAndLater(yesterday) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnTrueWhenEarlierItemExistsOnSameDay() { + // GIVEN + val now = LocalDateTime.now() + given(timeTrackingItemQueries.queryItems(any())).willReturn(Stream.of( + org.stt.model.TimeTrackingItem("test", now.minusHours(1)))) + + // WHEN + val result = sut.validateItemIsFirstItemAndLater(now) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnTrueWhenTimeIsNotInTheFuture() { + // GIVEN + val now = LocalDateTime.now() + given(timeTrackingItemQueries.queryItems(any())).willReturn(Stream.empty()) + + // WHEN + val result = sut.validateItemIsFirstItemAndLater(now) + + // THEN + assertThat(result).isTrue() + } + + @Test + fun shouldReturnFalseWhenTimeIsInFutureAndNoEarlierItem() { + // GIVEN + val future = LocalDateTime.now().plusHours(1) + given(timeTrackingItemQueries.queryItems(any())).willReturn(Stream.empty()) + + // WHEN + val result = sut.validateItemIsFirstItemAndLater(future) + + // THEN + assertThat(result).isFalse() + } +} \ No newline at end of file From de9f72a8d749924cdba04d3a5369bcae9a9d958d Mon Sep 17 00:00:00 2001 From: Robert Kleinschmager Date: Sat, 1 Aug 2026 20:22:52 +0200 Subject: [PATCH 2/7] build: added sdkman file --- .sdkmanrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .sdkmanrc diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 00000000..34ce9dc2 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=21.0.11-tem \ No newline at end of file From 100982b23f9f3f21b45cb2d88ae3f659457b907c Mon Sep 17 00:00:00 2001 From: Robert Kleinschmager Date: Sun, 30 Aug 2026 20:56:42 +0200 Subject: [PATCH 3/7] chore: added openspec --- .opencode/commands/opsx-apply.md | 181 ++++++++++ .opencode/commands/opsx-archive.md | 222 +++++++++++++ .opencode/commands/opsx-explore.md | 193 +++++++++++ .opencode/commands/opsx-propose.md | 142 ++++++++ .opencode/commands/opsx-sync.md | 255 ++++++++++++++ .opencode/commands/opsx-update.md | 84 +++++ .../skills/openspec-apply-change/SKILL.md | 188 +++++++++++ .../skills/openspec-archive-change/SKILL.md | 182 ++++++++++ .opencode/skills/openspec-explore/SKILL.md | 311 ++++++++++++++++++ .opencode/skills/openspec-propose/SKILL.md | 149 +++++++++ .opencode/skills/openspec-sync-specs/SKILL.md | 262 +++++++++++++++ .../skills/openspec-update-change/SKILL.md | 91 +++++ AGENTS.md | 57 +++- openspec/config.yaml | 32 ++ 14 files changed, 2348 insertions(+), 1 deletion(-) create mode 100644 .opencode/commands/opsx-apply.md create mode 100644 .opencode/commands/opsx-archive.md create mode 100644 .opencode/commands/opsx-explore.md create mode 100644 .opencode/commands/opsx-propose.md create mode 100644 .opencode/commands/opsx-sync.md create mode 100644 .opencode/commands/opsx-update.md create mode 100644 .opencode/skills/openspec-apply-change/SKILL.md create mode 100644 .opencode/skills/openspec-archive-change/SKILL.md create mode 100644 .opencode/skills/openspec-explore/SKILL.md create mode 100644 .opencode/skills/openspec-propose/SKILL.md create mode 100644 .opencode/skills/openspec-sync-specs/SKILL.md create mode 100644 .opencode/skills/openspec-update-change/SKILL.md create mode 100644 openspec/config.yaml diff --git a/.opencode/commands/opsx-apply.md b/.opencode/commands/opsx-apply.md new file mode 100644 index 00000000..39ea6432 --- /dev/null +++ b/.opencode/commands/opsx-apply.md @@ -0,0 +1,181 @@ +--- +description: "Implement tasks from an OpenSpec change (Experimental)" +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Provided arguments**: $ARGUMENTS + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and ask the user to select one + + Always announce: "Using change: " and how to override (e.g., `/opsx-apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + - Optional `context`: current required project instruction input from the selected root + - Optional `operationGuidance`: current advisory guidance for apply + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + + Treat `context` as a required prompt-level input. Read and consider it, and + apply relevant project facts, conventions, and constraints while implementing. + Treat `operationGuidance` as optional additive advice. Read and consider every + entry, and follow entries that are applicable and compatible with the built-in + workflow. + + Keep both fields separate from CLI-returned state, missing artifacts, tasks, + progress, `contextFiles`, and the built-in `instruction`. They are not + evidence of task completion, do not replace the built-in instruction, and do + not permit bypassing a blocked state. If context conflicts with the built-in + instruction, an explicit user choice, or a CLI-controlled value, report the + conflict and preserve the controlling value. If guidance is inapplicable or + conflicts with those controlling inputs, do not follow it and explain why. + These are prompt-level behavior contracts, not enforceable checks. + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + + Do not copy `context` or `operationGuidance` verbatim into implementation + files or planning artifacts unless the user separately asks for that content. + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - A task needs work beyond what the spec and tasks describe, or you are tempted to drop, narrow, defer, or accept exceptions to specified behavior to make it fit → surface the added scope and ask; do not absorb it silently + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx-archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.