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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,9 @@ void main() {
|
||||||
'completedLateCount',
|
'completedLateCount',
|
||||||
'completedDuringLockedHoursCount',
|
'completedDuringLockedHoursCount',
|
||||||
'completedDuringLockedHoursMinutes',
|
'completedDuringLockedHoursMinutes',
|
||||||
|
'completedAfterShieldCount',
|
||||||
|
'completedAfterPushCount',
|
||||||
|
'totalPushesBeforeCompletion',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue