From 374907a281c85d981de51b6317c1341d1b80af36 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 10:01:08 -0700 Subject: [PATCH] feat(projects): add learned suggestion statistics --- .../V1_BACKEND_COMPLETION_GAP_MATRIX.md | 6 +- ..._Lifecycle_Statistics_Project_Reminders.md | 9 + .../V1_PUBLIC_API_BASELINE.md | 20 +- .../V1_REQUIREMENTS_TRACEABILITY_MATRIX.md | 13 +- lib/scheduler_core.dart | 1 + lib/src/document_mapping.dart | 144 ++++ lib/src/persistence_contract.dart | 26 + lib/src/project_statistics.dart | 697 ++++++++++++++++++ lib/src/task_actions.dart | 5 + lib/src/task_lifecycle.dart | 30 +- test/document_mapping_test.dart | 46 ++ test/persistence_edge_cases_test.dart | 11 + test/project_statistics_test.dart | 310 ++++++++ 13 files changed, 1309 insertions(+), 9 deletions(-) create mode 100644 lib/src/project_statistics.dart create mode 100644 test/project_statistics_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index 8ea8505..f085318 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -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 | | Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 | | Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 | -| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 | +| 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 | -| 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 | | 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 | | 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 | -| 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 | | 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 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md index 38f4ff4..3b736a9 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md @@ -170,6 +170,8 @@ statistics and suggestions. Recommended Codex level: extra high +Status: Complete on 2026-06-25. + Tasks: - 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. - 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 resolution. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 2847ac3..70a38db 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -21,6 +21,7 @@ following source files: - `src/locked_time.dart` - `src/occupancy_policy.dart` - `src/persistence_contract.dart` +- `src/project_statistics.dart` - `src/quick_capture.dart` - `src/repositories.dart` - `src/scheduling_engine.dart` @@ -42,6 +43,7 @@ changes from accidental API drift. | `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | +| `src/project_statistics.dart` | `ProjectCompletionTimeBucket`, `ProjectSuggestionType`, `ProjectSuggestionConfidence` | | `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | @@ -56,11 +58,12 @@ changes from accidental API drift. | `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | | `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/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` | | `src/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` | | `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/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` | @@ -99,6 +102,9 @@ Current notable model fields: `reminderOverride`, `createdAt`, `updatedAt`, `stats` - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ 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` - `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour, 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 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 The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index 2df66b7..e1beb15 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -49,6 +49,13 @@ Latest Block 13.3 verification result on 2026-06-25: - `dart test`: passed, 225 tests - `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: - `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-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-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 @@ -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-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-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-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. | ## Intentionally deferred human-spec items diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 17ee9b9..0921978 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -24,6 +24,7 @@ export 'src/free_slots.dart'; export 'src/locked_time.dart'; export 'src/occupancy_policy.dart'; export 'src/persistence_contract.dart'; +export 'src/project_statistics.dart'; export 'src/quick_capture.dart'; export 'src/repositories.dart'; export 'src/scheduling_engine.dart'; diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart index 682bc62..02be7fb 100644 --- a/lib/src/document_mapping.dart +++ b/lib/src/document_mapping.dart @@ -5,6 +5,7 @@ import 'models.dart'; import 'persistence_contract.dart'; +import 'project_statistics.dart'; import 'task_statistics.dart'; /// Stable enum encode/decode helpers for document maps. @@ -45,6 +46,16 @@ abstract final class PersistenceEnumMapping { return _decodeEnum(BacklogTag.values, value, 'BacklogTag'); } + static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( + String value, + ) { + return _decodeEnum( + ProjectCompletionTimeBucket.values, + value, + 'ProjectCompletionTimeBucket', + ); + } + static T _decodeEnum( Iterable values, 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 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 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. extension TaskDocumentExtension on Task { /// 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 toDocument() { + return ProjectStatisticsDocumentMapping.toDocument(this); + } +} + String? _dateTimeToStored(DateTime? value) { return value == null ? null @@ -312,6 +400,62 @@ int _intOrZero(Map document, String fieldName) { throw ArgumentError.value(value, fieldName, 'Expected an int.'); } +Map _intKeyCountMapToDocument(Map counts) { + return { + for (final entry in counts.entries) entry.key.toString(): entry.value, + }; +} + +Map _enumCountMapToDocument(Map counts) { + return { + for (final entry in counts.entries) entry.key.persistenceName: entry.value, + }; +} + +Map _intKeyCountMapFromDocument( + Map document, + String fieldName, +) { + final raw = document[fieldName]; + if (raw == null) { + return const {}; + } + 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 _enumCountMapFromDocument( + Map document, + String fieldName, + T Function(String value) decode, +) { + final raw = document[fieldName]; + if (raw == null) { + return {}; + } + 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 document, String fieldName) { return PersistenceDateTimeConvention.fromStoredString( _requiredString(document, fieldName), diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index d007848..d40baa0 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -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 = { + id, + completedTaskCount, + durationMinuteCounts, + completionTimeBucketCounts, + totalPushesBeforeCompletion, + completedAfterPushCount, + rewardCounts, + difficultyCounts, + reminderProfileCounts, + }; +} + /// Document field names for [ClockTime] embedded documents. abstract final class ClockTimeDocumentFields { static const hour = 'hour'; @@ -377,6 +402,7 @@ abstract final class PersistenceDocumentFieldSets { TaskActivityDocumentFields.all, TaskStatisticsDocumentFields.all, ProjectDocumentFields.all, + ProjectStatisticsDocumentFields.all, ClockTimeDocumentFields.all, LockedBlockRecurrenceDocumentFields.all, LockedBlockDocumentFields.all, diff --git a/lib/src/project_statistics.dart b/lib/src/project_statistics.dart new file mode 100644 index 0000000..7cb1947 --- /dev/null +++ b/lib/src/project_statistics.dart @@ -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 durationMinuteCounts = const {}, + Map completionTimeBucketCounts = + const {}, + this.totalPushesBeforeCompletion = 0, + this.completedAfterPushCount = 0, + Map rewardCounts = const {}, + Map difficultyCounts = const {}, + Map reminderProfileCounts = + const {}, + }) : projectId = _requiredTrimmed(projectId, 'projectId'), + durationMinuteCounts = Map.unmodifiable( + _positiveIntKeyCounts(durationMinuteCounts, 'durationMinuteCounts'), + ), + completionTimeBucketCounts = + Map.unmodifiable( + _enumCounts( + completionTimeBucketCounts, + 'completionTimeBucketCounts', + ), + ), + rewardCounts = Map.unmodifiable( + _enumCounts(rewardCounts, 'rewardCounts'), + ), + difficultyCounts = Map.unmodifiable( + _enumCounts(difficultyCounts, 'difficultyCounts'), + ), + reminderProfileCounts = Map.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 durationMinuteCounts; + + /// Completion timestamp samples stored as coarse bucket counts. + final Map 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 rewardCounts; + + /// Difficulty observations from completed tasks. + final Map difficultyCounts; + + /// Reminder-profile observations when explicitly known. + final Map 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? durationMinuteCounts, + Map? completionTimeBucketCounts, + int? totalPushesBeforeCompletion, + int? completedAfterPushCount, + Map? rewardCounts, + Map? difficultyCounts, + Map? 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 appliedActivityIds = const [], + }) : appliedActivityIds = List.unmodifiable(appliedActivityIds); + + /// Aggregate after newly applied activity effects. + final ProjectStatistics statistics; + + /// Completion activity ids that changed the aggregate. + final List 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 activities, + Iterable tasks = const [], + Iterable alreadyAppliedActivityIds = const [], + }) { + final taskById = { + for (final task in tasks) task.id: task, + }; + final appliedIds = alreadyAppliedActivityIds.toSet(); + final newlyAppliedIds = []; + 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 { + 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? durationMinutes; + final ProjectSuggestion? completionTimeBucket; + final ProjectSuggestion? reward; + final ProjectSuggestion? difficulty; + final ProjectSuggestion? 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? _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( + type: ProjectSuggestionType.durationMinutes, + value: value, + sampleSize: sampleSize, + supportingSampleCount: statistics.durationMinuteCounts[value] ?? 0, + minimumSampleSize: policy.minimumDurationSamples, + confidence: _confidence( + statistics.durationMinuteCounts[value] ?? 0, + sampleSize, + ), + ); + } + + ProjectSuggestion? _modeSuggestion({ + required ProjectSuggestionType type, + required Map 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( + type: type, + value: mode, + sampleSize: sampleSize, + supportingSampleCount: support, + minimumSampleSize: minimumSampleSize, + confidence: _confidence(support, sampleSize), + ); + } +} + +Map _incrementCount(Map counts, K key) { + return { + ...counts, + key: (counts[key] ?? 0) + 1, + }; +} + +Map _positiveIntKeyCounts(Map counts, String name) { + final normalized = {}; + 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 _enumCounts(Map counts, String name) { + final normalized = {}; + 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(Map counts) { + var total = 0; + for (final value in counts.values) { + total += value; + } + return total; +} + +int? _weightedLowerMedian(Map 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(Map 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 _withoutNotSetReward(Map counts) { + return { + for (final entry in counts.entries) + if (entry.key != RewardLevel.notSet) entry.key: entry.value, + }; +} + +Map _withoutNotSetDifficulty( + Map 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(Iterable 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; +} diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index e4c97e4..c3ea7a9 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -932,6 +932,11 @@ TaskActivity _surpriseCompletedActivity({ 'completedAt': occurredAt, 'actualStart': task.actualStart, 'actualEnd': task.actualEnd, + 'durationMinutes': task.durationMinutes, + 'reward': task.reward.name, + 'difficulty': task.difficulty.name, + if (task.reminderOverride != null) + 'reminderProfile': task.reminderOverride!.name, 'pushesBeforeCompletion': 0, 'source': 'surpriseLog', }, diff --git a/lib/src/task_lifecycle.dart b/lib/src/task_lifecycle.dart index acb0046..e917e9c 100644 --- a/lib/src/task_lifecycle.dart +++ b/lib/src/task_lifecycle.dart @@ -355,8 +355,17 @@ class TaskTransitionService { ...metadata, 'completedAt': occurredAt, 'scheduledEnd': task.scheduledEnd, - 'actualStart': actualStart, - 'actualEnd': actualEnd, + 'actualStart': completed.actualStart, + '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), }, nextStatus: completed.status, @@ -808,6 +817,23 @@ int _pushesBeforeCompletion(Task task) { 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) { return value is int ? value : null; } diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart index 5e26b36..5efed97 100644 --- a/test/document_mapping_test.dart +++ b/test/document_mapping_test.dart @@ -126,6 +126,52 @@ void main() { 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', () { expect( () => PersistenceEnumMapping.decodeTaskType('unknown'), diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart index 2f76ffc..061eccc 100644 --- a/test/persistence_edge_cases_test.dart +++ b/test/persistence_edge_cases_test.dart @@ -253,6 +253,17 @@ void main() { test('document field sets cover project locked and snapshot documents', () { expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey'])); + expect(ProjectStatisticsDocumentFields.all, { + '_id', + 'completedTaskCount', + 'durationMinuteCounts', + 'completionTimeBucketCounts', + 'totalPushesBeforeCompletion', + 'completedAfterPushCount', + 'rewardCounts', + 'difficultyCounts', + 'reminderProfileCounts', + }); expect( LockedBlockDocumentFields.all, containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']), diff --git a/test/project_statistics_test.dart b/test/project_statistics_test.dart new file mode 100644 index 0000000..6609448 --- /dev/null +++ b/test/project_statistics_test.dart @@ -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, + ); +}