| | | 1 | | using Microsoft.Extensions.Logging; |
| | | 2 | | using Pomodoro.Web.Models; |
| | | 3 | | using Pomodoro.Web.Services.Repositories; |
| | | 4 | | |
| | | 5 | | namespace Pomodoro.Web.Services; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Service for managing activity history records using IndexedDB |
| | | 9 | | /// Stores unlimited history in IndexedDB but keeps a sliding window cache in memory |
| | | 10 | | /// Implements ITimerEventSubscriber to handle timer completion events |
| | | 11 | | /// </summary> |
| | | 12 | | public partial class ActivityService : IActivityService, ITimerEventSubscriber |
| | | 13 | | { |
| | | 14 | | private readonly IActivityRepository _activityRepository; |
| | | 15 | | private readonly ILogger<ActivityService> _logger; |
| | 439 | 16 | | private readonly object _cacheLock = new(); |
| | 439 | 17 | | private List<ActivityRecord> _cachedActivities = new(); |
| | | 18 | | private bool _isCacheLoaded; |
| | | 19 | | |
| | | 20 | | /// <summary> |
| | | 21 | | /// Date-based cache for activities grouped by local date |
| | | 22 | | /// </summary> |
| | 439 | 23 | | private Dictionary<DateTime, List<ActivityRecord>> _activitiesByDate = new(); |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// Cache for daily statistics (pomodoro count, focus minutes, break minutes) |
| | | 27 | | /// </summary> |
| | | 28 | | private readonly struct DailyStatsCache |
| | | 29 | | { |
| | 255 | 30 | | public int PomodoroCount { get; init; } |
| | 211 | 31 | | public int FocusMinutes { get; init; } |
| | 236 | 32 | | public int BreakMinutes { get; init; } |
| | | 33 | | } |
| | 439 | 34 | | private Dictionary<DateTime, DailyStatsCache> _dailyStatsCache = new(); |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// Cache for time distribution data by date |
| | | 38 | | /// </summary> |
| | 439 | 39 | | private Dictionary<DateTime, Dictionary<string, int>> _timeDistributionCache = new(); |
| | | 40 | | |
| | | 41 | | public event Action? OnActivityChanged; |
| | | 42 | | |
| | 439 | 43 | | public ActivityService(IActivityRepository activityRepository, ILogger<ActivityService> logger) |
| | 439 | 44 | | { |
| | 439 | 45 | | _activityRepository = activityRepository; |
| | 439 | 46 | | _logger = logger; |
| | 439 | 47 | | } |
| | | 48 | | |
| | | 49 | | public async Task InitializeAsync() |
| | 383 | 50 | | { |
| | 383 | 51 | | await LoadCacheAsync(); |
| | 383 | 52 | | } |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Reloads all activity data from storage, clearing and rebuilding caches. |
| | | 56 | | /// Called after import operations to refresh in-memory data. |
| | | 57 | | /// </summary> |
| | | 58 | | public async Task ReloadAsync() |
| | 20 | 59 | | { |
| | 20 | 60 | | var activities = await _activityRepository.GetAllAsync(); |
| | | 61 | | |
| | 20 | 62 | | lock (_cacheLock) |
| | 20 | 63 | | { |
| | 20 | 64 | | _cachedActivities = activities.Take(Constants.Cache.MaxActivityCacheSize).ToList(); |
| | 20 | 65 | | _isCacheLoaded = true; |
| | 20 | 66 | | ClearAllDerivedCaches(); |
| | 20 | 67 | | } |
| | | 68 | | |
| | 20 | 69 | | _logger.LogInformation("Reloaded {Count} activities from storage", _cachedActivities.Count); |
| | 20 | 70 | | OnActivityChanged?.Invoke(); |
| | 20 | 71 | | } |
| | | 72 | | |
| | | 73 | | private async Task LoadCacheAsync() |
| | 383 | 74 | | { |
| | 393 | 75 | | if (_isCacheLoaded) return; |
| | | 76 | | |
| | 373 | 77 | | var activities = await _activityRepository.GetAllAsync(); |
| | | 78 | | |
| | | 79 | | // Apply cache size limit - only keep most recent activities in memory |
| | 373 | 80 | | lock (_cacheLock) |
| | 373 | 81 | | { |
| | 373 | 82 | | _cachedActivities = (activities ?? Enumerable.Empty<ActivityRecord>()).Take(Constants.Cache.MaxActivityCache |
| | 373 | 83 | | _isCacheLoaded = true; |
| | 373 | 84 | | } |
| | | 85 | | |
| | 373 | 86 | | _logger.LogDebug(Constants.Messages.LogActivitiesLoadedFormat, _cachedActivities.Count, Constants.Cache.MaxActiv |
| | 2437 | 87 | | foreach (var a in _cachedActivities.Take(5)) |
| | 659 | 88 | | { |
| | 659 | 89 | | _logger.LogDebug(Constants.Messages.LogActivityDebugFormat, a.Type, a.CompletedAt, a.TaskName); |
| | 659 | 90 | | } |
| | 383 | 91 | | } |
| | | 92 | | |
| | | 93 | | public List<ActivityRecord> GetTodayActivities() |
| | 20 | 94 | | { |
| | | 95 | | // Use local time for consistent date comparison (matches GetActivitiesForDate behavior) |
| | 20 | 96 | | var todayLocal = DateTime.Now.Date; |
| | 20 | 97 | | return GetActivitiesForDate(todayLocal); |
| | 20 | 98 | | } |
| | | 99 | | |
| | | 100 | | /// <summary> |
| | | 101 | | /// Gets paged activities for a date range |
| | | 102 | | /// </summary> |
| | | 103 | | public async Task<List<ActivityRecord>> GetActivitiesPagedAsync(DateTime startDate, DateTime endDate, int skip = 0, |
| | 20 | 104 | | { |
| | 20 | 105 | | return await _activityRepository.GetPagedAsync(startDate, endDate, skip, take); |
| | 20 | 106 | | } |
| | | 107 | | |
| | | 108 | | /// <summary> |
| | | 109 | | /// Gets the total count of activities for a date range |
| | | 110 | | /// </summary> |
| | | 111 | | public async Task<int> GetActivityCountAsync(DateTime? startDate = null, DateTime? endDate = null) |
| | 20 | 112 | | { |
| | 20 | 113 | | return await _activityRepository.GetCountAsync(startDate, endDate); |
| | 20 | 114 | | } |
| | | 115 | | |
| | | 116 | | public List<ActivityRecord> GetAllActivities() |
| | 80 | 117 | | { |
| | 80 | 118 | | lock (_cacheLock) |
| | 80 | 119 | | { |
| | 80 | 120 | | return _cachedActivities.ToList(); |
| | | 121 | | } |
| | 80 | 122 | | } |
| | | 123 | | |
| | | 124 | | /// <summary> |
| | | 125 | | /// Gets all activities for a specific date (in local time) |
| | | 126 | | /// Uses date-based cache for O(1) lookup on repeated access |
| | | 127 | | /// </summary> |
| | | 128 | | public List<ActivityRecord> GetActivitiesForDate(DateTime date) |
| | 70 | 129 | | { |
| | 70 | 130 | | var targetDate = date.Date; |
| | | 131 | | |
| | 70 | 132 | | lock (_cacheLock) |
| | 70 | 133 | | { |
| | | 134 | | // Check cache first |
| | 70 | 135 | | if (_activitiesByDate.TryGetValue(targetDate, out var cached)) |
| | 10 | 136 | | { |
| | 10 | 137 | | _logger.LogDebug("Cache hit for GetActivitiesForDate: {Date}", targetDate); |
| | 10 | 138 | | return cached; |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | // Compute and cache |
| | 60 | 142 | | var result = _cachedActivities |
| | 120 | 143 | | .Where(a => a.CompletedAt.ToLocalTime().Date == targetDate) |
| | 70 | 144 | | .OrderByDescending(a => a.CompletedAt) |
| | 60 | 145 | | .ToList(); |
| | | 146 | | |
| | 60 | 147 | | _activitiesByDate[targetDate] = result; |
| | 60 | 148 | | _logger.LogDebug("Cache miss for GetActivitiesForDate: {Date}, cached {Count} activities", targetDate, resul |
| | 60 | 149 | | return result; |
| | | 150 | | } |
| | 70 | 151 | | } |
| | | 152 | | |
| | | 153 | | /// <summary> |
| | | 154 | | /// Gets daily break minutes for a date range (in local time) |
| | | 155 | | /// Returns total minutes of both short and long breaks per day |
| | | 156 | | /// Uses date-based cache for improved performance |
| | | 157 | | /// </summary> |
| | | 158 | | |
| | | 159 | | /// This is more efficient than computing each date separately when querying a range. |
| | | 160 | | /// </summary> |
| | | 161 | | /// <param name="dates">The dates to compute stats for</param> |
| | | 162 | | private void ComputeDailyStatsForRange(List<DateTime> dates) |
| | 73 | 163 | | { |
| | | 164 | | // Initialize stats for all dates |
| | 73 | 165 | | var statsByDate = new Dictionary<DateTime, (int PomodoroCount, int FocusMinutes, int BreakMinutes)>(); |
| | 541 | 166 | | foreach (var date in dates) |
| | 161 | 167 | | { |
| | 161 | 168 | | statsByDate[date.Date] = (0, 0, 0); |
| | 161 | 169 | | } |
| | | 170 | | |
| | | 171 | | // Single pass through all activities |
| | 631 | 172 | | foreach (var a in _cachedActivities) |
| | 206 | 173 | | { |
| | 206 | 174 | | var activityDate = a.CompletedAt.ToLocalTime().Date; |
| | | 175 | | |
| | | 176 | | // Only process if this date is in our target list |
| | 210 | 177 | | if (!statsByDate.ContainsKey(activityDate)) continue; |
| | | 178 | | |
| | 202 | 179 | | var stats = statsByDate[activityDate]; |
| | 202 | 180 | | if (a.Type == SessionType.Pomodoro) |
| | 110 | 181 | | { |
| | 110 | 182 | | statsByDate[activityDate] = (stats.PomodoroCount + 1, stats.FocusMinutes + a.DurationMinutes, stats.Brea |
| | 110 | 183 | | } |
| | 92 | 184 | | else if (a.Type == SessionType.ShortBreak || a.Type == SessionType.LongBreak) |
| | 92 | 185 | | { |
| | 92 | 186 | | statsByDate[activityDate] = (stats.PomodoroCount, stats.FocusMinutes, stats.BreakMinutes + a.DurationMin |
| | 92 | 187 | | } |
| | 202 | 188 | | } |
| | | 189 | | |
| | | 190 | | // Store in cache |
| | 541 | 191 | | foreach (var kvp in statsByDate) |
| | 161 | 192 | | { |
| | 161 | 193 | | _dailyStatsCache[kvp.Key] = new DailyStatsCache |
| | 161 | 194 | | { |
| | 161 | 195 | | PomodoroCount = kvp.Value.PomodoroCount, |
| | 161 | 196 | | FocusMinutes = kvp.Value.FocusMinutes, |
| | 161 | 197 | | BreakMinutes = kvp.Value.BreakMinutes |
| | 161 | 198 | | }; |
| | 161 | 199 | | } |
| | | 200 | | |
| | 73 | 201 | | _logger.LogDebug("Computed stats for {Count} dates in single pass", dates.Count); |
| | 73 | 202 | | } |
| | | 203 | | |
| | | 204 | | /// <summary> |
| | | 205 | | /// Computes daily statistics for a single date using single-pass iteration |
| | | 206 | | /// </summary> |
| | | 207 | | private DailyStatsCache ComputeDailyStats(DateTime date) |
| | 7 | 208 | | { |
| | 7 | 209 | | var targetDate = date.Date; |
| | 7 | 210 | | var pomodoroCount = 0; |
| | 7 | 211 | | var focusMinutes = 0; |
| | 7 | 212 | | var breakMinutes = 0; |
| | | 213 | | |
| | | 214 | | // Single-pass iteration for better performance |
| | 61 | 215 | | foreach (var a in _cachedActivities) |
| | 20 | 216 | | { |
| | 24 | 217 | | if (a.CompletedAt.ToLocalTime().Date != targetDate) continue; |
| | | 218 | | |
| | 16 | 219 | | if (a.Type == SessionType.Pomodoro) |
| | 9 | 220 | | { |
| | 9 | 221 | | pomodoroCount++; |
| | 9 | 222 | | focusMinutes += a.DurationMinutes; |
| | 9 | 223 | | } |
| | 7 | 224 | | else if (a.Type == SessionType.ShortBreak || a.Type == SessionType.LongBreak) |
| | 6 | 225 | | { |
| | 6 | 226 | | breakMinutes += a.DurationMinutes; |
| | 6 | 227 | | } |
| | 16 | 228 | | } |
| | | 229 | | |
| | 7 | 230 | | return new DailyStatsCache |
| | 7 | 231 | | { |
| | 7 | 232 | | PomodoroCount = pomodoroCount, |
| | 7 | 233 | | FocusMinutes = focusMinutes, |
| | 7 | 234 | | BreakMinutes = breakMinutes |
| | 7 | 235 | | }; |
| | 7 | 236 | | } |
| | | 237 | | |
| | | 238 | | /// <summary> |
| | | 239 | | /// Gets task pomodoro counts for a date range (in local time) |
| | | 240 | | /// </summary> |
| | | 241 | | public Dictionary<string, int> GetTaskPomodoroCounts(DateTime from, DateTime to) |
| | 26 | 242 | | { |
| | 26 | 243 | | var fromDate = from.Date; |
| | 26 | 244 | | var toDate = to.Date; |
| | | 245 | | |
| | 26 | 246 | | lock (_cacheLock) |
| | 26 | 247 | | { |
| | 26 | 248 | | return _cachedActivities |
| | 80 | 249 | | .Where(a => a.Type == SessionType.Pomodoro && |
| | 80 | 250 | | a.CompletedAt.ToLocalTime().Date >= fromDate && |
| | 80 | 251 | | a.CompletedAt.ToLocalTime().Date <= toDate && |
| | 80 | 252 | | !string.IsNullOrWhiteSpace(a.TaskName)) |
| | 49 | 253 | | .GroupBy(a => a.TaskName!) |
| | 26 | 254 | | .ToDictionary( |
| | 38 | 255 | | g => g.Key, |
| | 38 | 256 | | g => g.Count() |
| | 26 | 257 | | ); |
| | | 258 | | } |
| | 26 | 259 | | } |
| | | 260 | | |
| | | 261 | | /// <summary> |
| | | 262 | | /// Gets time distribution data for a specific date (in local time) |
| | | 263 | | /// Returns a dictionary with labels (task names or break types) as keys and minutes as values |
| | | 264 | | /// Uses date-based cache for O(1) lookup on repeated access |
| | | 265 | | /// </summary> |
| | | 266 | | public Dictionary<string, int> GetTimeDistribution(DateTime date) |
| | 58 | 267 | | { |
| | 58 | 268 | | var targetDate = date.Date; |
| | | 269 | | |
| | 58 | 270 | | lock (_cacheLock) |
| | 58 | 271 | | { |
| | | 272 | | // Check cache first |
| | 58 | 273 | | if (_timeDistributionCache.TryGetValue(targetDate, out var cached)) |
| | 10 | 274 | | { |
| | 10 | 275 | | _logger.LogDebug("Cache hit for GetTimeDistribution: {Date}", targetDate); |
| | 10 | 276 | | return cached; |
| | | 277 | | } |
| | | 278 | | |
| | | 279 | | // Compute and cache |
| | 48 | 280 | | var dayActivities = _cachedActivities |
| | 115 | 281 | | .Where(a => a.CompletedAt.ToLocalTime().Date == targetDate) |
| | 48 | 282 | | .ToList(); |
| | | 283 | | |
| | 48 | 284 | | var result = new Dictionary<string, int>(); |
| | | 285 | | |
| | | 286 | | // Group pomodoro sessions by task name |
| | 48 | 287 | | var pomodoroByTask = dayActivities |
| | 105 | 288 | | .Where(a => a.Type == SessionType.Pomodoro) |
| | 76 | 289 | | .GroupBy(a => a.TaskName ?? Constants.Activity.FocusTimeLabel) |
| | 48 | 290 | | .ToDictionary( |
| | 62 | 291 | | g => g.Key, |
| | 138 | 292 | | g => g.Sum(a => a.DurationMinutes) |
| | 48 | 293 | | ); |
| | | 294 | | |
| | | 295 | | // Add task times to result |
| | 268 | 296 | | foreach (var kvp in pomodoroByTask) |
| | 62 | 297 | | { |
| | 62 | 298 | | result[kvp.Key] = kvp.Value; |
| | 62 | 299 | | } |
| | | 300 | | |
| | | 301 | | // Aggregate short breaks |
| | 48 | 302 | | var shortBreakMinutes = dayActivities |
| | 105 | 303 | | .Where(a => a.Type == SessionType.ShortBreak) |
| | 64 | 304 | | .Sum(a => a.DurationMinutes); |
| | | 305 | | |
| | 48 | 306 | | if (shortBreakMinutes > 0) |
| | 15 | 307 | | { |
| | 15 | 308 | | result[Constants.Activity.ShortBreaksLabel] = shortBreakMinutes; |
| | 15 | 309 | | } |
| | | 310 | | |
| | | 311 | | // Aggregate long breaks |
| | 48 | 312 | | var longBreakMinutes = dayActivities |
| | 105 | 313 | | .Where(a => a.Type == SessionType.LongBreak) |
| | 61 | 314 | | .Sum(a => a.DurationMinutes); |
| | | 315 | | |
| | 48 | 316 | | if (longBreakMinutes > 0) |
| | 13 | 317 | | { |
| | 13 | 318 | | result[Constants.Activity.LongBreaksLabel] = longBreakMinutes; |
| | 13 | 319 | | } |
| | | 320 | | |
| | 48 | 321 | | _timeDistributionCache[targetDate] = result; |
| | 48 | 322 | | _logger.LogDebug("Cache miss for GetTimeDistribution: {Date}, cached {Count} entries", targetDate, result.Co |
| | 48 | 323 | | return result; |
| | | 324 | | } |
| | 58 | 325 | | } |
| | | 326 | | |
| | | 327 | | /// <summary> |
| | | 328 | | /// Gets weekly statistics for a given week start date |
| | | 329 | | /// </summary> |
| | | 330 | | public async Task<WeeklyStats> GetWeeklyStatsAsync(DateTime weekStartDate) |
| | 24 | 331 | | { |
| | | 332 | | try |
| | 24 | 333 | | { |
| | | 334 | | // Week starts on Saturday (matches History page calculation) |
| | | 335 | | // DayOfWeek: Sun=0, Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6 |
| | | 336 | | // Using AddDays(7) because IDBKeyRange.bound() is inclusive, so end at Saturday midnight includes all Frida |
| | 24 | 337 | | var weekStart = weekStartDate.Date; |
| | 24 | 338 | | var weekEnd = weekStart.AddDays(7); // Saturday to Saturday midnight (includes Saturday through Friday) |
| | 24 | 339 | | var previousWeekStart = weekStart.AddDays(-7); |
| | | 340 | | |
| | | 341 | | // Get activities for current week from repository (to handle large datasets) |
| | 24 | 342 | | var currentWeekActivities = await _activityRepository.GetByDateRangeAsync(weekStart, weekEnd); |
| | 23 | 343 | | var previousWeekActivities = await _activityRepository.GetByDateRangeAsync(previousWeekStart, weekStart); |
| | | 344 | | |
| | | 345 | | // Return zero stats if no activities for the week |
| | 22 | 346 | | if (!currentWeekActivities.Any()) |
| | 10 | 347 | | { |
| | 10 | 348 | | return new WeeklyStats |
| | 10 | 349 | | { |
| | 10 | 350 | | TotalFocusMinutes = 0, |
| | 10 | 351 | | TotalPomodoroCount = 0, |
| | 10 | 352 | | UniqueTasksWorkedOn = 0, |
| | 10 | 353 | | DailyAverageMinutes = 0, |
| | 10 | 354 | | MostProductiveDay = DayOfWeek.Monday, |
| | 10 | 355 | | PreviousWeekFocusMinutes = 0, |
| | 10 | 356 | | WeekOverWeekChange = 0 |
| | 10 | 357 | | }; |
| | | 358 | | } |
| | | 359 | | |
| | | 360 | | // Calculate current week statistics |
| | | 361 | | var pomodoroActivities = currentWeekActivities.Where(a => a.Type == SessionType.Pomodoro).ToList(); |
| | | 362 | | var totalFocusMinutes = pomodoroActivities.Sum(a => a.DurationMinutes); |
| | 12 | 363 | | var totalPomodoroCount = pomodoroActivities.Count; |
| | 12 | 364 | | var uniqueTasks = pomodoroActivities |
| | | 365 | | .Where(a => !string.IsNullOrEmpty(a.TaskName)) |
| | | 366 | | .Select(a => a.TaskName) |
| | 12 | 367 | | .Distinct() |
| | 12 | 368 | | .Count(); |
| | | 369 | | |
| | | 370 | | // Calculate daily average (divide by 7 for full week, or actual days with activity) |
| | 12 | 371 | | var daysWithActivity = currentWeekActivities |
| | | 372 | | .Select(a => a.CompletedAt.ToLocalTime().Date) |
| | 12 | 373 | | .Distinct() |
| | 12 | 374 | | .Count(); |
| | 12 | 375 | | var dailyAverageMinutes = (double)totalFocusMinutes / daysWithActivity; |
| | | 376 | | |
| | | 377 | | // Find most productive day |
| | 12 | 378 | | var dayTotals = pomodoroActivities |
| | | 379 | | .GroupBy(a => a.CompletedAt.ToLocalTime().DayOfWeek) |
| | | 380 | | .Select(g => new { Day = g.Key, Minutes = g.Sum(a => a.DurationMinutes) }) |
| | | 381 | | .OrderByDescending(g => g.Minutes) |
| | 12 | 382 | | .FirstOrDefault(); |
| | 12 | 383 | | var mostProductiveDay = dayTotals?.Day ?? DayOfWeek.Monday; |
| | | 384 | | |
| | | 385 | | // Calculate previous week focus minutes |
| | 12 | 386 | | var previousWeekFocusMinutes = previousWeekActivities |
| | | 387 | | .Where(a => a.Type == SessionType.Pomodoro) |
| | | 388 | | .Sum(a => a.DurationMinutes); |
| | | 389 | | |
| | | 390 | | // Calculate week-over-week change percentage |
| | 12 | 391 | | double weekOverWeekChange = 0; |
| | 12 | 392 | | if (previousWeekFocusMinutes > 0) |
| | 1 | 393 | | { |
| | 1 | 394 | | weekOverWeekChange = ((double)(totalFocusMinutes - previousWeekFocusMinutes) / previousWeekFocusMinutes) * 1 |
| | 1 | 395 | | } |
| | 11 | 396 | | else if (totalFocusMinutes > 0) |
| | 10 | 397 | | { |
| | | 398 | | // Previous week had no data, this week has data - treat as 100% increase |
| | 10 | 399 | | weekOverWeekChange = 100; |
| | 10 | 400 | | } |
| | | 401 | | |
| | 12 | 402 | | return new WeeklyStats |
| | 12 | 403 | | { |
| | 12 | 404 | | TotalFocusMinutes = totalFocusMinutes, |
| | 12 | 405 | | TotalPomodoroCount = totalPomodoroCount, |
| | 12 | 406 | | UniqueTasksWorkedOn = uniqueTasks, |
| | 12 | 407 | | DailyAverageMinutes = dailyAverageMinutes, |
| | 12 | 408 | | MostProductiveDay = mostProductiveDay, |
| | 12 | 409 | | PreviousWeekFocusMinutes = previousWeekFocusMinutes, |
| | 12 | 410 | | WeekOverWeekChange = weekOverWeekChange |
| | 12 | 411 | | }; |
| | | 412 | | } |
| | 2 | 413 | | catch (Exception ex) |
| | 2 | 414 | | { |
| | 2 | 415 | | _logger.LogError(ex, "Error calculating weekly stats for week starting {WeekStartDate}", weekStartDate); |
| | 2 | 416 | | return new WeeklyStats |
| | 2 | 417 | | { |
| | 2 | 418 | | TotalFocusMinutes = 0, |
| | 2 | 419 | | TotalPomodoroCount = 0, |
| | 2 | 420 | | UniqueTasksWorkedOn = 0, |
| | 2 | 421 | | DailyAverageMinutes = 0, |
| | 2 | 422 | | MostProductiveDay = DayOfWeek.Monday, |
| | 2 | 423 | | PreviousWeekFocusMinutes = 0, |
| | 2 | 424 | | WeekOverWeekChange = 0 |
| | 2 | 425 | | }; |
| | | 426 | | } |
| | 24 | 427 | | } |
| | | 428 | | |
| | | 429 | | public async Task AddActivityAsync(ActivityRecord activity) |
| | 70 | 430 | | { |
| | | 431 | | // Add to cache with thread safety and trim if needed |
| | 70 | 432 | | lock (_cacheLock) |
| | 70 | 433 | | { |
| | 70 | 434 | | _cachedActivities.Insert(0, activity); |
| | | 435 | | |
| | | 436 | | // Trim cache if it exceeds max size (remove oldest entries from end) |
| | | 437 | | // Track dates of removed activities to invalidate their caches |
| | 70 | 438 | | var datesToInvalidate = new HashSet<DateTime>(); |
| | | 439 | | |
| | 80 | 440 | | while (_cachedActivities.Count > Constants.Cache.MaxActivityCacheSize) |
| | 10 | 441 | | { |
| | 10 | 442 | | var removed = _cachedActivities[^1]; |
| | 10 | 443 | | datesToInvalidate.Add(removed.CompletedAt.ToLocalTime().Date); |
| | 10 | 444 | | _cachedActivities.RemoveAt(_cachedActivities.Count - 1); |
| | 10 | 445 | | } |
| | | 446 | | |
| | | 447 | | // Invalidate caches for all affected dates (removed activities + new activity) |
| | 230 | 448 | | foreach (var date in datesToInvalidate) |
| | 10 | 449 | | { |
| | 10 | 450 | | InvalidateDateCache(date); |
| | 10 | 451 | | } |
| | | 452 | | |
| | | 453 | | // Also invalidate the new activity's date |
| | 70 | 454 | | InvalidateDateCache(activity.CompletedAt.ToLocalTime().Date); |
| | 70 | 455 | | } |
| | | 456 | | |
| | 70 | 457 | | _logger.LogDebug(Constants.Messages.LogAddedActivityFormat, activity.Type, activity.CompletedAt, _cachedActiviti |
| | | 458 | | |
| | | 459 | | // Save to repository |
| | 70 | 460 | | await _activityRepository.SaveAsync(activity); |
| | | 461 | | |
| | 70 | 462 | | OnActivityChanged?.Invoke(); |
| | 70 | 463 | | } |
| | | 464 | | |
| | | 465 | | public async Task ClearAllActivitiesAsync() |
| | 30 | 466 | | { |
| | 30 | 467 | | lock (_cacheLock) |
| | 30 | 468 | | { |
| | 30 | 469 | | _cachedActivities.Clear(); |
| | 30 | 470 | | ClearAllDerivedCaches(); |
| | 30 | 471 | | } |
| | | 472 | | |
| | 30 | 473 | | await _activityRepository.ClearAllAsync(); |
| | 30 | 474 | | OnActivityChanged?.Invoke(); |
| | 30 | 475 | | } |
| | | 476 | | |
| | | 477 | | /// <summary> |
| | | 478 | | /// Gets activities for a date range directly from IndexedDB |
| | | 479 | | /// Useful for large datasets where caching everything isn't practical |
| | | 480 | | /// </summary> |
| | | 481 | | public async Task<List<ActivityRecord>> GetActivitiesByDateRangeAsync(DateTime from, DateTime to) |
| | 10 | 482 | | { |
| | 10 | 483 | | return await _activityRepository.GetByDateRangeAsync(from, to); |
| | 10 | 484 | | } |
| | | 485 | | |
| | | 486 | | /// <summary> |
| | | 487 | | /// Handles timer completion events from ITimerEventSubscriber |
| | | 488 | | /// Creates an activity record for the completed session |
| | | 489 | | /// </summary> |
| | | 490 | | public async Task HandleTimerCompletedAsync(TimerCompletedEventArgs args) |
| | 20 | 491 | | { |
| | 20 | 492 | | var activity = new ActivityRecord |
| | 20 | 493 | | { |
| | 20 | 494 | | Id = Guid.NewGuid(), |
| | 20 | 495 | | Type = args.SessionType, |
| | 20 | 496 | | TaskId = args.TaskId, |
| | 20 | 497 | | TaskName = args.TaskName, |
| | 20 | 498 | | CompletedAt = args.CompletedAt, |
| | 20 | 499 | | DurationMinutes = args.DurationMinutes, |
| | 20 | 500 | | WasCompleted = args.WasCompleted |
| | 20 | 501 | | }; |
| | | 502 | | |
| | 20 | 503 | | await AddActivityAsync(activity); |
| | 20 | 504 | | } |
| | | 505 | | |
| | | 506 | | #region Cache Management |
| | | 507 | | |
| | | 508 | | /// <summary> |
| | | 509 | | /// Invalidates all cached data for a specific date |
| | | 510 | | /// </summary> |
| | | 511 | | private void InvalidateDateCache(DateTime date) |
| | 92 | 512 | | { |
| | 92 | 513 | | var localDate = date.Date; |
| | 92 | 514 | | _activitiesByDate.Remove(localDate); |
| | 92 | 515 | | _dailyStatsCache.Remove(localDate); |
| | 92 | 516 | | _timeDistributionCache.Remove(localDate); |
| | 92 | 517 | | _logger.LogDebug("Invalidated cache for date: {Date}", localDate); |
| | 92 | 518 | | } |
| | | 519 | | |
| | | 520 | | /// <summary> |
| | | 521 | | /// Clears all derived caches (called when primary cache is cleared) |
| | | 522 | | /// </summary> |
| | | 523 | | private void ClearAllDerivedCaches() |
| | 50 | 524 | | { |
| | 50 | 525 | | _activitiesByDate.Clear(); |
| | 50 | 526 | | _dailyStatsCache.Clear(); |
| | 50 | 527 | | _timeDistributionCache.Clear(); |
| | 50 | 528 | | _logger.LogDebug("Cleared all derived caches"); |
| | 50 | 529 | | } |
| | | 530 | | |
| | | 531 | | /// <summary> |
| | | 532 | | /// Gets cache statistics for debugging/monitoring |
| | | 533 | | /// </summary> |
| | | 534 | | public (int ActivityCount, int DatesCached, int StatsCached, int DistributionCached) GetCacheStatistics() |
| | 10 | 535 | | { |
| | 10 | 536 | | lock (_cacheLock) |
| | 10 | 537 | | { |
| | 10 | 538 | | return ( |
| | 10 | 539 | | _cachedActivities.Count, |
| | 10 | 540 | | _activitiesByDate.Count, |
| | 10 | 541 | | _dailyStatsCache.Count, |
| | 10 | 542 | | _timeDistributionCache.Count |
| | 10 | 543 | | ); |
| | | 544 | | } |
| | 10 | 545 | | } |
| | | 546 | | |
| | | 547 | | /// <summary> |
| | | 548 | | /// Invalidates cached data for a specific activity by its date. |
| | | 549 | | /// Useful for future extensibility when activities can be modified or deleted individually. |
| | | 550 | | /// </summary> |
| | | 551 | | /// <param name="activityId">The ID of the activity that was modified/deleted</param> |
| | | 552 | | public void InvalidateCacheForActivity(Guid activityId) |
| | 14 | 553 | | { |
| | 14 | 554 | | lock (_cacheLock) |
| | 14 | 555 | | { |
| | | 556 | | // Find the activity in cache to get its date |
| | 26 | 557 | | var activity = _cachedActivities.FirstOrDefault(a => a.Id == activityId); |
| | 14 | 558 | | if (activity != null) |
| | 12 | 559 | | { |
| | 12 | 560 | | InvalidateDateCache(activity.CompletedAt.ToLocalTime().Date); |
| | 12 | 561 | | _logger.LogDebug("Invalidated cache for activity {ActivityId} on date {Date}", activityId, activity.Comp |
| | 12 | 562 | | } |
| | 14 | 563 | | } |
| | 14 | 564 | | } |
| | | 565 | | |
| | | 566 | | /// <summary> |
| | | 567 | | /// Invalidates cached data for a date range. |
| | | 568 | | /// Useful for bulk operations that affect multiple days. |
| | | 569 | | /// </summary> |
| | | 570 | | /// <param name="from">Start date (inclusive)</param> |
| | | 571 | | /// <param name="to">End date (inclusive)</param> |
| | | 572 | | public void InvalidateCacheForDateRange(DateTime from, DateTime to) |
| | 16 | 573 | | { |
| | 16 | 574 | | lock (_cacheLock) |
| | 16 | 575 | | { |
| | 16 | 576 | | var count = 0; |
| | 910 | 577 | | for (var date = from.Date; date <= to.Date; date = date.AddDays(1)) |
| | 439 | 578 | | { |
| | 439 | 579 | | _activitiesByDate.Remove(date); |
| | 439 | 580 | | _dailyStatsCache.Remove(date); |
| | 439 | 581 | | _timeDistributionCache.Remove(date); |
| | 439 | 582 | | count++; |
| | 439 | 583 | | } |
| | 16 | 584 | | _logger.LogDebug("Invalidated cache for {Count} dates from {From} to {To}", count, from.Date, to.Date); |
| | 16 | 585 | | } |
| | 16 | 586 | | } |
| | | 587 | | |
| | | 588 | | #endregion |
| | | 589 | | } |