forked from eva/focus-flow
feat(stats): account task activities exactly once
This commit is contained in:
parent
a5b72634fd
commit
57b07b8061
13 changed files with 824 additions and 117 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <String>{
|
||||
skippedDuringBurnoutCount,
|
||||
|
|
@ -168,6 +171,9 @@ abstract final class TaskStatisticsDocumentFields {
|
|||
completedLateCount,
|
||||
completedDuringLockedHoursCount,
|
||||
completedDuringLockedHoursMinutes,
|
||||
completedAfterShieldCount,
|
||||
completedAfterPushCount,
|
||||
totalPushesBeforeCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ class SurpriseTaskLogResult {
|
|||
const SurpriseTaskLogResult({
|
||||
required this.surpriseTask,
|
||||
required this.schedulingResult,
|
||||
this.activities = const <TaskActivity>[],
|
||||
this.requiredOverlaps = const <SchedulingOverlap>[],
|
||||
this.lockedOverlaps = const <TimeInterval>[],
|
||||
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<TaskActivity> activities;
|
||||
|
||||
/// Critical/inflexible overlaps with the surprise interval.
|
||||
final List<SchedulingOverlap> requiredOverlaps;
|
||||
|
||||
|
|
@ -266,6 +270,9 @@ class FlexibleTaskActionService {
|
|||
required FlexibleTaskQuickAction action,
|
||||
DateTime? updatedAt,
|
||||
String? operationId,
|
||||
DateTime? actualStart,
|
||||
DateTime? actualEnd,
|
||||
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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 = <Task>[surpriseTask, ...input.tasks];
|
||||
|
||||
|
|
@ -619,6 +668,7 @@ class SurpriseTaskLogService {
|
|||
SchedulingNotice('Surprise task logged.'),
|
||||
],
|
||||
),
|
||||
activities: [completedActivity],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -701,6 +751,7 @@ class SurpriseTaskLogService {
|
|||
),
|
||||
requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
|
||||
lockedOverlaps: List<TimeInterval>.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<TimeInterval> _lockedIntervalsForAccounting(SchedulingInput input) {
|
||||
return input.occupancyEntries
|
||||
.where(
|
||||
(entry) => entry.category == OccupancyCategory.hiddenLockedConstraint,
|
||||
)
|
||||
.map((entry) => entry.interval)
|
||||
.whereType<TimeInterval>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _hasDuplicateActivity({
|
||||
required Iterable<TaskActivity> existingActivities,
|
||||
required String operationId,
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
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<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
Iterable<String> appliedActivityIds = const <String>[],
|
||||
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) {
|
||||
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<String> appliedActivityIds,
|
||||
required List<TimeInterval> lockedIntervals,
|
||||
required Map<String, Object?> 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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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 = <TaskActivity>[
|
||||
_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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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<String, Object?> metadata,
|
||||
required TaskStatistics updatedStats,
|
||||
required Iterable<String> 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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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<String> appliedActivityIds,
|
||||
required Map<String, Object?> 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<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({
|
||||
required TaskTransitionCode transitionCode,
|
||||
required Task task,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,9 @@ void main() {
|
|||
'completedLateCount',
|
||||
'completedDuringLockedHoursCount',
|
||||
'completedDuringLockedHoursMinutes',
|
||||
'completedAfterShieldCount',
|
||||
'completedAfterPushCount',
|
||||
'totalPushesBeforeCompletion',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue