98 lines
3.8 KiB
Dart
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();
|
|
}
|