feat(stats): account task activities exactly once

This commit is contained in:
Ashley Venn 2026-06-25 09:44:22 -07:00
parent a5b72634fd
commit 57b07b8061
13 changed files with 824 additions and 117 deletions

View file

@ -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 | | 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 | | 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 | | 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 | | 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 | Atomic application use cases and exactly-once stat persistence | 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 and increment helpers | Automatic exactly-once updates, late/locked completion calculation, push-before-completion aggregates, child patterns | 13 | | 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 | | 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 | | 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 | | 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 |

View file

@ -64,6 +64,8 @@ accounting and locked-hour statistics.
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Complete on 2026-06-25.
Tasks: Tasks:
- Update manual-push, auto-push, moved-to-backlog, restored-from-backlog, missed, - 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. - Surprise completions participate in the same accounting policy.
- The task-statistics document contract can represent all resulting values. - 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 BREAKPOINT: Stop here. Confirm `high` mode before implementing child-task
orchestration. orchestration.

View file

@ -65,7 +65,7 @@ changes from accidental API drift.
| `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` |
| `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` | | `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_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/task_statistics.dart` | `TaskStatistics` |
| `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` | | `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` |
| `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` | | `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` |
@ -100,8 +100,8 @@ Current notable model fields:
- `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/
difficulty/reminder profile/duration fields difficulty/reminder profile/duration fields
- `TimeInterval`: `start`, `end`, optional `label` - `TimeInterval`: `start`, `end`, optional `label`
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour and - `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
parent-child counters completed-after-shield, push-before-completion, and parent-child counters
Known baseline gaps to preserve as explicit later work: 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 - Routed flexible/required direct action services through the canonical
transition service while preserving existing scheduling helper compatibility. 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 ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:

View file

@ -35,6 +35,13 @@ Latest Block 13.1 verification result on 2026-06-25:
- `dart test`: passed, 211 tests - `dart test`: passed, 211 tests
- `git diff --check`: passed - `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: Status values:
- `complete`: Current backend APIs and tests cover the requirement. - `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-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-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-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-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-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-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 ## 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-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-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-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. | | 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 ## Intentionally deferred human-spec items

View file

@ -187,6 +187,12 @@ abstract final class TaskStatisticsDocumentMapping {
stats.completedDuringLockedHoursCount, stats.completedDuringLockedHoursCount,
TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes: TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes:
stats.completedDuringLockedHoursMinutes, stats.completedDuringLockedHoursMinutes,
TaskStatisticsDocumentFields.completedAfterShieldCount:
stats.completedAfterShieldCount,
TaskStatisticsDocumentFields.completedAfterPushCount:
stats.completedAfterPushCount,
TaskStatisticsDocumentFields.totalPushesBeforeCompletion:
stats.totalPushesBeforeCompletion,
}; };
} }
@ -233,6 +239,18 @@ abstract final class TaskStatisticsDocumentMapping {
document, document,
TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes,
), ),
completedAfterShieldCount: _intOrZero(
document,
TaskStatisticsDocumentFields.completedAfterShieldCount,
),
completedAfterPushCount: _intOrZero(
document,
TaskStatisticsDocumentFields.completedAfterPushCount,
),
totalPushesBeforeCompletion: _intOrZero(
document,
TaskStatisticsDocumentFields.totalPushesBeforeCompletion,
),
); );
} }
} }

View file

@ -156,6 +156,9 @@ abstract final class TaskStatisticsDocumentFields {
'completedDuringLockedHoursCount'; 'completedDuringLockedHoursCount';
static const completedDuringLockedHoursMinutes = static const completedDuringLockedHoursMinutes =
'completedDuringLockedHoursMinutes'; 'completedDuringLockedHoursMinutes';
static const completedAfterShieldCount = 'completedAfterShieldCount';
static const completedAfterPushCount = 'completedAfterPushCount';
static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion';
static const all = <String>{ static const all = <String>{
skippedDuringBurnoutCount, skippedDuringBurnoutCount,
@ -168,6 +171,9 @@ abstract final class TaskStatisticsDocumentFields {
completedLateCount, completedLateCount,
completedDuringLockedHoursCount, completedDuringLockedHoursCount,
completedDuringLockedHoursMinutes, completedDuringLockedHoursMinutes,
completedAfterShieldCount,
completedAfterPushCount,
totalPushesBeforeCompletion,
}; };
} }

