feat(projects): add learned suggestion statistics

This commit is contained in:
Ashley Venn 2026-06-25 10:01:08 -07:00
parent be98141ffd
commit 374907a281
13 changed files with 1309 additions and 9 deletions

View file

@ -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 |

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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';

View file

@ -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<T extends Enum>(
Iterable<T> 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<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.
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<String, Object?> toDocument() {
return ProjectStatisticsDocumentMapping.toDocument(this);
}
}
String? _dateTimeToStored(DateTime? value) {
return value == null
? null
@ -312,6 +400,62 @@ int _intOrZero(Map<String, Object?> document, String fieldName) {
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) {
return PersistenceDateTimeConvention.fromStoredString(
_requiredString(document, fieldName),

View file

@ -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.
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,

View 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;
}

View file

@ -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',
},

View file

@ -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;
}

View file

@ -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'),

View file

@ -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']),

View 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,
);
}