feat(projects): add learned suggestion statistics
This commit is contained in:
parent
be98141ffd
commit
374907a281
13 changed files with 1309 additions and 9 deletions
|
|
@ -37,15 +37,15 @@ passed. The current suite has 143 passing tests.
|
||||||
| 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, 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 |
|
| 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 |
|
| 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 |
|
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, push-before-completion values, project aggregates, and learned suggestion inputs | Atomic persistence | 14 |
|
||||||
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 |
|
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 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, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, and explicit configured-vs-learned resolution | Effective reminder-profile resolver and protected-rest reminder policy | 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 |
|
||||||
| Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 |
|
| Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 |
|
||||||
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
|
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
|
||||||
| Application layer | Domain services can be called directly | Coherent use cases that load state, invoke rules, persist all changes atomically, and return UI-ready results | 14 |
|
| Application layer | Domain services can be called directly | Coherent use cases that load state, invoke rules, persist all changes atomically, and return UI-ready results | 14 |
|
||||||
| Repository contracts | Basic task/project/locked/snapshot interfaces and in-memory fakes | Date/project/parent queries, archive/delete behavior, revisions, activity/settings repositories, unit of work, conformance suite | 14, 15 |
|
| Repository contracts | Basic task/project/locked/snapshot interfaces and in-memory fakes | Date/project/parent queries, archive/delete behavior, revisions, activity/settings repositories, unit of work, conformance suite | 14, 15 |
|
||||||
| Document mapping | Task and task-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 |
|
| Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 |
|
||||||
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |
|
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |
|
||||||
| Backend acceptance | Historical unit test audit and handoff | End-to-end application scenarios, adapter conformance, migration/resilience/performance checks, final backend gate | 17 |
|
| Backend acceptance | Historical unit test audit and handoff | End-to-end application scenarios, adapter conformance, migration/resilience/performance checks, final backend gate | 17 |
|
||||||
| Flutter UI | Intentionally not started | Minimal shell and one vertical slice only after the backend gate; visual design remains open | 18 |
|
| Flutter UI | Intentionally not started | Minimal shell and one vertical slice only after the backend gate; visual design remains open | 18 |
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@ statistics and suggestions.
|
||||||
|
|
||||||
Recommended Codex level: extra high
|
Recommended Codex level: extra high
|
||||||
|
|
||||||
|
Status: Complete on 2026-06-25.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Add a persistence-friendly `ProjectStatistics` model for V1 observations,
|
- Add a persistence-friendly `ProjectStatistics` model for V1 observations,
|
||||||
|
|
@ -206,6 +208,13 @@ Acceptance criteria:
|
||||||
- Average pushes before completion and usual completion time are derivable.
|
- Average pushes before completion and usual completion time are derivable.
|
||||||
- Mapping requirements for Block 15 are documented and tested in memory.
|
- Mapping requirements for Block 15 are documented and tested in memory.
|
||||||
|
|
||||||
|
Verification on 2026-06-25:
|
||||||
|
|
||||||
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
|
- `dart analyze`: passed, no issues found
|
||||||
|
- `dart test`: passed, 233 tests
|
||||||
|
- `git diff --check`: passed
|
||||||
|
|
||||||
BREAKPOINT: Stop here. Confirm `high` mode before implementing reminder-policy
|
BREAKPOINT: Stop here. Confirm `high` mode before implementing reminder-policy
|
||||||
resolution.
|
resolution.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ following source files:
|
||||||
- `src/locked_time.dart`
|
- `src/locked_time.dart`
|
||||||
- `src/occupancy_policy.dart`
|
- `src/occupancy_policy.dart`
|
||||||
- `src/persistence_contract.dart`
|
- `src/persistence_contract.dart`
|
||||||
|
- `src/project_statistics.dart`
|
||||||
- `src/quick_capture.dart`
|
- `src/quick_capture.dart`
|
||||||
- `src/repositories.dart`
|
- `src/repositories.dart`
|
||||||
- `src/scheduling_engine.dart`
|
- `src/scheduling_engine.dart`
|
||||||
|
|
@ -42,6 +43,7 @@ changes from accidental API drift.
|
||||||
| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
|
| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
|
||||||
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
|
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
|
||||||
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
||||||
|
| `src/project_statistics.dart` | `ProjectCompletionTimeBucket`, `ProjectSuggestionType`, `ProjectSuggestionConfidence` |
|
||||||
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
||||||
| `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
|
| `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
|
||||||
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
|
||||||
|
|
@ -56,11 +58,12 @@ changes from accidental API drift.
|
||||||
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
|
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
|
||||||
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
||||||
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
|
||||||
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
|
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension`, `ProjectStatisticsDocumentExtension` |
|
||||||
| `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
|
| `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
|
||||||
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` |
|
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` |
|
||||||
| `src/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` |
|
| `src/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` |
|
||||||
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
||||||
|
| `src/project_statistics.dart` | `ProjectStatistics`, `ProjectStatisticsApplicationResult`, `ProjectStatisticsAggregationService`, `ProjectSuggestionPolicy`, `ProjectSuggestion`, `ProjectSuggestionSet`, `ProjectDefaultResolution`, `ProjectSuggestionService` |
|
||||||
| `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` |
|
| `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` |
|
||||||
| `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` |
|
||||||
|
|
@ -99,6 +102,9 @@ Current notable model fields:
|
||||||
`reminderOverride`, `createdAt`, `updatedAt`, `stats`
|
`reminderOverride`, `createdAt`, `updatedAt`, `stats`
|
||||||
- `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/
|
- `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/
|
||||||
difficulty/reminder profile/duration fields
|
difficulty/reminder profile/duration fields
|
||||||
|
- `ProjectStatistics`: completion count, duration sample counts,
|
||||||
|
completion-time buckets, pushes-before-completion totals, reward/difficulty
|
||||||
|
distributions, and reminder-profile observations
|
||||||
- `TimeInterval`: `start`, `end`, optional `label`
|
- `TimeInterval`: `start`, `end`, optional `label`
|
||||||
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
|
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
|
||||||
completed-after-shield, and push-before-completion counters. Parent/child
|
completed-after-shield, and push-before-completion counters. Parent/child
|
||||||
|
|
@ -223,6 +229,18 @@ Chunk 13.3 intentionally changed the public API:
|
||||||
- Routed parent/child completion through canonical lifecycle transitions and
|
- Routed parent/child completion through canonical lifecycle transitions and
|
||||||
parent/child completion metadata.
|
parent/child completion metadata.
|
||||||
|
|
||||||
|
Chunk 13.4 intentionally changed the public API:
|
||||||
|
|
||||||
|
- Added `src/project_statistics.dart` and exported it from `scheduler_core.dart`.
|
||||||
|
- Added project-statistics enums, aggregate model, exactly-once aggregation
|
||||||
|
result/service, suggestion policy/result types, and configured-default
|
||||||
|
resolution.
|
||||||
|
- Added `ProjectStatisticsDocumentFields`,
|
||||||
|
`ProjectStatisticsDocumentMapping`, and `ProjectStatisticsDocumentExtension`.
|
||||||
|
- Added project-statistics field sets to the persistence field registry.
|
||||||
|
- Expanded completion activity metadata with duration, reward, difficulty, and
|
||||||
|
reminder-profile observations for project aggregation.
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ Latest Block 13.3 verification result on 2026-06-25:
|
||||||
- `dart test`: passed, 225 tests
|
- `dart test`: passed, 225 tests
|
||||||
- `git diff --check`: passed
|
- `git diff --check`: passed
|
||||||
|
|
||||||
|
Latest Block 13.4 verification result on 2026-06-25:
|
||||||
|
|
||||||
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
|
- `dart analyze`: passed, no issues found
|
||||||
|
- `dart test`: passed, 233 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.
|
||||||
|
|
@ -76,7 +83,7 @@ Status values:
|
||||||
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. |
|
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring 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, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
|
| 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, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
|
||||||
| 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`, `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`, `test/child_tasks_test.dart` | incomplete | Chunks 13.1-13.3 add canonical activities, exactly-once task-stat accounting, and parent/child completion metadata; project aggregates and atomic persistence remain in Blocks 13.4 and 14. |
|
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart`, `test/project_statistics_test.dart` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; atomic persistence remains in Block 14. |
|
||||||
|
|
||||||
## Additional MVP backend requirements
|
## Additional MVP backend requirements
|
||||||
|
|
||||||
|
|
@ -87,10 +94,10 @@ Status values:
|
||||||
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. |
|
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. |
|
||||||
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. |
|
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. |
|
||||||
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers scheduling protection, helpers, explicit required conflicts, and typed outcomes; reminder directives remain in Block 13. |
|
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers scheduling protection, helpers, explicit required conflicts, and typed outcomes; 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-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart` | incomplete | Task override storage and learned suggestions exist; effective reminder resolver and reminder directives remain in Chunk 13.5. |
|
||||||
| 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 | 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-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `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`, `test/project_statistics_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; atomic persistence remains in Block 14. |
|
||||||
| 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
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export 'src/free_slots.dart';
|
||||||
export 'src/locked_time.dart';
|
export 'src/locked_time.dart';
|
||||||
export 'src/occupancy_policy.dart';
|
export 'src/occupancy_policy.dart';
|
||||||
export 'src/persistence_contract.dart';
|
export 'src/persistence_contract.dart';
|
||||||
|
export 'src/project_statistics.dart';
|
||||||
export 'src/quick_capture.dart';
|
export 'src/quick_capture.dart';
|
||||||
export 'src/repositories.dart';
|
export 'src/repositories.dart';
|
||||||
export 'src/scheduling_engine.dart';
|
export 'src/scheduling_engine.dart';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
import 'persistence_contract.dart';
|
import 'persistence_contract.dart';
|
||||||
|
import 'project_statistics.dart';
|
||||||
import 'task_statistics.dart';
|
import 'task_statistics.dart';
|
||||||
|
|
||||||
/// Stable enum encode/decode helpers for document maps.
|
/// Stable enum encode/decode helpers for document maps.
|
||||||
|
|
@ -45,6 +46,16 @@ abstract final class PersistenceEnumMapping {
|
||||||
return _decodeEnum(BacklogTag.values, value, 'BacklogTag');
|
return _decodeEnum(BacklogTag.values, value, 'BacklogTag');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket(
|
||||||
|
String value,
|
||||||
|
) {
|
||||||
|
return _decodeEnum(
|
||||||
|
ProjectCompletionTimeBucket.values,
|
||||||
|
value,
|
||||||
|
'ProjectCompletionTimeBucket',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static T _decodeEnum<T extends Enum>(
|
static T _decodeEnum<T extends Enum>(
|
||||||
Iterable<T> values,
|
Iterable<T> values,
|
||||||
String persistedName,
|
String persistedName,
|
||||||
|
|
@ -255,6 +266,75 @@ abstract final class TaskStatisticsDocumentMapping {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Document mapping for [ProjectStatistics].
|
||||||
|
abstract final class ProjectStatisticsDocumentMapping {
|
||||||
|
/// Convert [statistics] to a MongoDB-friendly document-shaped map.
|
||||||
|
static Map<String, Object?> toDocument(ProjectStatistics statistics) {
|
||||||
|
return {
|
||||||
|
ProjectStatisticsDocumentFields.id: statistics.projectId,
|
||||||
|
ProjectStatisticsDocumentFields.completedTaskCount:
|
||||||
|
statistics.completedTaskCount,
|
||||||
|
ProjectStatisticsDocumentFields.durationMinuteCounts:
|
||||||
|
_intKeyCountMapToDocument(statistics.durationMinuteCounts),
|
||||||
|
ProjectStatisticsDocumentFields.completionTimeBucketCounts:
|
||||||
|
_enumCountMapToDocument(statistics.completionTimeBucketCounts),
|
||||||
|
ProjectStatisticsDocumentFields.totalPushesBeforeCompletion:
|
||||||
|
statistics.totalPushesBeforeCompletion,
|
||||||
|
ProjectStatisticsDocumentFields.completedAfterPushCount:
|
||||||
|
statistics.completedAfterPushCount,
|
||||||
|
ProjectStatisticsDocumentFields.rewardCounts:
|
||||||
|
_enumCountMapToDocument(statistics.rewardCounts),
|
||||||
|
ProjectStatisticsDocumentFields.difficultyCounts:
|
||||||
|
_enumCountMapToDocument(statistics.difficultyCounts),
|
||||||
|
ProjectStatisticsDocumentFields.reminderProfileCounts:
|
||||||
|
_enumCountMapToDocument(statistics.reminderProfileCounts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild [ProjectStatistics] from a document-shaped map.
|
||||||
|
static ProjectStatistics fromDocument(Map<String, Object?> document) {
|
||||||
|
return ProjectStatistics(
|
||||||
|
projectId: _requiredString(document, ProjectStatisticsDocumentFields.id),
|
||||||
|
completedTaskCount: _intOrZero(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.completedTaskCount,
|
||||||
|
),
|
||||||
|
durationMinuteCounts: _intKeyCountMapFromDocument(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.durationMinuteCounts,
|
||||||
|
),
|
||||||
|
completionTimeBucketCounts: _enumCountMapFromDocument(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.completionTimeBucketCounts,
|
||||||
|
PersistenceEnumMapping.decodeProjectCompletionTimeBucket,
|
||||||
|
),
|
||||||
|
totalPushesBeforeCompletion: _intOrZero(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.totalPushesBeforeCompletion,
|
||||||
|
),
|
||||||
|
completedAfterPushCount: _intOrZero(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.completedAfterPushCount,
|
||||||
|
),
|
||||||
|
rewardCounts: _enumCountMapFromDocument(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.rewardCounts,
|
||||||
|
PersistenceEnumMapping.decodeReward,
|
||||||
|
),
|
||||||
|
difficultyCounts: _enumCountMapFromDocument(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.difficultyCounts,
|
||||||
|
PersistenceEnumMapping.decodeDifficulty,
|
||||||
|
),
|
||||||
|
reminderProfileCounts: _enumCountMapFromDocument(
|
||||||
|
document,
|
||||||
|
ProjectStatisticsDocumentFields.reminderProfileCounts,
|
||||||
|
(value) => PersistenceEnumMapping.decodeNullableReminderProfile(value)!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience extension for converting a [Task] into a document map.
|
/// Convenience extension for converting a [Task] into a document map.
|
||||||
extension TaskDocumentExtension on Task {
|
extension TaskDocumentExtension on Task {
|
||||||
/// Convert this task to a MongoDB-friendly document-shaped map.
|
/// Convert this task to a MongoDB-friendly document-shaped map.
|
||||||
|
|
@ -271,6 +351,14 @@ extension TaskStatisticsDocumentExtension on TaskStatistics {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience extension for converting [ProjectStatistics] into a document.
|
||||||
|
extension ProjectStatisticsDocumentExtension on ProjectStatistics {
|
||||||
|
/// Convert this project-statistics value to a MongoDB-friendly document.
|
||||||
|
Map<String, Object?> toDocument() {
|
||||||
|
return ProjectStatisticsDocumentMapping.toDocument(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? _dateTimeToStored(DateTime? value) {
|
String? _dateTimeToStored(DateTime? value) {
|
||||||
return value == null
|
return value == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -312,6 +400,62 @@ int _intOrZero(Map<String, Object?> document, String fieldName) {
|
||||||
throw ArgumentError.value(value, fieldName, 'Expected an int.');
|
throw ArgumentError.value(value, fieldName, 'Expected an int.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _intKeyCountMapToDocument(Map<int, int> counts) {
|
||||||
|
return {
|
||||||
|
for (final entry in counts.entries) entry.key.toString(): entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _enumCountMapToDocument(Map<Enum, int> counts) {
|
||||||
|
return {
|
||||||
|
for (final entry in counts.entries) entry.key.persistenceName: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, int> _intKeyCountMapFromDocument(
|
||||||
|
Map<String, Object?> document,
|
||||||
|
String fieldName,
|
||||||
|
) {
|
||||||
|
final raw = document[fieldName];
|
||||||
|
if (raw == null) {
|
||||||
|
return const <int, int>{};
|
||||||
|
}
|
||||||
|
if (raw is! Map) {
|
||||||
|
throw ArgumentError.value(raw, fieldName, 'Expected a count map.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final entry in raw.entries)
|
||||||
|
int.parse(entry.key as String): _countValue(entry.value, fieldName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<T, int> _enumCountMapFromDocument<T extends Enum>(
|
||||||
|
Map<String, Object?> document,
|
||||||
|
String fieldName,
|
||||||
|
T Function(String value) decode,
|
||||||
|
) {
|
||||||
|
final raw = document[fieldName];
|
||||||
|
if (raw == null) {
|
||||||
|
return <T, int>{};
|
||||||
|
}
|
||||||
|
if (raw is! Map) {
|
||||||
|
throw ArgumentError.value(raw, fieldName, 'Expected a count map.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final entry in raw.entries)
|
||||||
|
decode(entry.key as String): _countValue(entry.value, fieldName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int _countValue(Object? value, String fieldName) {
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw ArgumentError.value(value, fieldName, 'Expected an int count.');
|
||||||
|
}
|
||||||
|
|
||||||
DateTime _requiredDateTime(Map<String, Object?> document, String fieldName) {
|
DateTime _requiredDateTime(Map<String, Object?> document, String fieldName) {
|
||||||
return PersistenceDateTimeConvention.fromStoredString(
|
return PersistenceDateTimeConvention.fromStoredString(
|
||||||
_requiredString(document, fieldName),
|
_requiredString(document, fieldName),
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,31 @@ abstract final class ProjectDocumentFields {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Document field names for [ProjectStatistics] documents.
|
||||||
|
abstract final class ProjectStatisticsDocumentFields {
|
||||||
|
static const id = DocumentFields.id;
|
||||||
|
static const completedTaskCount = 'completedTaskCount';
|
||||||
|
static const durationMinuteCounts = 'durationMinuteCounts';
|
||||||
|
static const completionTimeBucketCounts = 'completionTimeBucketCounts';
|
||||||
|
static const totalPushesBeforeCompletion = 'totalPushesBeforeCompletion';
|
||||||
|
static const completedAfterPushCount = 'completedAfterPushCount';
|
||||||
|
static const rewardCounts = 'rewardCounts';
|
||||||
|
static const difficultyCounts = 'difficultyCounts';
|
||||||
|
static const reminderProfileCounts = 'reminderProfileCounts';
|
||||||
|
|
||||||
|
static const all = <String>{
|
||||||
|
id,
|
||||||
|
completedTaskCount,
|
||||||
|
durationMinuteCounts,
|
||||||
|
completionTimeBucketCounts,
|
||||||
|
totalPushesBeforeCompletion,
|
||||||
|
completedAfterPushCount,
|
||||||
|
rewardCounts,
|
||||||
|
difficultyCounts,
|
||||||
|
reminderProfileCounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Document field names for [ClockTime] embedded documents.
|
/// Document field names for [ClockTime] embedded documents.
|
||||||
abstract final class ClockTimeDocumentFields {
|
abstract final class ClockTimeDocumentFields {
|
||||||
static const hour = 'hour';
|
static const hour = 'hour';
|
||||||
|
|
@ -377,6 +402,7 @@ abstract final class PersistenceDocumentFieldSets {
|
||||||
TaskActivityDocumentFields.all,
|
TaskActivityDocumentFields.all,
|
||||||
TaskStatisticsDocumentFields.all,
|
TaskStatisticsDocumentFields.all,
|
||||||
ProjectDocumentFields.all,
|
ProjectDocumentFields.all,
|
||||||
|
ProjectStatisticsDocumentFields.all,
|
||||||
ClockTimeDocumentFields.all,
|
ClockTimeDocumentFields.all,
|
||||||
LockedBlockRecurrenceDocumentFields.all,
|
LockedBlockRecurrenceDocumentFields.all,
|
||||||
LockedBlockDocumentFields.all,
|
LockedBlockDocumentFields.all,
|
||||||
|
|
|
||||||
697
lib/src/project_statistics.dart
Normal file
697
lib/src/project_statistics.dart
Normal file
|
|
@ -0,0 +1,697 @@
|
||||||
|
// Project-level aggregate statistics and deterministic learned suggestions.
|
||||||
|
//
|
||||||
|
// These types stay in the pure Dart core. They provide persistence-friendly
|
||||||
|
// counters and optional suggestions, but they do not overwrite configured
|
||||||
|
// project defaults or implement reports UI.
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'task_lifecycle.dart';
|
||||||
|
|
||||||
|
/// Coarse completion-time buckets used for learned project suggestions.
|
||||||
|
enum ProjectCompletionTimeBucket {
|
||||||
|
overnight,
|
||||||
|
morning,
|
||||||
|
afternoon,
|
||||||
|
evening,
|
||||||
|
night,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suggestion categories derived from project observations.
|
||||||
|
enum ProjectSuggestionType {
|
||||||
|
durationMinutes,
|
||||||
|
completionTimeBucket,
|
||||||
|
reward,
|
||||||
|
difficulty,
|
||||||
|
reminderProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How concentrated the supporting observations are for a suggestion.
|
||||||
|
enum ProjectSuggestionConfidence {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable project-level observations used for future filtering and hints.
|
||||||
|
class ProjectStatistics {
|
||||||
|
ProjectStatistics({
|
||||||
|
required String projectId,
|
||||||
|
this.completedTaskCount = 0,
|
||||||
|
Map<int, int> durationMinuteCounts = const <int, int>{},
|
||||||
|
Map<ProjectCompletionTimeBucket, int> completionTimeBucketCounts =
|
||||||
|
const <ProjectCompletionTimeBucket, int>{},
|
||||||
|
this.totalPushesBeforeCompletion = 0,
|
||||||
|
this.completedAfterPushCount = 0,
|
||||||
|
Map<RewardLevel, int> rewardCounts = const <RewardLevel, int>{},
|
||||||
|
Map<DifficultyLevel, int> difficultyCounts = const <DifficultyLevel, int>{},
|
||||||
|
Map<ReminderProfile, int> reminderProfileCounts =
|
||||||
|
const <ReminderProfile, int>{},
|
||||||
|
}) : projectId = _requiredTrimmed(projectId, 'projectId'),
|
||||||
|
durationMinuteCounts = Map<int, int>.unmodifiable(
|
||||||
|
_positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'),
|
||||||
|
),
|
||||||
|
completionTimeBucketCounts =
|
||||||
|
Map<ProjectCompletionTimeBucket, int>.unmodifiable(
|
||||||
|
_enumCounts(
|
||||||
|
completionTimeBucketCounts,
|
||||||
|
'completionTimeBucketCounts',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rewardCounts = Map<RewardLevel, int>.unmodifiable(
|
||||||
|
_enumCounts(rewardCounts, 'rewardCounts'),
|
||||||
|
),
|
||||||
|
difficultyCounts = Map<DifficultyLevel, int>.unmodifiable(
|
||||||
|
_enumCounts(difficultyCounts, 'difficultyCounts'),
|
||||||
|
),
|
||||||
|
reminderProfileCounts = Map<ReminderProfile, int>.unmodifiable(
|
||||||
|
_enumCounts(reminderProfileCounts, 'reminderProfileCounts'),
|
||||||
|
) {
|
||||||
|
_validateNonNegative(completedTaskCount, 'completedTaskCount');
|
||||||
|
_validateNonNegative(
|
||||||
|
totalPushesBeforeCompletion,
|
||||||
|
'totalPushesBeforeCompletion',
|
||||||
|
);
|
||||||
|
_validateNonNegative(completedAfterPushCount, 'completedAfterPushCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project these observations belong to.
|
||||||
|
final String projectId;
|
||||||
|
|
||||||
|
/// Completion activities applied to this project aggregate.
|
||||||
|
final int completedTaskCount;
|
||||||
|
|
||||||
|
/// Duration samples stored as minute-to-count buckets.
|
||||||
|
final Map<int, int> durationMinuteCounts;
|
||||||
|
|
||||||
|
/// Completion timestamp samples stored as coarse bucket counts.
|
||||||
|
final Map<ProjectCompletionTimeBucket, int> completionTimeBucketCounts;
|
||||||
|
|
||||||
|
/// Sum of push counts present when tasks were completed.
|
||||||
|
final int totalPushesBeforeCompletion;
|
||||||
|
|
||||||
|
/// Number of completed tasks that had at least one prior push.
|
||||||
|
final int completedAfterPushCount;
|
||||||
|
|
||||||
|
/// Reward observations from completed tasks.
|
||||||
|
final Map<RewardLevel, int> rewardCounts;
|
||||||
|
|
||||||
|
/// Difficulty observations from completed tasks.
|
||||||
|
final Map<DifficultyLevel, int> difficultyCounts;
|
||||||
|
|
||||||
|
/// Reminder-profile observations when explicitly known.
|
||||||
|
final Map<ReminderProfile, int> reminderProfileCounts;
|
||||||
|
|
||||||
|
/// Known duration sample count derived from the duration distribution.
|
||||||
|
int get knownDurationSampleCount => _countTotal(durationMinuteCounts);
|
||||||
|
|
||||||
|
/// Sum of known duration samples in minutes.
|
||||||
|
int get totalKnownDurationMinutes {
|
||||||
|
var total = 0;
|
||||||
|
for (final entry in durationMinuteCounts.entries) {
|
||||||
|
total += entry.key * entry.value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numerator for average pushes before completion.
|
||||||
|
int get averagePushesBeforeCompletionNumerator {
|
||||||
|
return totalPushesBeforeCompletion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Denominator for average pushes before completion.
|
||||||
|
int get averagePushesBeforeCompletionDenominator {
|
||||||
|
return completedTaskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derived average push count, without storing floating-point aggregate state.
|
||||||
|
double? get averagePushesBeforeCompletion {
|
||||||
|
if (completedTaskCount == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return totalPushesBeforeCompletion / completedTaskCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a copy with selected aggregate fields changed.
|
||||||
|
ProjectStatistics copyWith({
|
||||||
|
String? projectId,
|
||||||
|
int? completedTaskCount,
|
||||||
|
Map<int, int>? durationMinuteCounts,
|
||||||
|
Map<ProjectCompletionTimeBucket, int>? completionTimeBucketCounts,
|
||||||
|
int? totalPushesBeforeCompletion,
|
||||||
|
int? completedAfterPushCount,
|
||||||
|
Map<RewardLevel, int>? rewardCounts,
|
||||||
|
Map<DifficultyLevel, int>? difficultyCounts,
|
||||||
|
Map<ReminderProfile, int>? reminderProfileCounts,
|
||||||
|
}) {
|
||||||
|
return ProjectStatistics(
|
||||||
|
projectId: projectId ?? this.projectId,
|
||||||
|
completedTaskCount: completedTaskCount ?? this.completedTaskCount,
|
||||||
|
durationMinuteCounts: durationMinuteCounts ?? this.durationMinuteCounts,
|
||||||
|
completionTimeBucketCounts:
|
||||||
|
completionTimeBucketCounts ?? this.completionTimeBucketCounts,
|
||||||
|
totalPushesBeforeCompletion:
|
||||||
|
totalPushesBeforeCompletion ?? this.totalPushesBeforeCompletion,
|
||||||
|
completedAfterPushCount:
|
||||||
|
completedAfterPushCount ?? this.completedAfterPushCount,
|
||||||
|
rewardCounts: rewardCounts ?? this.rewardCounts,
|
||||||
|
difficultyCounts: difficultyCounts ?? this.difficultyCounts,
|
||||||
|
reminderProfileCounts:
|
||||||
|
reminderProfileCounts ?? this.reminderProfileCounts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one completed task observation.
|
||||||
|
ProjectStatistics recordCompletion({
|
||||||
|
required DateTime completedAt,
|
||||||
|
int? durationMinutes,
|
||||||
|
int pushesBeforeCompletion = 0,
|
||||||
|
RewardLevel? reward,
|
||||||
|
DifficultyLevel? difficulty,
|
||||||
|
ReminderProfile? reminderProfile,
|
||||||
|
}) {
|
||||||
|
if (durationMinutes != null && durationMinutes <= 0) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
durationMinutes,
|
||||||
|
'durationMinutes',
|
||||||
|
'Duration minutes must be positive when present.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pushesBeforeCompletion < 0) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
pushesBeforeCompletion,
|
||||||
|
'pushesBeforeCompletion',
|
||||||
|
'Push count cannot be negative.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyWith(
|
||||||
|
completedTaskCount: completedTaskCount + 1,
|
||||||
|
durationMinuteCounts: durationMinutes == null
|
||||||
|
? durationMinuteCounts
|
||||||
|
: _incrementCount(durationMinuteCounts, durationMinutes),
|
||||||
|
completionTimeBucketCounts: _incrementCount(
|
||||||
|
completionTimeBucketCounts,
|
||||||
|
bucketForCompletion(completedAt),
|
||||||
|
),
|
||||||
|
totalPushesBeforeCompletion:
|
||||||
|
totalPushesBeforeCompletion + pushesBeforeCompletion,
|
||||||
|
completedAfterPushCount: pushesBeforeCompletion > 0
|
||||||
|
? completedAfterPushCount + 1
|
||||||
|
: completedAfterPushCount,
|
||||||
|
rewardCounts: reward == null
|
||||||
|
? rewardCounts
|
||||||
|
: _incrementCount(
|
||||||
|
rewardCounts,
|
||||||
|
reward,
|
||||||
|
),
|
||||||
|
difficultyCounts: difficulty == null
|
||||||
|
? difficultyCounts
|
||||||
|
: _incrementCount(
|
||||||
|
difficultyCounts,
|
||||||
|
difficulty,
|
||||||
|
),
|
||||||
|
reminderProfileCounts: reminderProfile == null
|
||||||
|
? reminderProfileCounts
|
||||||
|
: _incrementCount(
|
||||||
|
reminderProfileCounts,
|
||||||
|
reminderProfile,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the completion-time bucket for [completedAt].
|
||||||
|
static ProjectCompletionTimeBucket bucketForCompletion(DateTime completedAt) {
|
||||||
|
final hour = completedAt.hour;
|
||||||
|
if (hour < 5) {
|
||||||
|
return ProjectCompletionTimeBucket.overnight;
|
||||||
|
}
|
||||||
|
if (hour < 12) {
|
||||||
|
return ProjectCompletionTimeBucket.morning;
|
||||||
|
}
|
||||||
|
if (hour < 17) {
|
||||||
|
return ProjectCompletionTimeBucket.afternoon;
|
||||||
|
}
|
||||||
|
if (hour < 21) {
|
||||||
|
return ProjectCompletionTimeBucket.evening;
|
||||||
|
}
|
||||||
|
return ProjectCompletionTimeBucket.night;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of applying project-statistic effects from activities.
|
||||||
|
class ProjectStatisticsApplicationResult {
|
||||||
|
ProjectStatisticsApplicationResult({
|
||||||
|
required this.statistics,
|
||||||
|
List<String> appliedActivityIds = const <String>[],
|
||||||
|
}) : appliedActivityIds = List<String>.unmodifiable(appliedActivityIds);
|
||||||
|
|
||||||
|
/// Aggregate after newly applied activity effects.
|
||||||
|
final ProjectStatistics statistics;
|
||||||
|
|
||||||
|
/// Completion activity ids that changed the aggregate.
|
||||||
|
final List<String> appliedActivityIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates project statistics from canonical task activities exactly once.
|
||||||
|
class ProjectStatisticsAggregationService {
|
||||||
|
const ProjectStatisticsAggregationService();
|
||||||
|
|
||||||
|
/// Apply completion [activities] to [statistics].
|
||||||
|
///
|
||||||
|
/// [alreadyAppliedActivityIds] is supplied by the application/persistence
|
||||||
|
/// boundary. This service returns the new ids so Block 14 can persist task,
|
||||||
|
/// activity, and aggregate mutations atomically.
|
||||||
|
ProjectStatisticsApplicationResult apply({
|
||||||
|
required ProjectStatistics statistics,
|
||||||
|
required Iterable<TaskActivity> activities,
|
||||||
|
Iterable<Task> tasks = const <Task>[],
|
||||||
|
Iterable<String> alreadyAppliedActivityIds = const <String>[],
|
||||||
|
}) {
|
||||||
|
final taskById = {
|
||||||
|
for (final task in tasks) task.id: task,
|
||||||
|
};
|
||||||
|
final appliedIds = alreadyAppliedActivityIds.toSet();
|
||||||
|
final newlyAppliedIds = <String>[];
|
||||||
|
var current = statistics;
|
||||||
|
|
||||||
|
for (final activity in activities) {
|
||||||
|
if (activity.projectId != statistics.projectId ||
|
||||||
|
activity.code != TaskActivityCode.completed ||
|
||||||
|
appliedIds.contains(activity.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final task = taskById[activity.taskId];
|
||||||
|
final completedAt = _dateTimeMetadata(activity.metadata['completedAt']) ??
|
||||||
|
activity.occurredAt;
|
||||||
|
final pushesBeforeCompletion =
|
||||||
|
_intMetadata(activity.metadata['pushesBeforeCompletion']) ??
|
||||||
|
_pushesBeforeCompletion(task);
|
||||||
|
|
||||||
|
current = current.recordCompletion(
|
||||||
|
completedAt: completedAt,
|
||||||
|
durationMinutes: _durationMinutes(activity, task),
|
||||||
|
pushesBeforeCompletion: pushesBeforeCompletion,
|
||||||
|
reward: _rewardLevel(activity.metadata['reward']) ?? task?.reward,
|
||||||
|
difficulty: _difficultyLevel(activity.metadata['difficulty']) ??
|
||||||
|
task?.difficulty,
|
||||||
|
reminderProfile:
|
||||||
|
_reminderProfile(activity.metadata['reminderProfile']) ??
|
||||||
|
task?.reminderOverride,
|
||||||
|
);
|
||||||
|
appliedIds.add(activity.id);
|
||||||
|
newlyAppliedIds.add(activity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProjectStatisticsApplicationResult(
|
||||||
|
statistics: current,
|
||||||
|
appliedActivityIds: newlyAppliedIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum sample sizes for deterministic project suggestions.
|
||||||
|
class ProjectSuggestionPolicy {
|
||||||
|
const ProjectSuggestionPolicy({
|
||||||
|
this.minimumDurationSamples = 3,
|
||||||
|
this.minimumCompletionTimeSamples = 3,
|
||||||
|
this.minimumRewardSamples = 3,
|
||||||
|
this.minimumDifficultySamples = 3,
|
||||||
|
this.minimumReminderSamples = 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int minimumDurationSamples;
|
||||||
|
final int minimumCompletionTimeSamples;
|
||||||
|
final int minimumRewardSamples;
|
||||||
|
final int minimumDifficultySamples;
|
||||||
|
final int minimumReminderSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One optional learned suggestion with provenance.
|
||||||
|
class ProjectSuggestion<T extends Object> {
|
||||||
|
const ProjectSuggestion({
|
||||||
|
required this.type,
|
||||||
|
required this.value,
|
||||||
|
required this.sampleSize,
|
||||||
|
required this.supportingSampleCount,
|
||||||
|
required this.minimumSampleSize,
|
||||||
|
required this.confidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Suggestion category.
|
||||||
|
final ProjectSuggestionType type;
|
||||||
|
|
||||||
|
/// Suggested value.
|
||||||
|
final T value;
|
||||||
|
|
||||||
|
/// Number of meaningful observations considered.
|
||||||
|
final int sampleSize;
|
||||||
|
|
||||||
|
/// Observations that support [value].
|
||||||
|
final int supportingSampleCount;
|
||||||
|
|
||||||
|
/// Minimum sample threshold used before producing the suggestion.
|
||||||
|
final int minimumSampleSize;
|
||||||
|
|
||||||
|
/// Concentration of supporting observations.
|
||||||
|
final ProjectSuggestionConfidence confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional suggestion set derived from one project's observations.
|
||||||
|
class ProjectSuggestionSet {
|
||||||
|
const ProjectSuggestionSet({
|
||||||
|
required this.statistics,
|
||||||
|
this.durationMinutes,
|
||||||
|
this.completionTimeBucket,
|
||||||
|
this.reward,
|
||||||
|
this.difficulty,
|
||||||
|
this.reminderProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectStatistics statistics;
|
||||||
|
final ProjectSuggestion<int>? durationMinutes;
|
||||||
|
final ProjectSuggestion<ProjectCompletionTimeBucket>? completionTimeBucket;
|
||||||
|
final ProjectSuggestion<RewardLevel>? reward;
|
||||||
|
final ProjectSuggestion<DifficultyLevel>? difficulty;
|
||||||
|
final ProjectSuggestion<ReminderProfile>? reminderProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configured project defaults plus optional suggestions kept separate.
|
||||||
|
class ProjectDefaultResolution {
|
||||||
|
const ProjectDefaultResolution({
|
||||||
|
required this.project,
|
||||||
|
required this.suggestions,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Authoritative configured project profile.
|
||||||
|
final ProjectProfile project;
|
||||||
|
|
||||||
|
/// Optional learned suggestions that UI can present explicitly.
|
||||||
|
final ProjectSuggestionSet suggestions;
|
||||||
|
|
||||||
|
int? get configuredDurationMinutes => project.defaultDurationMinutes;
|
||||||
|
RewardLevel get configuredReward => project.defaultReward;
|
||||||
|
DifficultyLevel get configuredDifficulty => project.defaultDifficulty;
|
||||||
|
ReminderProfile get configuredReminderProfile =>
|
||||||
|
project.defaultReminderProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives deterministic project suggestions from aggregate observations.
|
||||||
|
class ProjectSuggestionService {
|
||||||
|
const ProjectSuggestionService({
|
||||||
|
this.policy = const ProjectSuggestionPolicy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectSuggestionPolicy policy;
|
||||||
|
|
||||||
|
/// Build optional suggestions for [statistics].
|
||||||
|
ProjectSuggestionSet suggestionsFor(ProjectStatistics statistics) {
|
||||||
|
return ProjectSuggestionSet(
|
||||||
|
statistics: statistics,
|
||||||
|
durationMinutes: _durationSuggestion(statistics),
|
||||||
|
completionTimeBucket: _modeSuggestion(
|
||||||
|
type: ProjectSuggestionType.completionTimeBucket,
|
||||||
|
counts: statistics.completionTimeBucketCounts,
|
||||||
|
minimumSampleSize: policy.minimumCompletionTimeSamples,
|
||||||
|
),
|
||||||
|
reward: _modeSuggestion(
|
||||||
|
type: ProjectSuggestionType.reward,
|
||||||
|
counts: _withoutNotSetReward(statistics.rewardCounts),
|
||||||
|
minimumSampleSize: policy.minimumRewardSamples,
|
||||||
|
),
|
||||||
|
difficulty: _modeSuggestion(
|
||||||
|
type: ProjectSuggestionType.difficulty,
|
||||||
|
counts: _withoutNotSetDifficulty(statistics.difficultyCounts),
|
||||||
|
minimumSampleSize: policy.minimumDifficultySamples,
|
||||||
|
),
|
||||||
|
reminderProfile: _modeSuggestion(
|
||||||
|
type: ProjectSuggestionType.reminderProfile,
|
||||||
|
counts: statistics.reminderProfileCounts,
|
||||||
|
minimumSampleSize: policy.minimumReminderSamples,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return configured defaults and optional suggestions without merging them.
|
||||||
|
ProjectDefaultResolution resolveDefaults({
|
||||||
|
required ProjectProfile project,
|
||||||
|
required ProjectStatistics statistics,
|
||||||
|
}) {
|
||||||
|
return ProjectDefaultResolution(
|
||||||
|
project: project,
|
||||||
|
suggestions: suggestionsFor(statistics),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectSuggestion<int>? _durationSuggestion(ProjectStatistics statistics) {
|
||||||
|
final sampleSize = statistics.knownDurationSampleCount;
|
||||||
|
if (sampleSize < policy.minimumDurationSamples) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = _weightedLowerMedian(statistics.durationMinuteCounts);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProjectSuggestion<int>(
|
||||||
|
type: ProjectSuggestionType.durationMinutes,
|
||||||
|
value: value,
|
||||||
|
sampleSize: sampleSize,
|
||||||
|
supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0,
|
||||||
|
minimumSampleSize: policy.minimumDurationSamples,
|
||||||
|
confidence: _confidence(
|
||||||
|
statistics.durationMinuteCounts[value] ?? 0,
|
||||||
|
sampleSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectSuggestion<T>? _modeSuggestion<T extends Object>({
|
||||||
|
required ProjectSuggestionType type,
|
||||||
|
required Map<T, int> counts,
|
||||||
|
required int minimumSampleSize,
|
||||||
|
}) {
|
||||||
|
final sampleSize = _countTotal(counts);
|
||||||
|
if (sampleSize < minimumSampleSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mode = _uniqueMode(counts);
|
||||||
|
if (mode == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final support = counts[mode] ?? 0;
|
||||||
|
return ProjectSuggestion<T>(
|
||||||
|
type: type,
|
||||||
|
value: mode,
|
||||||
|
sampleSize: sampleSize,
|
||||||
|
supportingSampleCount: support,
|
||||||
|
minimumSampleSize: minimumSampleSize,
|
||||||
|
confidence: _confidence(support, sampleSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<K, int> _incrementCount<K extends Object>(Map<K, int> counts, K key) {
|
||||||
|
return {
|
||||||
|
...counts,
|
||||||
|
key: (counts[key] ?? 0) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, int> _positiveIntKeyCounts(Map<int, int> counts, String name) {
|
||||||
|
final normalized = <int, int>{};
|
||||||
|
for (final entry in counts.entries) {
|
||||||
|
if (entry.key <= 0) {
|
||||||
|
throw ArgumentError.value(entry.key, name, 'Key must be positive.');
|
||||||
|
}
|
||||||
|
_validateNonNegative(entry.value, name);
|
||||||
|
if (entry.value > 0) {
|
||||||
|
normalized[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<T, int> _enumCounts<T extends Enum>(Map<T, int> counts, String name) {
|
||||||
|
final normalized = <T, int>{};
|
||||||
|
for (final entry in counts.entries) {
|
||||||
|
_validateNonNegative(entry.value, name);
|
||||||
|
if (entry.value > 0) {
|
||||||
|
normalized[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateNonNegative(int value, String name) {
|
||||||
|
if (value < 0) {
|
||||||
|
throw ArgumentError.value(value, name, 'Count cannot be negative.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _countTotal<K extends Object>(Map<K, int> counts) {
|
||||||
|
var total = 0;
|
||||||
|
for (final value in counts.values) {
|
||||||
|
total += value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _weightedLowerMedian(Map<int, int> counts) {
|
||||||
|
if (counts.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final sampleSize = _countTotal(counts);
|
||||||
|
final target = (sampleSize + 1) ~/ 2;
|
||||||
|
var seen = 0;
|
||||||
|
final sortedMinutes = counts.keys.toList()..sort();
|
||||||
|
for (final minutes in sortedMinutes) {
|
||||||
|
seen += counts[minutes] ?? 0;
|
||||||
|
if (seen >= target) {
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
T? _uniqueMode<T extends Object>(Map<T, int> counts) {
|
||||||
|
T? bestKey;
|
||||||
|
var bestCount = 0;
|
||||||
|
var tied = false;
|
||||||
|
|
||||||
|
for (final entry in counts.entries) {
|
||||||
|
if (entry.value > bestCount) {
|
||||||
|
bestKey = entry.key;
|
||||||
|
bestCount = entry.value;
|
||||||
|
tied = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.value == bestCount && bestCount > 0) {
|
||||||
|
tied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tied ? null : bestKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectSuggestionConfidence _confidence(int support, int sampleSize) {
|
||||||
|
if (sampleSize <= 0) {
|
||||||
|
return ProjectSuggestionConfidence.low;
|
||||||
|
}
|
||||||
|
if (support * 3 >= sampleSize * 2) {
|
||||||
|
return ProjectSuggestionConfidence.high;
|
||||||
|
}
|
||||||
|
if (support * 2 >= sampleSize) {
|
||||||
|
return ProjectSuggestionConfidence.medium;
|
||||||
|
}
|
||||||
|
return ProjectSuggestionConfidence.low;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<RewardLevel, int> _withoutNotSetReward(Map<RewardLevel, int> counts) {
|
||||||
|
return {
|
||||||
|
for (final entry in counts.entries)
|
||||||
|
if (entry.key != RewardLevel.notSet) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<DifficultyLevel, int> _withoutNotSetDifficulty(
|
||||||
|
Map<DifficultyLevel, int> counts,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
for (final entry in counts.entries)
|
||||||
|
if (entry.key != DifficultyLevel.notSet) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateTimeMetadata(Object? value) {
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return DateTime.tryParse(value)?.toUtc();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _intMetadata(Object? value) {
|
||||||
|
return value is int && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _durationMinutes(TaskActivity activity, Task? task) {
|
||||||
|
final metadataDuration = _intMetadata(activity.metadata['durationMinutes']);
|
||||||
|
if (metadataDuration != null && metadataDuration > 0) {
|
||||||
|
return metadataDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
final actualStart =
|
||||||
|
_dateTimeMetadata(activity.metadata['actualStart']) ?? task?.actualStart;
|
||||||
|
final actualEnd =
|
||||||
|
_dateTimeMetadata(activity.metadata['actualEnd']) ?? task?.actualEnd;
|
||||||
|
if (actualStart != null &&
|
||||||
|
actualEnd != null &&
|
||||||
|
actualStart.isBefore(actualEnd)) {
|
||||||
|
final minutes = actualEnd.difference(actualStart).inMinutes;
|
||||||
|
if (minutes > 0) {
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return task?.durationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _pushesBeforeCompletion(Task? task) {
|
||||||
|
if (task == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
RewardLevel? _rewardLevel(Object? value) {
|
||||||
|
if (value is RewardLevel) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return _enumByName(RewardLevel.values, value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DifficultyLevel? _difficultyLevel(Object? value) {
|
||||||
|
if (value is DifficultyLevel) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return _enumByName(DifficultyLevel.values, value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReminderProfile? _reminderProfile(Object? value) {
|
||||||
|
if (value is ReminderProfile) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return _enumByName(ReminderProfile.values, value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
T? _enumByName<T extends Enum>(Iterable<T> values, String name) {
|
||||||
|
for (final value in values) {
|
||||||
|
if (value.name == name) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredTrimmed(String value, String name) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw ArgumentError.value(value, name, 'Value is required.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
@ -932,6 +932,11 @@ TaskActivity _surpriseCompletedActivity({
|
||||||
'completedAt': occurredAt,
|
'completedAt': occurredAt,
|
||||||
'actualStart': task.actualStart,
|
'actualStart': task.actualStart,
|
||||||
'actualEnd': task.actualEnd,
|
'actualEnd': task.actualEnd,
|
||||||
|
'durationMinutes': task.durationMinutes,
|
||||||
|
'reward': task.reward.name,
|
||||||
|
'difficulty': task.difficulty.name,
|
||||||
|
if (task.reminderOverride != null)
|
||||||
|
'reminderProfile': task.reminderOverride!.name,
|
||||||
'pushesBeforeCompletion': 0,
|
'pushesBeforeCompletion': 0,
|
||||||
'source': 'surpriseLog',
|
'source': 'surpriseLog',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -355,8 +355,17 @@ class TaskTransitionService {
|
||||||
...metadata,
|
...metadata,
|
||||||
'completedAt': occurredAt,
|
'completedAt': occurredAt,
|
||||||
'scheduledEnd': task.scheduledEnd,
|
'scheduledEnd': task.scheduledEnd,
|
||||||
'actualStart': actualStart,
|
'actualStart': completed.actualStart,
|
||||||
'actualEnd': actualEnd,
|
'actualEnd': completed.actualEnd,
|
||||||
|
'durationMinutes': _knownDurationMinutes(
|
||||||
|
completed,
|
||||||
|
completed.actualStart,
|
||||||
|
completed.actualEnd,
|
||||||
|
),
|
||||||
|
'reward': task.reward.name,
|
||||||
|
'difficulty': task.difficulty.name,
|
||||||
|
if (task.reminderOverride != null)
|
||||||
|
'reminderProfile': task.reminderOverride!.name,
|
||||||
'pushesBeforeCompletion': _pushesBeforeCompletion(task),
|
'pushesBeforeCompletion': _pushesBeforeCompletion(task),
|
||||||
},
|
},
|
||||||
nextStatus: completed.status,
|
nextStatus: completed.status,
|
||||||
|
|
@ -808,6 +817,23 @@ int _pushesBeforeCompletion(Task task) {
|
||||||
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _knownDurationMinutes(
|
||||||
|
Task task,
|
||||||
|
DateTime? actualStart,
|
||||||
|
DateTime? actualEnd,
|
||||||
|
) {
|
||||||
|
if (actualStart != null &&
|
||||||
|
actualEnd != null &&
|
||||||
|
actualStart.isBefore(actualEnd)) {
|
||||||
|
final actualMinutes = actualEnd.difference(actualStart).inMinutes;
|
||||||
|
if (actualMinutes > 0) {
|
||||||
|
return actualMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.durationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
int? _intMetadata(Object? value) {
|
int? _intMetadata(Object? value) {
|
||||||
return value is int ? value : null;
|
return value is int ? value : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,52 @@ void main() {
|
||||||
expect(restored.totalPushesBeforeCompletion, 13);
|
expect(restored.totalPushesBeforeCompletion, 13);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('project statistics document mapping preserves aggregate fields', () {
|
||||||
|
final statistics = ProjectStatistics(
|
||||||
|
projectId: 'home',
|
||||||
|
completedTaskCount: 8,
|
||||||
|
durationMinuteCounts: const {20: 3, 45: 5},
|
||||||
|
completionTimeBucketCounts: const {
|
||||||
|
ProjectCompletionTimeBucket.morning: 6,
|
||||||
|
ProjectCompletionTimeBucket.evening: 2,
|
||||||
|
},
|
||||||
|
totalPushesBeforeCompletion: 11,
|
||||||
|
completedAfterPushCount: 4,
|
||||||
|
rewardCounts: const {
|
||||||
|
RewardLevel.high: 5,
|
||||||
|
RewardLevel.low: 3,
|
||||||
|
},
|
||||||
|
difficultyCounts: const {
|
||||||
|
DifficultyLevel.easy: 2,
|
||||||
|
DifficultyLevel.hard: 6,
|
||||||
|
},
|
||||||
|
reminderProfileCounts: const {
|
||||||
|
ReminderProfile.gentle: 3,
|
||||||
|
ReminderProfile.persistent: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final document = statistics.toDocument();
|
||||||
|
final restored = ProjectStatisticsDocumentMapping.fromDocument(document);
|
||||||
|
|
||||||
|
expect(document.keys.toSet(), ProjectStatisticsDocumentFields.all);
|
||||||
|
expect(document[ProjectStatisticsDocumentFields.id], 'home');
|
||||||
|
expect(document[ProjectStatisticsDocumentFields.durationMinuteCounts], {
|
||||||
|
'20': 3,
|
||||||
|
'45': 5,
|
||||||
|
});
|
||||||
|
expect(restored.projectId, statistics.projectId);
|
||||||
|
expect(restored.completedTaskCount, 8);
|
||||||
|
expect(restored.durationMinuteCounts, statistics.durationMinuteCounts);
|
||||||
|
expect(restored.completionTimeBucketCounts,
|
||||||
|
statistics.completionTimeBucketCounts);
|
||||||
|
expect(restored.totalPushesBeforeCompletion, 11);
|
||||||
|
expect(restored.completedAfterPushCount, 4);
|
||||||
|
expect(restored.rewardCounts, statistics.rewardCounts);
|
||||||
|
expect(restored.difficultyCounts, statistics.difficultyCounts);
|
||||||
|
expect(restored.reminderProfileCounts, statistics.reminderProfileCounts);
|
||||||
|
});
|
||||||
|
|
||||||
test('enum document mapping rejects unknown persistence names', () {
|
test('enum document mapping rejects unknown persistence names', () {
|
||||||
expect(
|
expect(
|
||||||
() => PersistenceEnumMapping.decodeTaskType('unknown'),
|
() => PersistenceEnumMapping.decodeTaskType('unknown'),
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,17 @@ void main() {
|
||||||
|
|
||||||
test('document field sets cover project locked and snapshot documents', () {
|
test('document field sets cover project locked and snapshot documents', () {
|
||||||
expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey']));
|
expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey']));
|
||||||
|
expect(ProjectStatisticsDocumentFields.all, {
|
||||||
|
'_id',
|
||||||
|
'completedTaskCount',
|
||||||
|
'durationMinuteCounts',
|
||||||
|
'completionTimeBucketCounts',
|
||||||
|
'totalPushesBeforeCompletion',
|
||||||
|
'completedAfterPushCount',
|
||||||
|
'rewardCounts',
|
||||||
|
'difficultyCounts',
|
||||||
|
'reminderProfileCounts',
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
LockedBlockDocumentFields.all,
|
LockedBlockDocumentFields.all,
|
||||||
containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']),
|
containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']),
|
||||||
|
|
|
||||||
310
test/project_statistics_test.dart
Normal file
310
test/project_statistics_test.dart
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Project statistics aggregation', () {
|
||||||
|
final createdAt = DateTime.utc(2026, 6, 25, 8);
|
||||||
|
const transitionService = TaskTransitionService();
|
||||||
|
const aggregationService = ProjectStatisticsAggregationService();
|
||||||
|
|
||||||
|
test('applies completed task activities exactly once', () {
|
||||||
|
final task = scheduledTask(
|
||||||
|
id: 'task-1',
|
||||||
|
projectId: 'home',
|
||||||
|
createdAt: createdAt,
|
||||||
|
start: DateTime.utc(2026, 6, 25, 9),
|
||||||
|
reward: RewardLevel.high,
|
||||||
|
difficulty: DifficultyLevel.hard,
|
||||||
|
reminderOverride: ReminderProfile.strict,
|
||||||
|
).copyWith(
|
||||||
|
stats: const TaskStatistics(
|
||||||
|
manuallyPushedCount: 1,
|
||||||
|
autoPushedCount: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final transition = transitionService.apply(
|
||||||
|
task: task,
|
||||||
|
transitionCode: TaskTransitionCode.complete,
|
||||||
|
operationId: 'complete-task-1',
|
||||||
|
activityId: 'activity-1',
|
||||||
|
occurredAt: DateTime.utc(2026, 6, 25, 10),
|
||||||
|
actualStart: DateTime.utc(2026, 6, 25, 9, 5),
|
||||||
|
actualEnd: DateTime.utc(2026, 6, 25, 9, 25),
|
||||||
|
);
|
||||||
|
|
||||||
|
final first = aggregationService.apply(
|
||||||
|
statistics: ProjectStatistics(projectId: 'home'),
|
||||||
|
activities: transition.activities,
|
||||||
|
tasks: [transition.task],
|
||||||
|
);
|
||||||
|
final retry = aggregationService.apply(
|
||||||
|
statistics: first.statistics,
|
||||||
|
activities: transition.activities,
|
||||||
|
tasks: [transition.task],
|
||||||
|
alreadyAppliedActivityIds: first.appliedActivityIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.appliedActivityIds, ['activity-1']);
|
||||||
|
expect(first.statistics.completedTaskCount, 1);
|
||||||
|
expect(first.statistics.durationMinuteCounts, {20: 1});
|
||||||
|
expect(first.statistics.completionTimeBucketCounts, {
|
||||||
|
ProjectCompletionTimeBucket.morning: 1,
|
||||||
|
});
|
||||||
|
expect(first.statistics.totalPushesBeforeCompletion, 3);
|
||||||
|
expect(first.statistics.completedAfterPushCount, 1);
|
||||||
|
expect(first.statistics.rewardCounts, {RewardLevel.high: 1});
|
||||||
|
expect(first.statistics.difficultyCounts, {DifficultyLevel.hard: 1});
|
||||||
|
expect(first.statistics.reminderProfileCounts, {
|
||||||
|
ReminderProfile.strict: 1,
|
||||||
|
});
|
||||||
|
expect(first.statistics.averagePushesBeforeCompletionNumerator, 3);
|
||||||
|
expect(first.statistics.averagePushesBeforeCompletionDenominator, 1);
|
||||||
|
expect(first.statistics.averagePushesBeforeCompletion, 3);
|
||||||
|
|
||||||
|
expect(retry.appliedActivityIds, isEmpty);
|
||||||
|
expect(retry.statistics.completedTaskCount, 1);
|
||||||
|
expect(retry.statistics.totalPushesBeforeCompletion, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips non-completion and other-project activities', () {
|
||||||
|
final completed = TaskActivity(
|
||||||
|
id: 'completed',
|
||||||
|
operationId: 'complete',
|
||||||
|
code: TaskActivityCode.completed,
|
||||||
|
taskId: 'task-1',
|
||||||
|
projectId: 'home',
|
||||||
|
occurredAt: DateTime.utc(2026, 6, 25, 9),
|
||||||
|
metadata: const {'durationMinutes': 25},
|
||||||
|
);
|
||||||
|
final pushed = TaskActivity(
|
||||||
|
id: 'pushed',
|
||||||
|
operationId: 'push',
|
||||||
|
code: TaskActivityCode.manuallyPushed,
|
||||||
|
taskId: 'task-1',
|
||||||
|
projectId: 'home',
|
||||||
|
occurredAt: DateTime.utc(2026, 6, 25, 8),
|
||||||
|
);
|
||||||
|
final otherProject = TaskActivity(
|
||||||
|
id: 'other-project',
|
||||||
|
operationId: 'complete-other',
|
||||||
|
code: TaskActivityCode.completed,
|
||||||
|
taskId: 'task-2',
|
||||||
|
projectId: 'work',
|
||||||
|
occurredAt: DateTime.utc(2026, 6, 25, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = aggregationService.apply(
|
||||||
|
statistics: ProjectStatistics(projectId: 'home'),
|
||||||
|
activities: [completed, pushed, otherProject, completed],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.appliedActivityIds, ['completed']);
|
||||||
|
expect(result.statistics.completedTaskCount, 1);
|
||||||
|
expect(result.statistics.durationMinuteCounts, {25: 1});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Project learned suggestions', () {
|
||||||
|
const service = ProjectSuggestionService();
|
||||||
|
|
||||||
|
test('uses deterministic samples and resists duration outliers', () {
|
||||||
|
final statistics = ProjectStatistics(
|
||||||
|
projectId: 'home',
|
||||||
|
completedTaskCount: 4,
|
||||||
|
durationMinuteCounts: const {25: 2, 30: 1, 240: 1},
|
||||||
|
completionTimeBucketCounts: const {
|
||||||
|
ProjectCompletionTimeBucket.morning: 3,
|
||||||
|
ProjectCompletionTimeBucket.evening: 1,
|
||||||
|
},
|
||||||
|
rewardCounts: const {
|
||||||
|
RewardLevel.high: 2,
|
||||||
|
RewardLevel.low: 1,
|
||||||
|
RewardLevel.notSet: 10,
|
||||||
|
},
|
||||||
|
difficultyCounts: const {
|
||||||
|
DifficultyLevel.medium: 3,
|
||||||
|
DifficultyLevel.hard: 1,
|
||||||
|
},
|
||||||
|
reminderProfileCounts: const {
|
||||||
|
ReminderProfile.persistent: 3,
|
||||||
|
ReminderProfile.gentle: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final suggestions = service.suggestionsFor(statistics);
|
||||||
|
|
||||||
|
expect(suggestions.durationMinutes!.value, 25);
|
||||||
|
expect(suggestions.durationMinutes!.sampleSize, 4);
|
||||||
|
expect(suggestions.durationMinutes!.supportingSampleCount, 2);
|
||||||
|
expect(
|
||||||
|
suggestions.completionTimeBucket!.value,
|
||||||
|
ProjectCompletionTimeBucket.morning,
|
||||||
|
);
|
||||||
|
expect(suggestions.completionTimeBucket!.confidence,
|
||||||
|
ProjectSuggestionConfidence.high);
|
||||||
|
expect(suggestions.reward!.value, RewardLevel.high);
|
||||||
|
expect(suggestions.reward!.sampleSize, 3);
|
||||||
|
expect(suggestions.difficulty!.value, DifficultyLevel.medium);
|
||||||
|
expect(suggestions.reminderProfile!.value, ReminderProfile.persistent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not suggest when samples are insufficient', () {
|
||||||
|
final statistics = ProjectStatistics(
|
||||||
|
projectId: 'home',
|
||||||
|
completedTaskCount: 2,
|
||||||
|
durationMinuteCounts: const {25: 2},
|
||||||
|
completionTimeBucketCounts: const {
|
||||||
|
ProjectCompletionTimeBucket.morning: 2,
|
||||||
|
},
|
||||||
|
rewardCounts: const {RewardLevel.high: 2},
|
||||||
|
difficultyCounts: const {DifficultyLevel.medium: 2},
|
||||||
|
reminderProfileCounts: const {ReminderProfile.gentle: 2},
|
||||||
|
);
|
||||||
|
|
||||||
|
final suggestions = service.suggestionsFor(statistics);
|
||||||
|
|
||||||
|
expect(suggestions.durationMinutes, isNull);
|
||||||
|
expect(suggestions.completionTimeBucket, isNull);
|
||||||
|
expect(suggestions.reward, isNull);
|
||||||
|
expect(suggestions.difficulty, isNull);
|
||||||
|
expect(suggestions.reminderProfile, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not choose categorical suggestions on ties', () {
|
||||||
|
final statistics = ProjectStatistics(
|
||||||
|
projectId: 'home',
|
||||||
|
completedTaskCount: 4,
|
||||||
|
completionTimeBucketCounts: const {
|
||||||
|
ProjectCompletionTimeBucket.morning: 2,
|
||||||
|
ProjectCompletionTimeBucket.evening: 2,
|
||||||
|
},
|
||||||
|
rewardCounts: const {
|
||||||
|
RewardLevel.high: 2,
|
||||||
|
RewardLevel.low: 2,
|
||||||
|
},
|
||||||
|
difficultyCounts: const {
|
||||||
|
DifficultyLevel.easy: 2,
|
||||||
|
DifficultyLevel.hard: 2,
|
||||||
|
},
|
||||||
|
reminderProfileCounts: const {
|
||||||
|
ReminderProfile.gentle: 2,
|
||||||
|
ReminderProfile.persistent: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final suggestions = service.suggestionsFor(statistics);
|
||||||
|
|
||||||
|
expect(suggestions.completionTimeBucket, isNull);
|
||||||
|
expect(suggestions.reward, isNull);
|
||||||
|
expect(suggestions.difficulty, isNull);
|
||||||
|
expect(suggestions.reminderProfile, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stable recomputation is independent of observation order', () {
|
||||||
|
final first = ProjectStatistics(projectId: 'home')
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 9),
|
||||||
|
durationMinutes: 20,
|
||||||
|
reward: RewardLevel.high,
|
||||||
|
)
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 18),
|
||||||
|
durationMinutes: 40,
|
||||||
|
reward: RewardLevel.low,
|
||||||
|
)
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 10),
|
||||||
|
durationMinutes: 20,
|
||||||
|
reward: RewardLevel.high,
|
||||||
|
);
|
||||||
|
final second = ProjectStatistics(projectId: 'home')
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 18),
|
||||||
|
durationMinutes: 40,
|
||||||
|
reward: RewardLevel.low,
|
||||||
|
)
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 10),
|
||||||
|
durationMinutes: 20,
|
||||||
|
reward: RewardLevel.high,
|
||||||
|
)
|
||||||
|
.recordCompletion(
|
||||||
|
completedAt: DateTime.utc(2026, 6, 25, 9),
|
||||||
|
durationMinutes: 20,
|
||||||
|
reward: RewardLevel.high,
|
||||||
|
);
|
||||||
|
|
||||||
|
final firstSuggestions = service.suggestionsFor(first);
|
||||||
|
final secondSuggestions = service.suggestionsFor(second);
|
||||||
|
|
||||||
|
expect(firstSuggestions.durationMinutes!.value, 20);
|
||||||
|
expect(secondSuggestions.durationMinutes!.value, 20);
|
||||||
|
expect(firstSuggestions.reward!.value, RewardLevel.high);
|
||||||
|
expect(secondSuggestions.reward!.value, RewardLevel.high);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured defaults remain authoritative beside suggestions', () {
|
||||||
|
final project = ProjectProfile(
|
||||||
|
id: 'home',
|
||||||
|
name: 'Home',
|
||||||
|
colorKey: 'green',
|
||||||
|
defaultReward: RewardLevel.high,
|
||||||
|
defaultDifficulty: DifficultyLevel.hard,
|
||||||
|
defaultReminderProfile: ReminderProfile.strict,
|
||||||
|
defaultDurationMinutes: 45,
|
||||||
|
);
|
||||||
|
final statistics = ProjectStatistics(
|
||||||
|
projectId: 'home',
|
||||||
|
completedTaskCount: 3,
|
||||||
|
durationMinuteCounts: const {20: 3},
|
||||||
|
rewardCounts: const {RewardLevel.low: 3},
|
||||||
|
difficultyCounts: const {DifficultyLevel.easy: 3},
|
||||||
|
reminderProfileCounts: const {ReminderProfile.gentle: 3},
|
||||||
|
);
|
||||||
|
|
||||||
|
final resolution = service.resolveDefaults(
|
||||||
|
project: project,
|
||||||
|
statistics: statistics,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.configuredDurationMinutes, 45);
|
||||||
|
expect(resolution.configuredReward, RewardLevel.high);
|
||||||
|
expect(resolution.configuredDifficulty, DifficultyLevel.hard);
|
||||||
|
expect(resolution.configuredReminderProfile, ReminderProfile.strict);
|
||||||
|
expect(resolution.suggestions.durationMinutes!.value, 20);
|
||||||
|
expect(resolution.suggestions.reward!.value, RewardLevel.low);
|
||||||
|
expect(resolution.suggestions.difficulty!.value, DifficultyLevel.easy);
|
||||||
|
expect(
|
||||||
|
resolution.suggestions.reminderProfile!.value,
|
||||||
|
ReminderProfile.gentle,
|
||||||
|
);
|
||||||
|
expect(project.defaultReward, RewardLevel.high);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Task scheduledTask({
|
||||||
|
required String id,
|
||||||
|
required String projectId,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime start,
|
||||||
|
RewardLevel reward = RewardLevel.notSet,
|
||||||
|
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
||||||
|
ReminderProfile? reminderOverride,
|
||||||
|
}) {
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: 'Task',
|
||||||
|
projectId: projectId,
|
||||||
|
type: TaskType.flexible,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
reward: reward,
|
||||||
|
difficulty: difficulty,
|
||||||
|
durationMinutes: 30,
|
||||||
|
scheduledStart: start,
|
||||||
|
scheduledEnd: start.add(const Duration(minutes: 30)),
|
||||||
|
reminderOverride: reminderOverride,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue