timetracker/lib/features/timer/domain/frequent_projects_provider.dart
2026-08-03 21:51:48 +02:00

98 lines
3.8 KiB
Dart

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:timetrack/features/entries/domain/entries_provider.dart';
import 'package:timetrack/features/projects/domain/project.dart';
import 'package:timetrack/features/projects/domain/projects_provider.dart';
import 'package:timetrack/features/settings/domain/settings_provider.dart';
part 'frequent_projects_provider.g.dart';
/// Returns the top-N most frequently + recently used active projects.
///
/// Score = 0.30 * normalised_count + 0.70 * normalised_recency
/// - count : number of time entries in the last 30 days for that project
/// - recency : milliseconds since last entry, inverted and normalised
///
/// Scored projects are always shown first (highest score → left/top).
/// Remaining slots up to N are filled with the oldest active projects
/// (by createdAt) that are not already in the scored set.
/// Only when there are >= N scored projects are unscored projects omitted.
///
/// N is controlled by [quickAccessCountNotifierProvider] (default 6).
@riverpod
List<Project> frequentProjects(FrequentProjectsRef ref) {
final projectsAsync = ref.watch(activeProjectsProvider);
final entriesAsync = ref.watch(allEntriesProvider);
final count = ref.watch(quickAccessCountNotifierProvider);
final projects = projectsAsync.valueOrNull ?? [];
final entries = entriesAsync.valueOrNull ?? [];
if (projects.isEmpty) return [];
final cutoff = DateTime.now().subtract(const Duration(days: 30));
// Aggregate per project: entry count (30d) + most recent start time (all time)
final Map<int, int> countMap = {};
final Map<int, DateTime> lastUsedMap = {};
for (final e in entries) {
final projectId = e.projectId;
if (e.startTime.isAfter(cutoff)) {
countMap[projectId] = (countMap[projectId] ?? 0) + 1;
}
final current = lastUsedMap[projectId];
if (current == null || e.startTime.isAfter(current)) {
lastUsedMap[projectId] = e.startTime;
}
}
// Only keep active projects that have at least one entry
final scored = projects
.where((p) => countMap.containsKey(p.id) || lastUsedMap.containsKey(p.id))
.toList();
// Fallback: no entries at all → show the first N projects by creation date
if (scored.isEmpty) {
final fallback = [...projects]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return fallback.take(count).toList();
}
// Sort scored projects by descending score
final maxCount = scored
.map((p) => countMap[p.id] ?? 0)
.reduce((a, b) => a > b ? a : b);
final now = DateTime.now();
final maxAge = scored.map((p) {
final last = lastUsedMap[p.id];
return last != null ? now.difference(last).inMilliseconds : 0.0;
}).fold<double>(0.0, (prev, age) => age > prev ? age.toDouble() : prev);
// Pre-compute scores into a map keyed by project id to avoid
// using indexOf() inside the sort comparator (which causes RangeError
// when the list is mutated mid-sort).
final scoreMap = <int, double>{};
for (final p in scored) {
final normCount = maxCount > 0 ? (countMap[p.id] ?? 0) / maxCount : 0.0;
final last = lastUsedMap[p.id];
final age = last != null ? now.difference(last).inMilliseconds.toDouble() : 0.0;
final normRecency = maxAge > 0 ? 1.0 - (age / maxAge) : 0.0;
scoreMap[p.id] = 0.30 * normCount + 0.70 * normRecency;
}
scored.sort((a, b) => scoreMap[b.id]!.compareTo(scoreMap[a.id]!));
// If we already have enough scored projects, return top-N directly
if (scored.length >= count) {
return scored.take(count).toList();
}
// Fill remaining slots with the oldest projects not already in the scored set
final scoredIds = scored.map((p) => p.id).toSet();
final filler = [...projects.where((p) => !scoredIds.contains(p.id))]
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return [...scored, ...filler].take(count).toList();
}