View file

@ -19,6 +19,8 @@ import 'time_contracts.dart';
const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); const OccupancyPolicy _occupancyPolicy = OccupancyPolicy();
const TaskTransitionService _transitionService = TaskTransitionService(); const TaskTransitionService _transitionService = TaskTransitionService();
const TaskActivityAccountingService _activityAccountingService =
TaskActivityAccountingService();
/// Category for scheduler notices. /// Category for scheduler notices.
/// ///
@ -1349,14 +1351,25 @@ SchedulingResult _applyPlacement({
continue; continue;
} }
final updatedTask = task.copyWith( final placedTask = task.copyWith(
status: isInsertedTask ? TaskStatus.planned : task.status, status: isInsertedTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
stats: isInsertedTask );
? task.stats.incrementRestoredFromBacklog() final updatedTask = _applySchedulingActivity(
: task.stats.incrementAutoPush(), 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); updatedTasks.add(updatedTask);
@ -1434,13 +1447,23 @@ SchedulingResult _applyPushPlacement({
} }
final isPushedTask = task.id == pushedTask.id; final isPushedTask = task.id == pushedTask.id;
final updatedTask = task.copyWith( final placedTask = task.copyWith(
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, updatedAt: updatedAt,
stats: isPushedTask && countPushedTaskAsManual );
? task.stats.incrementManualPush() final updatedTask = _applySchedulingActivity(
: task.stats.incrementAutoPush(), 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); updatedTasks.add(updatedTask);
@ -1511,12 +1534,22 @@ SchedulingResult _applyRolloverPlacement({
} }
final isRolledTask = rolledTaskIds.contains(task.id); final isRolledTask = rolledTaskIds.contains(task.id);
final updatedTask = task.copyWith( final placedTask = task.copyWith(
status: isRolledTask ? TaskStatus.planned : task.status, status: isRolledTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start, scheduledStart: interval.start,
scheduledEnd: interval.end, scheduledEnd: interval.end,
updatedAt: updatedAt, 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); updatedTasks.add(updatedTask);
@ -1611,6 +1644,40 @@ bool _sameDateTime(DateTime? first, DateTime? second) {
return first.isAtSameMomentAs(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]. /// Find the first candidate interval at or after [earliestStart].
/// ///
/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps /// The scan assumes [blocked] is sorted by start time. When a candidate overlaps

View file

@ -213,6 +213,7 @@ class SurpriseTaskLogResult {
const SurpriseTaskLogResult({ const SurpriseTaskLogResult({
required this.surpriseTask, required this.surpriseTask,
required this.schedulingResult, required this.schedulingResult,
this.activities = const <TaskActivity>[],
this.requiredOverlaps = const <SchedulingOverlap>[], this.requiredOverlaps = const <SchedulingOverlap>[],
this.lockedOverlaps = const <TimeInterval>[], this.lockedOverlaps = const <TimeInterval>[],
this.usedNormalPushRules = false, this.usedNormalPushRules = false,
@ -224,6 +225,9 @@ class SurpriseTaskLogResult {
/// Updated task list, movement notices, changes, and visible overlaps. /// Updated task list, movement notices, changes, and visible overlaps.
final SchedulingResult schedulingResult; final SchedulingResult schedulingResult;
/// Internal activities produced by logging the surprise task.
final List<TaskActivity> activities;
/// Critical/inflexible overlaps with the surprise interval. /// Critical/inflexible overlaps with the surprise interval.
final List<SchedulingOverlap> requiredOverlaps; final List<SchedulingOverlap> requiredOverlaps;
@ -266,6 +270,9 @@ class FlexibleTaskActionService {
required FlexibleTaskQuickAction action, required FlexibleTaskQuickAction action,
DateTime? updatedAt, DateTime? updatedAt,
String? operationId, String? operationId,
DateTime? actualStart,
DateTime? actualEnd,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
if (!task.isFlexible) { if (!task.isFlexible) {
@ -288,6 +295,9 @@ class FlexibleTaskActionService {
TaskActivityCode.completed, TaskActivityCode.completed,
), ),
occurredAt: now, occurredAt: now,
actualStart: actualStart,
actualEnd: actualEnd,
lockedIntervals: lockedIntervals,
existingActivities: existingActivities, existingActivities: existingActivities,
); );
return FlexibleTaskActionResult( return FlexibleTaskActionResult(
@ -352,6 +362,24 @@ class FlexibleTaskActionService {
final now = updatedAt ?? clock.now(); final now = updatedAt ?? clock.now();
final opId = operationId ?? final opId = operationId ??
_defaultOperationId('push-${destination.name}', taskId, now); _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) { final result = switch (destination) {
PushDestination.nextAvailableSlot => PushDestination.nextAvailableSlot =>
schedulingEngine.pushFlexibleTaskToNextAvailableSlot( schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
@ -500,6 +528,9 @@ class RequiredTaskActionService {
required RequiredTaskAction action, required RequiredTaskAction action,
DateTime? updatedAt, DateTime? updatedAt,
String? operationId, String? operationId,
DateTime? actualStart,
DateTime? actualEnd,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) { }) {
if (!task.isRequiredVisible) { if (!task.isRequiredVisible) {
@ -532,6 +563,9 @@ class RequiredTaskActionService {
operationId: opId, operationId: opId,
activityId: _activityId(opId, task.id, activityCode), activityId: _activityId(opId, task.id, activityCode),
occurredAt: now, occurredAt: now,
actualStart: actualStart,
actualEnd: actualEnd,
lockedIntervals: lockedIntervals,
existingActivities: existingActivities, existingActivities: existingActivities,
); );
@ -556,6 +590,7 @@ class RequiredTaskActionService {
class SurpriseTaskLogService { class SurpriseTaskLogService {
const SurpriseTaskLogService({ const SurpriseTaskLogService({
this.schedulingEngine = const SchedulingEngine(), this.schedulingEngine = const SchedulingEngine(),
this.accountingService = const TaskActivityAccountingService(),
this.clock = const SystemClock(), this.clock = const SystemClock(),
this.idGenerator, this.idGenerator,
}); });
@ -563,6 +598,9 @@ class SurpriseTaskLogService {
/// Scheduling dependency used to move overlapping flexible tasks. /// Scheduling dependency used to move overlapping flexible tasks.
final SchedulingEngine schedulingEngine; final SchedulingEngine schedulingEngine;
/// Activity-derived statistics updater.
final TaskActivityAccountingService accountingService;
/// Clock boundary used when a log timestamp is not supplied. /// Clock boundary used when a log timestamp is not supplied.
final Clock clock; 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); final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
var currentTasks = <Task>[surpriseTask, ...input.tasks]; var currentTasks = <Task>[surpriseTask, ...input.tasks];
@ -619,6 +668,7 @@ class SurpriseTaskLogService {
SchedulingNotice('Surprise task logged.'), SchedulingNotice('Surprise task logged.'),
], ],
), ),
activities: [completedActivity],
); );
} }
@ -701,6 +751,7 @@ class SurpriseTaskLogService {
), ),
requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps), requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps), lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps),
activities: [completedActivity],
usedNormalPushRules: usedNormalPushRules, usedNormalPushRules: usedNormalPushRules,
); );
} }
@ -851,6 +902,52 @@ TaskActivityCode _activityCodeForPushChange({
: TaskActivityCode.automaticallyPushed; : 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<TimeInterval> _lockedIntervalsForAccounting(SchedulingInput input) {
return input.occupancyEntries
.where(
(entry) => entry.category == OccupancyCategory.hiddenLockedConstraint,
)
.map((entry) => entry.interval)
.whereType<TimeInterval>()
.toList(growable: false);
}
bool _hasDuplicateActivity({ bool _hasDuplicateActivity({
required Iterable<TaskActivity> existingActivities, required Iterable<TaskActivity> existingActivities,
required String operationId, required String operationId,

View file

@ -113,15 +113,75 @@ class TaskTransitionResult {
bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied;
} }
/// Result of applying activity-derived task statistics.
class TaskActivityApplicationResult {
TaskActivityApplicationResult({
required this.task,
List<String> appliedActivityIds = const <String>[],
}) : appliedActivityIds = List<String>.unmodifiable(appliedActivityIds);
/// Task after all newly applied activity effects.
final Task task;
/// Activity ids that changed statistics in this application pass.
final List<String> 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<TaskActivity> activities,
Iterable<String> alreadyAppliedActivityIds = const <String>[],
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
}) {
final appliedIds = alreadyAppliedActivityIds.toSet();
final newlyAppliedIds = <String>[];
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. /// Canonical lifecycle transition service.
class TaskTransitionService { class TaskTransitionService {
const TaskTransitionService({ const TaskTransitionService({
this.clock = const SystemClock(), this.clock = const SystemClock(),
this.accountingService = const TaskActivityAccountingService(),
}); });
/// Clock boundary used when no explicit occurrence time is supplied. /// Clock boundary used when no explicit occurrence time is supplied.
final Clock clock; final Clock clock;
/// Activity-derived statistics updater.
final TaskActivityAccountingService accountingService;
/// Apply one transition to [task]. /// Apply one transition to [task].
/// ///
/// [operationId] is the idempotency key. If an existing activity for the same /// [operationId] is the idempotency key. If an existing activity for the same
@ -136,6 +196,8 @@ class TaskTransitionService {
DateTime? actualStart, DateTime? actualStart,
DateTime? actualEnd, DateTime? actualEnd,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[], Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
Map<String, Object?> metadata = const <String, Object?>{}, Map<String, Object?> metadata = const <String, Object?>{},
}) { }) {
final now = occurredAt ?? clock.now(); final now = occurredAt ?? clock.now();
@ -185,6 +247,8 @@ class TaskTransitionService {
occurredAt: now, occurredAt: now,
actualStart: actualStart, actualStart: actualStart,
actualEnd: actualEnd, actualEnd: actualEnd,
appliedActivityIds: appliedActivityIds,
lockedIntervals: lockedIntervals,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.miss => _miss( TaskTransitionCode.miss => _miss(
@ -192,6 +256,7 @@ class TaskTransitionService {
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.cancel => _cancel( TaskTransitionCode.cancel => _cancel(
@ -199,6 +264,7 @@ class TaskTransitionService {
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.noLongerRelevant => _noLongerRelevant( TaskTransitionCode.noLongerRelevant => _noLongerRelevant(
@ -206,6 +272,7 @@ class TaskTransitionService {
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.manualPush => _movementOnly( TaskTransitionCode.manualPush => _movementOnly(
@ -216,7 +283,7 @@ class TaskTransitionService {
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
metadata: metadata, metadata: metadata,
updatedStats: task.stats.incrementManualPush(), appliedActivityIds: appliedActivityIds,
), ),
TaskTransitionCode.automaticPush => _movementOnly( TaskTransitionCode.automaticPush => _movementOnly(
task: task, task: task,
@ -226,13 +293,14 @@ class TaskTransitionService {
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
metadata: metadata, metadata: metadata,
updatedStats: task.stats.incrementAutoPush(), appliedActivityIds: appliedActivityIds,
), ),
TaskTransitionCode.moveToBacklog => _moveToBacklog( TaskTransitionCode.moveToBacklog => _moveToBacklog(
task: task, task: task,
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog(
@ -240,6 +308,7 @@ class TaskTransitionService {
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
TaskTransitionCode.activate => _activate( TaskTransitionCode.activate => _activate(
@ -247,6 +316,7 @@ class TaskTransitionService {
operationId: operationId, operationId: operationId,
activityId: activityId, activityId: activityId,
occurredAt: now, occurredAt: now,
appliedActivityIds: appliedActivityIds,
metadata: metadata, metadata: metadata,
), ),
}; };
@ -259,6 +329,8 @@ class TaskTransitionService {
required DateTime occurredAt, required DateTime occurredAt,
required DateTime? actualStart, required DateTime? actualStart,
required DateTime? actualEnd, required DateTime? actualEnd,
required Iterable<String> appliedActivityIds,
required List<TimeInterval> lockedIntervals,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { if (task.status != TaskStatus.planned && task.status != TaskStatus.active) {
@ -272,21 +344,35 @@ class TaskTransitionService {
actualStart: actualStart, actualStart: actualStart,
actualEnd: actualEnd, actualEnd: actualEnd,
); );
final activities = [
return _applied(
transitionCode: TaskTransitionCode.complete,
task: completed,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
code: TaskActivityCode.completed, code: TaskActivityCode.completed,
task: task, task: task,
occurredAt: occurredAt, occurredAt: occurredAt,
metadata: metadata, metadata: {
...metadata,
'completedAt': occurredAt,
'scheduledEnd': task.scheduledEnd,
'actualStart': actualStart,
'actualEnd': actualEnd,
'pushesBeforeCompletion': _pushesBeforeCompletion(task),
},
nextStatus: completed.status, nextStatus: completed.status,
), ),
], ];
final accounted = accountingService.apply(
task: completed,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
lockedIntervals: lockedIntervals,
);
return _applied(
transitionCode: TaskTransitionCode.complete,
task: accounted.task,
activities: activities,
); );
} }
@ -295,6 +381,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { if (task.status != TaskStatus.planned && task.status != TaskStatus.active) {
@ -304,18 +391,15 @@ class TaskTransitionService {
return _invalid(task, TaskTransitionCode.miss); return _invalid(task, TaskTransitionCode.miss);
} }
final missedStats = task.stats.incrementMissed();
final updatedTask = task.type == TaskType.critical final updatedTask = task.type == TaskType.critical
? task.copyWith( ? task.copyWith(
status: TaskStatus.backlog, status: TaskStatus.backlog,
updatedAt: occurredAt, updatedAt: occurredAt,
stats: missedStats.incrementMovedToBacklog(),
clearSchedule: true, clearSchedule: true,
) )
: task.copyWith( : task.copyWith(
status: TaskStatus.missed, status: TaskStatus.missed,
updatedAt: occurredAt, updatedAt: occurredAt,
stats: missedStats,
); );
final activities = <TaskActivity>[ final activities = <TaskActivity>[
_activity( _activity(
@ -346,9 +430,15 @@ class TaskTransitionService {
); );
} }
final accounted = accountingService.apply(
task: updatedTask,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied( return _applied(
transitionCode: TaskTransitionCode.miss, transitionCode: TaskTransitionCode.miss,
task: updatedTask, task: accounted.task,
activities: activities, activities: activities,
); );
} }
@ -358,6 +448,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status == TaskStatus.cancelled) { if (task.status == TaskStatus.cancelled) {
@ -370,14 +461,9 @@ class TaskTransitionService {
final cancelled = task.copyWith( final cancelled = task.copyWith(
status: TaskStatus.cancelled, status: TaskStatus.cancelled,
updatedAt: occurredAt, updatedAt: occurredAt,
stats: task.stats.incrementCancelled(),
clearSchedule: true, clearSchedule: true,
); );
final activities = [
return _applied(
transitionCode: TaskTransitionCode.cancel,
task: cancelled,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -387,7 +473,17 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: cancelled.status, nextStatus: cancelled.status,
), ),
], ];
final accounted = accountingService.apply(
task: cancelled,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: TaskTransitionCode.cancel,
task: accounted.task,
activities: activities,
); );
} }
@ -396,6 +492,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status == TaskStatus.noLongerRelevant) { if (task.status == TaskStatus.noLongerRelevant) {
@ -411,10 +508,7 @@ class TaskTransitionService {
clearSchedule: true, clearSchedule: true,
); );
return _applied( final activities = [
transitionCode: TaskTransitionCode.noLongerRelevant,
task: dismissed,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -424,7 +518,17 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: dismissed.status, nextStatus: dismissed.status,
), ),
], ];
final accounted = accountingService.apply(
task: dismissed,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: TaskTransitionCode.noLongerRelevant,
task: accounted.task,
activities: activities,
); );
} }
@ -436,17 +540,14 @@ class TaskTransitionService {
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
required TaskStatistics updatedStats, required Iterable<String> appliedActivityIds,
}) { }) {
if (!task.isFlexible || task.status != TaskStatus.planned) { if (!task.isFlexible || task.status != TaskStatus.planned) {
return _invalid(task, transitionCode); return _invalid(task, transitionCode);
} }
final updated = task.copyWith(updatedAt: occurredAt, stats: updatedStats); final updated = task.copyWith(updatedAt: occurredAt);
return _applied( final activities = [
transitionCode: transitionCode,
task: updated,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -456,7 +557,17 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: updated.status, nextStatus: updated.status,
), ),
], ];
final accounted = accountingService.apply(
task: updated,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: transitionCode,
task: accounted.task,
activities: activities,
); );
} }
@ -465,6 +576,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
final canMove = (task.isFlexible || task.type == TaskType.critical) && final canMove = (task.isFlexible || task.type == TaskType.critical) &&
@ -476,14 +588,9 @@ class TaskTransitionService {
final moved = task.copyWith( final moved = task.copyWith(
status: TaskStatus.backlog, status: TaskStatus.backlog,
updatedAt: occurredAt, updatedAt: occurredAt,
stats: task.stats.incrementMovedToBacklog(),
clearSchedule: true, clearSchedule: true,
); );
final activities = [
return _applied(
transitionCode: TaskTransitionCode.moveToBacklog,
task: moved,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -493,7 +600,17 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: moved.status, nextStatus: moved.status,
), ),
], ];
final accounted = accountingService.apply(
task: moved,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: TaskTransitionCode.moveToBacklog,
task: accounted.task,
activities: activities,
); );
} }
@ -502,6 +619,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status != TaskStatus.backlog || if (task.status != TaskStatus.backlog ||
@ -512,13 +630,8 @@ class TaskTransitionService {
final restored = task.copyWith( final restored = task.copyWith(
status: TaskStatus.planned, status: TaskStatus.planned,
updatedAt: occurredAt, updatedAt: occurredAt,
stats: task.stats.incrementRestoredFromBacklog(),
); );
final activities = [
return _applied(
transitionCode: TaskTransitionCode.restoreFromBacklog,
task: restored,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -528,7 +641,17 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: restored.status, nextStatus: restored.status,
), ),
], ];
final accounted = accountingService.apply(
task: restored,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: TaskTransitionCode.restoreFromBacklog,
task: accounted.task,
activities: activities,
); );
} }
@ -537,6 +660,7 @@ class TaskTransitionService {
required String operationId, required String operationId,
required String activityId, required String activityId,
required DateTime occurredAt, required DateTime occurredAt,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata, required Map<String, Object?> metadata,
}) { }) {
if (task.status != TaskStatus.planned || if (task.status != TaskStatus.planned ||
@ -551,10 +675,7 @@ class TaskTransitionService {
updatedAt: occurredAt, updatedAt: occurredAt,
); );
return _applied( final activities = [
transitionCode: TaskTransitionCode.activate,
task: activated,
activities: [
_activity( _activity(
id: activityId, id: activityId,
operationId: operationId, operationId: operationId,
@ -564,11 +685,141 @@ class TaskTransitionService {
metadata: metadata, metadata: metadata,
nextStatus: activated.status, nextStatus: activated.status,
), ),
], ];
final accounted = accountingService.apply(
task: activated,
activities: activities,
alreadyAppliedActivityIds: appliedActivityIds,
);
return _applied(
transitionCode: TaskTransitionCode.activate,
task: accounted.task,
activities: activities,
); );
} }
} }
TaskStatistics _applyActivityStats({
required Task task,
required TaskActivity activity,
required List<TimeInterval> 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<TimeInterval> 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<TimeInterval> 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 = <TimeInterval>[];
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({ TaskTransitionResult _applied({
required TaskTransitionCode transitionCode, required TaskTransitionCode transitionCode,
required Task task, required Task task,

View file

@ -27,6 +27,9 @@ class TaskStatistics {
this.completedLateCount = 0, this.completedLateCount = 0,
this.completedDuringLockedHoursCount = 0, this.completedDuringLockedHoursCount = 0,
this.completedDuringLockedHoursMinutes = 0, this.completedDuringLockedHoursMinutes = 0,
this.completedAfterShieldCount = 0,
this.completedAfterPushCount = 0,
this.totalPushesBeforeCompletion = 0,
}); });
/// Number of times this task was skipped during Shield/recovery behavior. /// Number of times this task was skipped during Shield/recovery behavior.
@ -59,6 +62,15 @@ class TaskStatistics {
/// Total minutes completed while overlapping locked hours. /// Total minutes completed while overlapping locked hours.
final int completedDuringLockedHoursMinutes; 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. /// Return a copy with selected counters changed.
/// ///
/// Counters default to their current values when omitted, which keeps small /// Counters default to their current values when omitted, which keeps small
@ -74,6 +86,9 @@ class TaskStatistics {
int? completedLateCount, int? completedLateCount,
int? completedDuringLockedHoursCount, int? completedDuringLockedHoursCount,
int? completedDuringLockedHoursMinutes, int? completedDuringLockedHoursMinutes,
int? completedAfterShieldCount,
int? completedAfterPushCount,
int? totalPushesBeforeCompletion,
}) { }) {
return TaskStatistics( return TaskStatistics(
skippedDuringBurnoutCount: skippedDuringBurnoutCount:
@ -90,6 +105,12 @@ class TaskStatistics {
this.completedDuringLockedHoursCount, this.completedDuringLockedHoursCount,
completedDuringLockedHoursMinutes: completedDuringLockedHoursMinutes ?? completedDuringLockedHoursMinutes: completedDuringLockedHoursMinutes ??
this.completedDuringLockedHoursMinutes, this.completedDuringLockedHoursMinutes,
completedAfterShieldCount:
completedAfterShieldCount ?? this.completedAfterShieldCount,
completedAfterPushCount:
completedAfterPushCount ?? this.completedAfterPushCount,
totalPushesBeforeCompletion:
totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion,
); );
} }
@ -144,4 +165,16 @@ class TaskStatistics {
completedDuringLockedHoursMinutes + minutes, 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,
);
}
} }

