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