diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index 5cda54f..4ca9d7e 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -35,9 +35,9 @@ passed. The current suite has 143 passing tests. | Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage | Application-facing orchestration and final backend acceptance scenarios | 14, 17 | | Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 | | Free slots | Task type and timeline token exist | Creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests | 12, 13, 14 | -| Surprise work | Completed surprise logging and immediate flexible-task repair | Persist actual occupancy, prevent later overlap/regression, idempotent replays, completion/locked-hour statistics | 12, 13, 14 | -| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals | Atomic application use cases and exactly-once stat persistence | 13, 14 | -| Internal task statistics | Baseline counters and increment helpers | Automatic exactly-once updates, late/locked completion calculation, push-before-completion aggregates, child patterns | 13 | +| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 | +| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 | +| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 | | Child tasks | Entry conversion, ownership views, parent completion propagation | Atomic break-up workflow, cycle/direct-child validation, activity/stat updates, application use case | 13, 14 | | Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 | | Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; platform delivery remains outside the pure core | 13 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md index f2ce83b..c57cd08 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md @@ -64,6 +64,8 @@ accounting and locked-hour statistics. Recommended Codex level: extra high +Status: Complete on 2026-06-25. + Tasks: - Update manual-push, auto-push, moved-to-backlog, restored-from-backlog, missed, @@ -102,6 +104,13 @@ Acceptance criteria: - Surprise completions participate in the same accounting policy. - The task-statistics document contract can represent all resulting values. +Verification on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed +- `dart analyze`: passed, no issues found +- `dart test`: passed, 218 tests +- `git diff --check`: passed + BREAKPOINT: Stop here. Confirm `high` mode before implementing child-task orchestration. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 4f97340..15820ec 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -65,7 +65,7 @@ changes from accidental API drift. | `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` | | `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` | -| `src/task_lifecycle.dart` | `TaskActivity`, `TaskTransitionResult`, `TaskTransitionService` | +| `src/task_lifecycle.dart` | `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `TaskTransitionResult`, `TaskTransitionService` | | `src/task_statistics.dart` | `TaskStatistics` | | `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` | | `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` | @@ -100,8 +100,8 @@ Current notable model fields: - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ difficulty/reminder profile/duration fields - `TimeInterval`: `start`, `end`, optional `label` -- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour and - parent-child counters +- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour, + completed-after-shield, push-before-completion, and parent-child counters Known baseline gaps to preserve as explicit later work: @@ -194,6 +194,21 @@ Chunk 13.1 intentionally changed the public API: - Routed flexible/required direct action services through the canonical transition service while preserving existing scheduling helper compatibility. +Chunk 13.2 intentionally changed the public API: + +- Added `TaskActivityApplicationResult` and `TaskActivityAccountingService`. +- Added `TaskStatistics.completedAfterShieldCount`, + `TaskStatistics.completedAfterPushCount`, and + `TaskStatistics.totalPushesBeforeCompletion`. +- Added `TaskStatistics.recordPushesBeforeCompletion`. +- Added matching `TaskStatisticsDocumentFields` constants and document mapping. +- Added `appliedActivityIds` and `lockedIntervals` parameters to + `TaskTransitionService.apply`. +- Added actual/locked interval parameters to flexible and required action + completion paths. +- Added `SurpriseTaskLogResult.activities` and routed surprise completion + through the same accounting boundary. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index 34bc03e..bd5b4b2 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -35,6 +35,13 @@ Latest Block 13.1 verification result on 2026-06-25: - `dart test`: passed, 211 tests - `git diff --check`: passed +Latest Block 13.2 verification result on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed +- `dart analyze`: passed, no issues found +- `dart test`: passed, 218 tests +- `git diff --check`: passed + Status values: - `complete`: Current backend APIs and tests cover the requirement. @@ -58,11 +65,11 @@ Status values: | MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 adds internal activity records; exactly-once statistics/accounting continues in Chunk 13.2. | | MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. | | MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. | -| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunk 13.1 adds completion timestamps; statistics/application use cases remain in Blocks 13 and 14. | +| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. | | MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case remains in Block 14. | | MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. | | MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. | -| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunk 13.1 adds canonical activities; exactly-once counter accounting remains in Chunk 13.2. | +| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunks 13.1-13.2 add canonical activities plus exactly-once task-stat accounting for movement, late, locked-hour, and push-before-completion values; project aggregates and atomic persistence remain in Blocks 13.4 and 14. | ## Additional MVP backend requirements @@ -76,7 +83,7 @@ Status values: | MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | -| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunk 13.1 adds canonical transition/activity records; exactly-once statistic accounting remains in Chunk 13.2. | +| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunks 13.1-13.2 add canonical transition/activity records and exactly-once task-stat accounting; atomic persistence and project aggregation remain in Blocks 14 and 13.4. | | MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. | ## Intentionally deferred human-spec items diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart index 76ff9e8..682bc62 100644 --- a/lib/src/document_mapping.dart +++ b/lib/src/document_mapping.dart @@ -187,6 +187,12 @@ abstract final class TaskStatisticsDocumentMapping { stats.completedDuringLockedHoursCount, TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: stats.completedDuringLockedHoursMinutes, + TaskStatisticsDocumentFields.completedAfterShieldCount: + stats.completedAfterShieldCount, + TaskStatisticsDocumentFields.completedAfterPushCount: + stats.completedAfterPushCount, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion: + stats.totalPushesBeforeCompletion, }; } @@ -233,6 +239,18 @@ abstract final class TaskStatisticsDocumentMapping { document, TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, ), + completedAfterShieldCount: _intOrZero( + document, + TaskStatisticsDocumentFields.completedAfterShieldCount, + ), + completedAfterPushCount: _intOrZero( + document, + TaskStatisticsDocumentFields.completedAfterPushCount, + ), + totalPushesBeforeCompletion: _intOrZero( + document, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion, + ), ); } } diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index 1858999..d007848 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -156,6 +156,9 @@ abstract final class TaskStatisticsDocumentFields { 'completedDuringLockedHoursCount'; static const completedDuringLockedHoursMinutes = 'completedDuringLockedHoursMinutes'; + static const completedAfterShieldCount = 'completedAfterShieldCount'; + static const completedAfterPushCount = 'completedAfterPushCount'; + static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion'; static const all = { skippedDuringBurnoutCount, @@ -168,6 +171,9 @@ abstract final class TaskStatisticsDocumentFields { completedLateCount, completedDuringLockedHoursCount, completedDuringLockedHoursMinutes, + completedAfterShieldCount, + completedAfterPushCount, + totalPushesBeforeCompletion, }; } diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index c24a33c..0443a7f 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -19,6 +19,8 @@ import 'time_contracts.dart'; const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); const TaskTransitionService _transitionService = TaskTransitionService(); +const TaskActivityAccountingService _activityAccountingService = + TaskActivityAccountingService(); /// Category for scheduler notices. /// @@ -1349,14 +1351,25 @@ SchedulingResult _applyPlacement({ continue; } - final updatedTask = task.copyWith( + final placedTask = task.copyWith( status: isInsertedTask ? TaskStatus.planned : task.status, scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, - stats: isInsertedTask - ? task.stats.incrementRestoredFromBacklog() - : task.stats.incrementAutoPush(), + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + activityCode: isInsertedTask + ? TaskActivityCode.restoredFromBacklog + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, ); updatedTasks.add(updatedTask); @@ -1434,13 +1447,23 @@ SchedulingResult _applyPushPlacement({ } final isPushedTask = task.id == pushedTask.id; - final updatedTask = task.copyWith( + final placedTask = task.copyWith( scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, - stats: isPushedTask && countPushedTaskAsManual - ? task.stats.incrementManualPush() - : task.stats.incrementAutoPush(), + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: operationCode, + activityCode: isPushedTask && countPushedTaskAsManual + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, ); updatedTasks.add(updatedTask); @@ -1511,12 +1534,22 @@ SchedulingResult _applyRolloverPlacement({ } final isRolledTask = rolledTaskIds.contains(task.id); - final updatedTask = task.copyWith( + final placedTask = task.copyWith( status: isRolledTask ? TaskStatus.planned : task.status, scheduledStart: interval.start, scheduledEnd: interval.end, updatedAt: updatedAt, - stats: task.stats.incrementAutoPush(), + ); + final updatedTask = _applySchedulingActivity( + originalTask: task, + updatedTask: placedTask, + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + activityCode: TaskActivityCode.automaticallyPushed, + occurredAt: updatedAt, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, ); updatedTasks.add(updatedTask); @@ -1611,6 +1644,40 @@ bool _sameDateTime(DateTime? first, DateTime? second) { return first.isAtSameMomentAs(second); } +Task _applySchedulingActivity({ + required Task originalTask, + required Task updatedTask, + required SchedulingOperationCode operationCode, + required TaskActivityCode activityCode, + required DateTime occurredAt, + required DateTime? previousStart, + required DateTime? previousEnd, + required DateTime? nextStart, + required DateTime? nextEnd, +}) { + final operationId = + '${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}'; + final activity = TaskActivity( + id: '$operationId:${activityCode.name}', + operationId: operationId, + code: activityCode, + taskId: originalTask.id, + projectId: originalTask.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': originalTask.status.name, + 'nextStatus': updatedTask.status.name, + 'previousStart': previousStart, + 'previousEnd': previousEnd, + 'nextStart': nextStart, + 'nextEnd': nextEnd, + }, + ); + + return _activityAccountingService + .apply(task: updatedTask, activities: [activity]).task; +} + /// Find the first candidate interval at or after [earliestStart]. /// /// The scan assumes [blocked] is sorted by start time. When a candidate overlaps diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 188b22c..e4c97e4 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -213,6 +213,7 @@ class SurpriseTaskLogResult { const SurpriseTaskLogResult({ required this.surpriseTask, required this.schedulingResult, + this.activities = const [], this.requiredOverlaps = const [], this.lockedOverlaps = const [], this.usedNormalPushRules = false, @@ -224,6 +225,9 @@ class SurpriseTaskLogResult { /// Updated task list, movement notices, changes, and visible overlaps. final SchedulingResult schedulingResult; + /// Internal activities produced by logging the surprise task. + final List activities; + /// Critical/inflexible overlaps with the surprise interval. final List requiredOverlaps; @@ -266,6 +270,9 @@ class FlexibleTaskActionService { required FlexibleTaskQuickAction action, DateTime? updatedAt, String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], Iterable existingActivities = const [], }) { if (!task.isFlexible) { @@ -288,6 +295,9 @@ class FlexibleTaskActionService { TaskActivityCode.completed, ), occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, existingActivities: existingActivities, ); return FlexibleTaskActionResult( @@ -352,6 +362,24 @@ class FlexibleTaskActionService { final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId('push-${destination.name}', taskId, now); + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: opId, + taskId: taskId, + )) { + return PushDestinationResult( + destination: destination, + schedulingResult: SchedulingResult( + tasks: input.tasks, + operationCode: _operationCodeForPushDestination(destination), + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice('Push operation already applied.'), + ], + ), + ); + } + final result = switch (destination) { PushDestination.nextAvailableSlot => schedulingEngine.pushFlexibleTaskToNextAvailableSlot( @@ -500,6 +528,9 @@ class RequiredTaskActionService { required RequiredTaskAction action, DateTime? updatedAt, String? operationId, + DateTime? actualStart, + DateTime? actualEnd, + List lockedIntervals = const [], Iterable existingActivities = const [], }) { if (!task.isRequiredVisible) { @@ -532,6 +563,9 @@ class RequiredTaskActionService { operationId: opId, activityId: _activityId(opId, task.id, activityCode), occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: lockedIntervals, existingActivities: existingActivities, ); @@ -556,6 +590,7 @@ class RequiredTaskActionService { class SurpriseTaskLogService { const SurpriseTaskLogService({ this.schedulingEngine = const SchedulingEngine(), + this.accountingService = const TaskActivityAccountingService(), this.clock = const SystemClock(), this.idGenerator, }); @@ -563,6 +598,9 @@ class SurpriseTaskLogService { /// Scheduling dependency used to move overlapping flexible tasks. final SchedulingEngine schedulingEngine; + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + /// Clock boundary used when a log timestamp is not supplied. final Clock clock; @@ -604,7 +642,18 @@ class SurpriseTaskLogService { ); } - final surpriseTask = _createSurpriseTask(request, updatedAt: now); + final rawSurpriseTask = _createSurpriseTask(request, updatedAt: now); + final completedActivity = _surpriseCompletedActivity( + task: rawSurpriseTask, + operationId: request.id, + occurredAt: now, + ); + final accountedSurprise = accountingService.apply( + task: rawSurpriseTask, + activities: [completedActivity], + lockedIntervals: _lockedIntervalsForAccounting(input), + ); + final surpriseTask = accountedSurprise.task; final surpriseInterval = _occupyingIntervalForTask(surpriseTask); var currentTasks = [surpriseTask, ...input.tasks]; @@ -619,6 +668,7 @@ class SurpriseTaskLogService { SchedulingNotice('Surprise task logged.'), ], ), + activities: [completedActivity], ); } @@ -701,6 +751,7 @@ class SurpriseTaskLogService { ), requiredOverlaps: List.unmodifiable(requiredOverlaps), lockedOverlaps: List.unmodifiable(lockedOverlaps), + activities: [completedActivity], usedNormalPushRules: usedNormalPushRules, ); } @@ -851,6 +902,52 @@ TaskActivityCode _activityCodeForPushChange({ : TaskActivityCode.automaticallyPushed; } +SchedulingOperationCode _operationCodeForPushDestination( + PushDestination destination, +) { + return switch (destination) { + PushDestination.nextAvailableSlot => + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + PushDestination.tomorrowTopOfQueue => + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + PushDestination.backlog => + SchedulingOperationCode.moveFlexibleTaskToBacklog, + }; +} + +TaskActivity _surpriseCompletedActivity({ + required Task task, + required String operationId, + required DateTime occurredAt, +}) { + return TaskActivity( + id: _activityId(operationId, task.id, TaskActivityCode.completed), + operationId: operationId, + code: TaskActivityCode.completed, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'nextStatus': TaskStatus.completed.name, + 'completedAt': occurredAt, + 'actualStart': task.actualStart, + 'actualEnd': task.actualEnd, + 'pushesBeforeCompletion': 0, + 'source': 'surpriseLog', + }, + ); +} + +List _lockedIntervalsForAccounting(SchedulingInput input) { + return input.occupancyEntries + .where( + (entry) => entry.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false); +} + bool _hasDuplicateActivity({ required Iterable existingActivities, required String operationId, diff --git a/lib/src/task_lifecycle.dart b/lib/src/task_lifecycle.dart index 1a01b40..acb0046 100644 --- a/lib/src/task_lifecycle.dart +++ b/lib/src/task_lifecycle.dart @@ -113,15 +113,75 @@ class TaskTransitionResult { bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; } +/// Result of applying activity-derived task statistics. +class TaskActivityApplicationResult { + TaskActivityApplicationResult({ + required this.task, + List appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Task after all newly applied activity effects. + final Task task; + + /// Activity ids that changed statistics in this application pass. + final List appliedActivityIds; +} + +/// Applies task-statistic effects from internal activities exactly once. +class TaskActivityAccountingService { + const TaskActivityAccountingService(); + + /// Apply [activities] to [task], skipping ids already present in + /// [alreadyAppliedActivityIds]. + /// + /// Completion accounting uses explicit completion timestamps and actual + /// intervals. If a completion has a timestamp but no actual interval, locked + /// minutes are left at zero rather than inferred from the planned slot. + TaskActivityApplicationResult apply({ + required Task task, + required Iterable activities, + Iterable alreadyAppliedActivityIds = const [], + List lockedIntervals = const [], + }) { + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + var current = task; + + for (final activity in activities) { + if (activity.taskId != task.id || appliedIds.contains(activity.id)) { + continue; + } + + final nextStats = _applyActivityStats( + task: current, + activity: activity, + lockedIntervals: lockedIntervals, + ); + current = current.copyWith(stats: nextStats); + appliedIds.add(activity.id); + newlyAppliedIds.add(activity.id); + } + + return TaskActivityApplicationResult( + task: current, + appliedActivityIds: newlyAppliedIds, + ); + } +} + /// Canonical lifecycle transition service. class TaskTransitionService { const TaskTransitionService({ this.clock = const SystemClock(), + this.accountingService = const TaskActivityAccountingService(), }); /// Clock boundary used when no explicit occurrence time is supplied. final Clock clock; + /// Activity-derived statistics updater. + final TaskActivityAccountingService accountingService; + /// Apply one transition to [task]. /// /// [operationId] is the idempotency key. If an existing activity for the same @@ -136,6 +196,8 @@ class TaskTransitionService { DateTime? actualStart, DateTime? actualEnd, Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + List lockedIntervals = const [], Map metadata = const {}, }) { final now = occurredAt ?? clock.now(); @@ -185,6 +247,8 @@ class TaskTransitionService { occurredAt: now, actualStart: actualStart, actualEnd: actualEnd, + appliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, metadata: metadata, ), TaskTransitionCode.miss => _miss( @@ -192,6 +256,7 @@ class TaskTransitionService { operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), TaskTransitionCode.cancel => _cancel( @@ -199,6 +264,7 @@ class TaskTransitionService { operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), TaskTransitionCode.noLongerRelevant => _noLongerRelevant( @@ -206,6 +272,7 @@ class TaskTransitionService { operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), TaskTransitionCode.manualPush => _movementOnly( @@ -216,7 +283,7 @@ class TaskTransitionService { activityId: activityId, occurredAt: now, metadata: metadata, - updatedStats: task.stats.incrementManualPush(), + appliedActivityIds: appliedActivityIds, ), TaskTransitionCode.automaticPush => _movementOnly( task: task, @@ -226,13 +293,14 @@ class TaskTransitionService { activityId: activityId, occurredAt: now, metadata: metadata, - updatedStats: task.stats.incrementAutoPush(), + appliedActivityIds: appliedActivityIds, ), TaskTransitionCode.moveToBacklog => _moveToBacklog( task: task, operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( @@ -240,6 +308,7 @@ class TaskTransitionService { operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), TaskTransitionCode.activate => _activate( @@ -247,6 +316,7 @@ class TaskTransitionService { operationId: operationId, activityId: activityId, occurredAt: now, + appliedActivityIds: appliedActivityIds, metadata: metadata, ), }; @@ -259,6 +329,8 @@ class TaskTransitionService { required DateTime occurredAt, required DateTime? actualStart, required DateTime? actualEnd, + required Iterable appliedActivityIds, + required List lockedIntervals, required Map metadata, }) { if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { @@ -272,21 +344,35 @@ class TaskTransitionService { actualStart: actualStart, actualEnd: actualEnd, ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.completed, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'completedAt': occurredAt, + 'scheduledEnd': task.scheduledEnd, + 'actualStart': actualStart, + 'actualEnd': actualEnd, + 'pushesBeforeCompletion': _pushesBeforeCompletion(task), + }, + nextStatus: completed.status, + ), + ]; + final accounted = accountingService.apply( + task: completed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + lockedIntervals: lockedIntervals, + ); return _applied( transitionCode: TaskTransitionCode.complete, - task: completed, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.completed, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: completed.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -295,6 +381,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { @@ -304,18 +391,15 @@ class TaskTransitionService { return _invalid(task, TaskTransitionCode.miss); } - final missedStats = task.stats.incrementMissed(); final updatedTask = task.type == TaskType.critical ? task.copyWith( status: TaskStatus.backlog, updatedAt: occurredAt, - stats: missedStats.incrementMovedToBacklog(), clearSchedule: true, ) : task.copyWith( status: TaskStatus.missed, updatedAt: occurredAt, - stats: missedStats, ); final activities = [ _activity( @@ -346,9 +430,15 @@ class TaskTransitionService { ); } + final accounted = accountingService.apply( + task: updatedTask, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + return _applied( transitionCode: TaskTransitionCode.miss, - task: updatedTask, + task: accounted.task, activities: activities, ); } @@ -358,6 +448,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { if (task.status == TaskStatus.cancelled) { @@ -370,24 +461,29 @@ class TaskTransitionService { final cancelled = task.copyWith( status: TaskStatus.cancelled, updatedAt: occurredAt, - stats: task.stats.incrementCancelled(), clearSchedule: true, ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.cancelled, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: cancelled.status, + ), + ]; + final accounted = accountingService.apply( + task: cancelled, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); return _applied( transitionCode: TaskTransitionCode.cancel, - task: cancelled, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.cancelled, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: cancelled.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -396,6 +492,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { if (task.status == TaskStatus.noLongerRelevant) { @@ -411,20 +508,27 @@ class TaskTransitionService { clearSchedule: true, ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.noLongerRelevant, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: dismissed.status, + ), + ]; + final accounted = accountingService.apply( + task: dismissed, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + return _applied( transitionCode: TaskTransitionCode.noLongerRelevant, - task: dismissed, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.noLongerRelevant, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: dismissed.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -436,27 +540,34 @@ class TaskTransitionService { required String activityId, required DateTime occurredAt, required Map metadata, - required TaskStatistics updatedStats, + required Iterable appliedActivityIds, }) { if (!task.isFlexible || task.status != TaskStatus.planned) { return _invalid(task, transitionCode); } - final updated = task.copyWith(updatedAt: occurredAt, stats: updatedStats); + final updated = task.copyWith(updatedAt: occurredAt); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: activityCode, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updated.status, + ), + ]; + final accounted = accountingService.apply( + task: updated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + return _applied( transitionCode: transitionCode, - task: updated, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: activityCode, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: updated.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -465,6 +576,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { final canMove = (task.isFlexible || task.type == TaskType.critical) && @@ -476,24 +588,29 @@ class TaskTransitionService { final moved = task.copyWith( status: TaskStatus.backlog, updatedAt: occurredAt, - stats: task.stats.incrementMovedToBacklog(), clearSchedule: true, ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: moved.status, + ), + ]; + final accounted = accountingService.apply( + task: moved, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); return _applied( transitionCode: TaskTransitionCode.moveToBacklog, - task: moved, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.movedToBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: moved.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -502,6 +619,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { if (task.status != TaskStatus.backlog || @@ -512,23 +630,28 @@ class TaskTransitionService { final restored = task.copyWith( status: TaskStatus.planned, updatedAt: occurredAt, - stats: task.stats.incrementRestoredFromBacklog(), + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.restoredFromBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: restored.status, + ), + ]; + final accounted = accountingService.apply( + task: restored, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, ); return _applied( transitionCode: TaskTransitionCode.restoreFromBacklog, - task: restored, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.restoredFromBacklog, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: restored.status, - ), - ], + task: accounted.task, + activities: activities, ); } @@ -537,6 +660,7 @@ class TaskTransitionService { required String operationId, required String activityId, required DateTime occurredAt, + required Iterable appliedActivityIds, required Map metadata, }) { if (task.status != TaskStatus.planned || @@ -551,24 +675,151 @@ class TaskTransitionService { updatedAt: occurredAt, ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.activated, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: activated.status, + ), + ]; + final accounted = accountingService.apply( + task: activated, + activities: activities, + alreadyAppliedActivityIds: appliedActivityIds, + ); + return _applied( transitionCode: TaskTransitionCode.activate, - task: activated, - activities: [ - _activity( - id: activityId, - operationId: operationId, - code: TaskActivityCode.activated, - task: task, - occurredAt: occurredAt, - metadata: metadata, - nextStatus: activated.status, - ), - ], + task: accounted.task, + activities: activities, ); } } +TaskStatistics _applyActivityStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + return switch (activity.code) { + TaskActivityCode.manuallyPushed => task.stats.incrementManualPush(), + TaskActivityCode.automaticallyPushed => task.stats.incrementAutoPush(), + TaskActivityCode.movedToBacklog => task.stats.incrementMovedToBacklog(), + TaskActivityCode.restoredFromBacklog => + task.stats.incrementRestoredFromBacklog(), + TaskActivityCode.missed => task.stats.incrementMissed(), + TaskActivityCode.cancelled => task.stats.incrementCancelled(), + TaskActivityCode.completed => _applyCompletionStats( + task: task, + activity: activity, + lockedIntervals: lockedIntervals, + ), + TaskActivityCode.noLongerRelevant || + TaskActivityCode.activated => + task.stats, + }; +} + +TaskStatistics _applyCompletionStats({ + required Task task, + required TaskActivity activity, + required List lockedIntervals, +}) { + var stats = task.stats; + final scheduledEnd = task.scheduledEnd; + final completedAt = task.completedAt ?? activity.occurredAt; + + if (scheduledEnd != null && completedAt.isAfter(scheduledEnd)) { + stats = stats.incrementCompletedLate(); + } + + final lockedMinutes = _actualLockedOverlapMinutes( + task: task, + lockedIntervals: lockedIntervals, + ); + if (lockedMinutes > 0) { + stats = stats.incrementCompletedDuringLockedHours(lockedMinutes); + } + + final pushesBeforeCompletion = + _intMetadata(activity.metadata['pushesBeforeCompletion']) ?? + _pushesBeforeCompletion(task); + + return stats.recordPushesBeforeCompletion(pushesBeforeCompletion); +} + +int _actualLockedOverlapMinutes({ + required Task task, + required List lockedIntervals, +}) { + final actualStart = task.actualStart; + final actualEnd = task.actualEnd; + if (actualStart == null || + actualEnd == null || + !actualStart.isBefore(actualEnd) || + lockedIntervals.isEmpty) { + return 0; + } + + final actualInterval = TimeInterval(start: actualStart, end: actualEnd); + final intersections = []; + + for (final lockedInterval in lockedIntervals) { + if (!actualInterval.overlaps(lockedInterval)) { + continue; + } + intersections.add( + TimeInterval( + start: _latest(actualInterval.start, lockedInterval.start), + end: _earliest(actualInterval.end, lockedInterval.end), + ), + ); + } + + if (intersections.isEmpty) { + return 0; + } + + intersections.sort((left, right) => left.start.compareTo(right.start)); + var total = Duration.zero; + var current = intersections.first; + for (final interval in intersections.skip(1)) { + if (interval.start.isAfter(current.end)) { + total += current.duration; + current = interval; + continue; + } + + current = TimeInterval( + start: current.start, + end: _latest(current.end, interval.end), + ); + } + total += current.duration; + + return total.inMinutes; +} + +int _pushesBeforeCompletion(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} + +int? _intMetadata(Object? value) { + return value is int ? value : null; +} + +DateTime _earliest(DateTime first, DateTime second) { + return first.isBefore(second) ? first : second; +} + +DateTime _latest(DateTime first, DateTime second) { + return first.isAfter(second) ? first : second; +} + TaskTransitionResult _applied({ required TaskTransitionCode transitionCode, required Task task, diff --git a/lib/src/task_statistics.dart b/lib/src/task_statistics.dart index e9e379e..5bf1dff 100644 --- a/lib/src/task_statistics.dart +++ b/lib/src/task_statistics.dart @@ -27,6 +27,9 @@ class TaskStatistics { this.completedLateCount = 0, this.completedDuringLockedHoursCount = 0, this.completedDuringLockedHoursMinutes = 0, + this.completedAfterShieldCount = 0, + this.completedAfterPushCount = 0, + this.totalPushesBeforeCompletion = 0, }); /// Number of times this task was skipped during Shield/recovery behavior. @@ -59,6 +62,15 @@ class TaskStatistics { /// Total minutes completed while overlapping locked hours. final int completedDuringLockedHoursMinutes; + /// Dormant V2-compatible counter for future Shield completion workflows. + final int completedAfterShieldCount; + + /// Number of completions that happened after at least one push. + final int completedAfterPushCount; + + /// Sum of push counts present when completion was recorded. + final int totalPushesBeforeCompletion; + /// Return a copy with selected counters changed. /// /// Counters default to their current values when omitted, which keeps small @@ -74,6 +86,9 @@ class TaskStatistics { int? completedLateCount, int? completedDuringLockedHoursCount, int? completedDuringLockedHoursMinutes, + int? completedAfterShieldCount, + int? completedAfterPushCount, + int? totalPushesBeforeCompletion, }) { return TaskStatistics( skippedDuringBurnoutCount: @@ -90,6 +105,12 @@ class TaskStatistics { this.completedDuringLockedHoursCount, completedDuringLockedHoursMinutes: completedDuringLockedHoursMinutes ?? this.completedDuringLockedHoursMinutes, + completedAfterShieldCount: + completedAfterShieldCount ?? this.completedAfterShieldCount, + completedAfterPushCount: + completedAfterPushCount ?? this.completedAfterPushCount, + totalPushesBeforeCompletion: + totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion, ); } @@ -144,4 +165,16 @@ class TaskStatistics { completedDuringLockedHoursMinutes + minutes, ); } + + /// Record that completion happened after [pushCount] prior pushes. + TaskStatistics recordPushesBeforeCompletion(int pushCount) { + if (pushCount <= 0) { + return this; + } + + return copyWith( + completedAfterPushCount: completedAfterPushCount + 1, + totalPushesBeforeCompletion: totalPushesBeforeCompletion + pushCount, + ); + } } diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart index 49bddcc..5e26b36 100644 --- a/test/document_mapping_test.dart +++ b/test/document_mapping_test.dart @@ -102,6 +102,9 @@ void main() { completedLateCount: 8, completedDuringLockedHoursCount: 9, completedDuringLockedHoursMinutes: 10, + completedAfterShieldCount: 11, + completedAfterPushCount: 12, + totalPushesBeforeCompletion: 13, ); final document = stats.toDocument(); @@ -118,6 +121,9 @@ void main() { expect(restored.completedLateCount, 8); expect(restored.completedDuringLockedHoursCount, 9); expect(restored.completedDuringLockedHoursMinutes, 10); + expect(restored.completedAfterShieldCount, 11); + expect(restored.completedAfterPushCount, 12); + expect(restored.totalPushesBeforeCompletion, 13); }); test('enum document mapping rejects unknown persistence names', () { diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart index 74c27db..2f76ffc 100644 --- a/test/persistence_edge_cases_test.dart +++ b/test/persistence_edge_cases_test.dart @@ -245,6 +245,9 @@ void main() { 'completedLateCount', 'completedDuringLockedHoursCount', 'completedDuringLockedHoursMinutes', + 'completedAfterShieldCount', + 'completedAfterPushCount', + 'totalPushesBeforeCompletion', }); }); diff --git a/test/task_lifecycle_test.dart b/test/task_lifecycle_test.dart index 3f208d1..61ee165 100644 --- a/test/task_lifecycle_test.dart +++ b/test/task_lifecycle_test.dart @@ -29,6 +29,7 @@ void main() { expect(result.activities.single.id, 'activity-complete'); expect(result.activities.single.operationId, 'op-complete'); expect(result.activities.single.code, TaskActivityCode.completed); + expect(result.task.stats.completedLateCount, 1); expect( result.activities.single.metadata, containsPair('previousStatus', 'planned'), @@ -37,6 +38,106 @@ void main() { result.activities.single.metadata, containsPair('nextStatus', 'completed'), ); + expect( + result.activities.single.metadata, + containsPair('pushesBeforeCompletion', 0), + ); + }); + + test('late-by-zero completion is not counted late', () { + final task = scheduledTask(id: 'task-1', now: now); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-complete', + activityId: 'activity-complete', + occurredAt: task.scheduledEnd, + ); + + expect(result.task.completedAt, task.scheduledEnd); + expect(result.task.stats.completedLateCount, 0); + }); + + test('completion accounting uses actual locked overlap boundaries', () { + final task = scheduledTask( + id: 'task-1', + now: now, + start: DateTime(2026, 6, 25, 9), + durationMinutes: 90, + ); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-complete', + activityId: 'activity-complete', + occurredAt: DateTime(2026, 6, 25, 10, 30), + actualStart: DateTime(2026, 6, 25, 9, 15), + actualEnd: DateTime(2026, 6, 25, 10, 15), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 25, 8), + end: DateTime(2026, 6, 25, 9, 15), + ), + TimeInterval( + start: DateTime(2026, 6, 25, 9, 30), + end: DateTime(2026, 6, 25, 9, 45), + ), + TimeInterval( + start: DateTime(2026, 6, 25, 9, 40), + end: DateTime(2026, 6, 25, 10), + ), + ], + ); + + expect(result.task.stats.completedDuringLockedHoursCount, 1); + expect(result.task.stats.completedDuringLockedHoursMinutes, 30); + }); + + test('completion without actual interval does not fabricate locked minutes', + () { + final task = scheduledTask(id: 'task-1', now: now); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-complete', + activityId: 'activity-complete', + occurredAt: DateTime(2026, 6, 25, 9, 20), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 25, 9), + end: DateTime(2026, 6, 25, 10), + ), + ], + ); + + expect(result.task.actualStart, isNull); + expect(result.task.stats.completedDuringLockedHoursCount, 0); + expect(result.task.stats.completedDuringLockedHoursMinutes, 0); + }); + + test('completion captures pushes present at completion', () { + final task = scheduledTask(id: 'task-1', now: now).copyWith( + stats: const TaskStatistics( + manuallyPushedCount: 2, + autoPushedCount: 1, + ), + ); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-complete', + activityId: 'activity-complete', + occurredAt: task.scheduledEnd, + ); + + expect(result.activities.single.metadata, + containsPair('pushesBeforeCompletion', 3)); + expect(result.task.stats.completedAfterPushCount, 1); + expect(result.task.stats.totalPushesBeforeCompletion, 3); }); test('duplicate operation ids do not create activities or mutate again', @@ -158,6 +259,35 @@ void main() { }); }); + group('Task activity accounting service', () { + final now = DateTime(2026, 6, 25, 9); + const service = TaskActivityAccountingService(); + + test('applies activity counters once by activity id', () { + final task = scheduledTask(id: 'task-1', now: now); + final activity = TaskActivity( + id: 'activity-push', + operationId: 'op-push', + code: TaskActivityCode.manuallyPushed, + taskId: task.id, + projectId: task.projectId, + occurredAt: now, + ); + + final first = service.apply(task: task, activities: [activity]); + final replay = service.apply( + task: first.task, + activities: [activity], + alreadyAppliedActivityIds: first.appliedActivityIds, + ); + + expect(first.task.stats.manuallyPushedCount, 1); + expect(first.appliedActivityIds, ['activity-push']); + expect(replay.task.stats.manuallyPushedCount, 1); + expect(replay.appliedActivityIds, isEmpty); + }); + }); + group('Action services use canonical transitions', () { final now = DateTime(2026, 6, 25, 9); @@ -235,6 +365,70 @@ void main() { 'op-push', }); }); + + test('duplicate push destination operation id returns no-op result', () { + const service = FlexibleTaskActionService(); + final task = scheduledTask(id: 'first', now: now); + final previousActivity = TaskActivity( + id: 'activity-push', + operationId: 'op-push', + code: TaskActivityCode.manuallyPushed, + taskId: task.id, + projectId: task.projectId, + occurredAt: now, + ); + + final result = service.applyPushDestination( + destination: PushDestination.nextAvailableSlot, + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 25, 9), + end: DateTime(2026, 6, 25, 11), + ), + ), + taskId: task.id, + updatedAt: now, + operationId: 'op-push', + existingActivities: [previousActivity], + ); + + expect(result.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp); + expect(result.schedulingResult.tasks.single, task); + expect(result.activities, isEmpty); + }); + + test('surprise logging applies completion accounting', () { + const service = SurpriseTaskLogService(); + final result = service.log( + request: SurpriseTaskLogRequest( + id: 'surprise', + title: 'Unexpected call', + createdAt: now, + startedAt: DateTime(2026, 6, 25, 9, 10), + timeUsedMinutes: 40, + ), + input: SchedulingInput( + tasks: const [], + window: SchedulingWindow( + start: DateTime(2026, 6, 25, 9), + end: DateTime(2026, 6, 25, 11), + ), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 25, 9, 30), + end: DateTime(2026, 6, 25, 10), + ), + ], + ), + updatedAt: now, + ); + + expect(result.activities.single.code, TaskActivityCode.completed); + expect(result.surpriseTask.completedAt, now); + expect(result.surpriseTask.stats.completedDuringLockedHoursCount, 1); + expect(result.surpriseTask.stats.completedDuringLockedHoursMinutes, 20); + }); }); } @@ -242,6 +436,7 @@ Task scheduledTask({ required String id, required DateTime now, DateTime? start, + int durationMinutes = 15, }) { final scheduledStart = start ?? DateTime(2026, 6, 25, 9); return Task( @@ -251,9 +446,9 @@ Task scheduledTask({ type: TaskType.flexible, status: TaskStatus.planned, priority: PriorityLevel.medium, - durationMinutes: 15, + durationMinutes: durationMinutes, scheduledStart: scheduledStart, - scheduledEnd: scheduledStart.add(const Duration(minutes: 15)), + scheduledEnd: scheduledStart.add(Duration(minutes: durationMinutes)), createdAt: now, updatedAt: now, );