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 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 countMap = {}; final Map 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(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 = {}; 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(); }