View file

@ -102,6 +102,9 @@ void main() {
completedLateCount: 8, completedLateCount: 8,
completedDuringLockedHoursCount: 9, completedDuringLockedHoursCount: 9,
completedDuringLockedHoursMinutes: 10, completedDuringLockedHoursMinutes: 10,
completedAfterShieldCount: 11,
completedAfterPushCount: 12,
totalPushesBeforeCompletion: 13,
); );
final document = stats.toDocument(); final document = stats.toDocument();
@ -118,6 +121,9 @@ void main() {
expect(restored.completedLateCount, 8); expect(restored.completedLateCount, 8);
expect(restored.completedDuringLockedHoursCount, 9); expect(restored.completedDuringLockedHoursCount, 9);
expect(restored.completedDuringLockedHoursMinutes, 10); expect(restored.completedDuringLockedHoursMinutes, 10);
expect(restored.completedAfterShieldCount, 11);
expect(restored.completedAfterPushCount, 12);
expect(restored.totalPushesBeforeCompletion, 13);
}); });
test('enum document mapping rejects unknown persistence names', () { test('enum document mapping rejects unknown persistence names', () {

View file

@ -245,6 +245,9 @@ void main() {
'completedLateCount', 'completedLateCount',
'completedDuringLockedHoursCount', 'completedDuringLockedHoursCount',
'completedDuringLockedHoursMinutes', 'completedDuringLockedHoursMinutes',
'completedAfterShieldCount',
'completedAfterPushCount',
'totalPushesBeforeCompletion',
}); });
}); });

