focus-flow/lib/src/project_statistics.dart

697 lines
21 KiB
Dart

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