View file

@ -29,6 +29,7 @@ void main() {
expect(result.activities.single.id, 'activity-complete'); expect(result.activities.single.id, 'activity-complete');
expect(result.activities.single.operationId, 'op-complete'); expect(result.activities.single.operationId, 'op-complete');
expect(result.activities.single.code, TaskActivityCode.completed); expect(result.activities.single.code, TaskActivityCode.completed);
expect(result.task.stats.completedLateCount, 1);
expect( expect(
result.activities.single.metadata, result.activities.single.metadata,
containsPair('previousStatus', 'planned'), containsPair('previousStatus', 'planned'),
@ -37,6 +38,106 @@ void main() {
result.activities.single.metadata, result.activities.single.metadata,
containsPair('nextStatus', 'completed'), 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', 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', () { group('Action services use canonical transitions', () {
final now = DateTime(2026, 6, 25, 9); final now = DateTime(2026, 6, 25, 9);
@ -235,6 +365,70 @@ void main() {
'op-push', '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 String id,
required DateTime now, required DateTime now,
DateTime? start, DateTime? start,
int durationMinutes = 15,
}) { }) {
final scheduledStart = start ?? DateTime(2026, 6, 25, 9); final scheduledStart = start ?? DateTime(2026, 6, 25, 9);
return Task( return Task(
@ -251,9 +446,9 @@ Task scheduledTask({
type: TaskType.flexible, type: TaskType.flexible,
status: TaskStatus.planned, status: TaskStatus.planned,
priority: PriorityLevel.medium, priority: PriorityLevel.medium,
durationMinutes: 15, durationMinutes: durationMinutes,
scheduledStart: scheduledStart, scheduledStart: scheduledStart,
scheduledEnd: scheduledStart.add(const Duration(minutes: 15)), scheduledEnd: scheduledStart.add(Duration(minutes: durationMinutes)),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
); );