< Summary

Information
Class: Pomodoro.Web.Services.TimerService
Assembly: Pomodoro.Web
File(s): /home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/TimerService.cs
Line coverage
100%
Covered lines: 333
Uncovered lines: 0
Coverable lines: 333
Total lines: 520
Line coverage: 100%
Branch coverage
100%
Covered branches: 76
Total branches: 76
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_RemainingSeconds()100%22100%
get_TickCount()100%11100%
get_CurrentSession()100%11100%
get_Settings()100%11100%
get_IsRunning()100%22100%
get_IsPaused()100%44100%
get_IsStarted()100%22100%
get_CurrentSessionType()100%22100%
get_RemainingTime()100%11100%
InitializeAsync()100%1010100%
OnTimerTickJs()100%1010100%
StartPomodoroAsync()100%11100%
StartShortBreakAsync()100%11100%
StartLongBreakAsync()100%11100%
SwitchSessionTypeAsync()100%22100%
PauseAsync()100%44100%
ResumeAsync()100%44100%
ResetAsync()100%22100%
UpdateSettingsAsync()100%44100%
SaveSettingsAsync()100%11100%
StartJsTimerAsync()100%22100%
StopJsTimer()100%11100%
HandleTimerCompleteAsync()100%1212100%
HandleTimerCompleteSafeAsync()100%44100%
SaveDailyStatsAsync()100%11100%
NotifyTick()100%22100%
NotifyTimerCompletedAsync()100%44100%
NotifyStateChanged()100%22100%
DisposeAsync()100%22100%

File(s)

/home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/TimerService.cs

#LineLine coverage
 1using System.Threading;
 2using Microsoft.JSInterop;
 3using Microsoft.Extensions.Logging;
 4using Pomodoro.Web.Models;
 5using Pomodoro.Web.Services.Repositories;
 6
 7namespace Pomodoro.Web.Services;
 8
 9/// <summary>
 10/// Service for managing the pomodoro timer.
 11/// Uses JavaScript interop for reliable timer in Blazor WebAssembly.
 12/// Uses IndexedDB for persistent storage.
 13/// Implements event publisher pattern to decouple from TaskService and ActivityService.
 14/// </summary>
 15public class TimerService : ITimerService, ITimerEventPublisher, IAsyncDisposable
 16{
 17    private readonly IIndexedDbService _indexedDb;
 18    private readonly ISettingsRepository _settingsRepository;
 19    private readonly AppState _appState;
 20    private readonly IJSRuntime _jsRuntime;
 21    private readonly ILogger<TimerService> _logger;
 22    private DotNetObjectReference<TimerService>? _dotNetRef;
 23    private SynchronizationContext? _syncContext;
 21724    private readonly SemaphoreSlim _timerCompleteLock = new(Constants.Threading.SemaphoreInitialCount, Constants.Threadi
 21725    private readonly object _timerTickLock = new();
 26    private bool _isDisposed;
 27
 28    // ITimerEventPublisher events
 29    public event Func<TimerCompletedEventArgs, Task>? OnTimerCompleted;
 30    public event Action? OnTimerStateChanged;
 31
 32    // ITimerService events
 33    public event Action? OnTick;
 34    public event Action<SessionType>? OnTimerComplete; // Backward compatibility
 35    public event Action? OnStateChanged;
 36
 37    // Public properties for UI binding
 938    public int RemainingSeconds => _appState.CurrentSession?.RemainingSeconds ?? 0;
 9439    public int TickCount { get; private set; } // Used to force UI updates
 40
 141    public TimerSession? CurrentSession => _appState.CurrentSession;
 742    public TimerSettings Settings => _appState.Settings;
 2043    public bool IsRunning => _appState.CurrentSession?.IsRunning ?? false;
 44    // IsPaused requires WasStarted to distinguish between "reset but not started" and "started then paused"
 45    // This ensures PiP toggle logic correctly calls StartPomodoroAsync instead of ResumeAsync after reset
 1646    public bool IsPaused => _appState.CurrentSession != null && !_appState.CurrentSession.IsRunning && _appState.Current
 447    public bool IsStarted => _appState.CurrentSession?.WasStarted ?? false;
 1448    public SessionType CurrentSessionType => _appState.CurrentSession?.Type ?? SessionType.Pomodoro;
 849    public TimeSpan RemainingTime => TimeSpan.FromSeconds(RemainingSeconds);
 50
 21751    public TimerService(
 21752        IIndexedDbService indexedDb,
 21753        ISettingsRepository settingsRepository,
 21754        AppState appState,
 21755        IJSRuntime jsRuntime,
 21756        ILogger<TimerService> logger)
 21757    {
 21758        _indexedDb = indexedDb;
 21759        _settingsRepository = settingsRepository;
 21760        _appState = appState;
 21761        _jsRuntime = jsRuntime;
 21762        _logger = logger;
 21763    }
 64
 65    public async Task InitializeAsync()
 12766    {
 67        // Capture the synchronization context for UI updates
 12768        _syncContext = SynchronizationContext.Current;
 69
 70        // Load settings from repository
 12771        var settings = await _settingsRepository.GetAsync();
 12772        if (settings != null)
 2473        {
 2474            _appState.Settings = settings;
 2475        }
 76
 77        // Load daily stats from IndexedDB
 12778        var todayKey = AppState.GetCurrentDayKey().ToString(Constants.DateFormats.IsoFormat);
 12779        var dailyStats = await _indexedDb.GetAsync<DailyStats>(Constants.Storage.DailyStatsStore, todayKey);
 12780        if (dailyStats != null)
 2581        {
 82            // Check if the stats are from today (using UTC date = 6 AM Bangladesh time reset)
 2583            var currentDayKey = AppState.GetCurrentDayKey();
 2584            if (dailyStats.Date == currentDayKey)
 1385            {
 86                // Stats are from today, restore them
 1387                _appState.TodayTotalFocusMinutes = dailyStats.TotalFocusMinutes;
 1388                _appState.TodayPomodoroCount = dailyStats.PomodoroCount;
 1389                _appState.TodayTaskIdsWorkedOn = dailyStats.TaskIdsWorkedOn ?? new List<Guid>();
 1390                _appState.LastResetDate = dailyStats.Date;
 1391            }
 92            else
 1293            {
 94                // Stats are from a previous day, reset them
 1295                _appState.ResetDailyStats();
 1296            }
 2597        }
 98        else
 10299        {
 100            // No saved stats, initialize fresh
 102101            _appState.ResetDailyStats();
 102102        }
 103
 104        // Initialize with a default Pomodoro session if none exists
 127105        if (_appState.CurrentSession == null)
 127106        {
 127107            var durationSeconds = _appState.Settings.PomodoroMinutes * Constants.TimeConversion.SecondsPerMinute;
 127108            _appState.CurrentSession = new TimerSession
 127109            {
 127110                Id = Guid.NewGuid(),
 127111                TaskId = null,
 127112                Type = SessionType.Pomodoro,
 127113                StartedAt = DateTime.UtcNow,
 127114                DurationSeconds = durationSeconds,
 127115                RemainingSeconds = durationSeconds,
 127116                IsRunning = false,
 127117                IsCompleted = false
 127118            };
 127119        }
 120
 121        // Create dotnet reference for JS callbacks
 127122        _dotNetRef = DotNetObjectReference.Create(this);
 123
 124        // Initialize JavaScript constants with user settings for chart time calculations
 127125        await _indexedDb.InitializeJsConstantsAsync(
 127126            _appState.Settings.PomodoroMinutes,
 127127            _appState.Settings.ShortBreakMinutes,
 127128            _appState.Settings.LongBreakMinutes);
 129
 127130        NotifyStateChanged();
 127131    }
 132
 133    // Called from JavaScript
 134    [JSInvokable(Constants.JsInvokableMethods.OnTimerTick)]
 135    public void OnTimerTickJs()
 42136    {
 137        // Check if day has changed and reset daily stats if needed
 42138        if (_appState.NeedsDailyReset())
 3139        {
 3140            _appState.ResetDailyStats();
 3141        }
 142
 143        // Use lock to ensure thread-safe access to session state
 144        // This prevents race conditions if JS callback fires during other state modifications
 42145        lock (_timerTickLock)
 42146        {
 42147            if (_appState.CurrentSession == null || !_appState.CurrentSession.IsRunning)
 4148            {
 4149                return;
 150            }
 151
 38152            _appState.CurrentSession.RemainingSeconds--;
 38153            TickCount++; // Increment to force UI update detection
 154
 38155            if (_appState.CurrentSession.RemainingSeconds <= 0)
 33156            {
 157                // Use SafeTaskRunner for consistent fire-and-forget handling with error logging
 33158                SafeTaskRunner.RunAndForget(
 33159                    HandleTimerCompleteSafeAsync,
 33160                    _logger,
 33161                    Constants.SafeTaskOperations.TimerComplete
 33162                );
 33163                return;
 164            }
 5165        }
 166
 167        // Use synchronization context to ensure UI update happens on main thread
 5168        if (_syncContext != null)
 1169        {
 2170            _syncContext.Post(_ => NotifyTick(), null);
 1171        }
 172        else
 4173        {
 4174            NotifyTick();
 4175        }
 42176    }
 177
 178    public async Task StartPomodoroAsync(Guid? taskId = null)
 62179    {
 62180        var durationSeconds = _appState.Settings.GetDurationSeconds(SessionType.Pomodoro);
 181
 62182        _appState.CurrentSession = new TimerSession
 62183        {
 62184            Id = Guid.NewGuid(),
 62185            TaskId = taskId,
 62186            Type = SessionType.Pomodoro,
 62187            StartedAt = DateTime.UtcNow,
 62188            DurationSeconds = durationSeconds,
 62189            RemainingSeconds = durationSeconds,
 62190            IsRunning = true,
 62191            IsCompleted = false,
 62192            WasStarted = true
 62193        };
 194
 62195        NotifyStateChanged();
 62196        await StartJsTimerAsync();
 62197    }
 198
 199    public async Task StartShortBreakAsync()
 12200    {
 12201        var durationSeconds = _appState.Settings.GetDurationSeconds(SessionType.ShortBreak);
 202
 12203        _appState.CurrentSession = new TimerSession
 12204        {
 12205            Id = Guid.NewGuid(),
 12206            TaskId = null,
 12207            Type = SessionType.ShortBreak,
 12208            StartedAt = DateTime.UtcNow,
 12209            DurationSeconds = durationSeconds,
 12210            RemainingSeconds = durationSeconds,
 12211            IsRunning = true,
 12212            IsCompleted = false,
 12213            WasStarted = true
 12214        };
 215
 12216        NotifyStateChanged();
 12217        await StartJsTimerAsync();
 12218    }
 219
 220    public async Task StartLongBreakAsync()
 9221    {
 9222        var durationSeconds = _appState.Settings.GetDurationSeconds(SessionType.LongBreak);
 223
 9224        _appState.CurrentSession = new TimerSession
 9225        {
 9226            Id = Guid.NewGuid(),
 9227            TaskId = null,
 9228            Type = SessionType.LongBreak,
 9229            StartedAt = DateTime.UtcNow,
 9230            DurationSeconds = durationSeconds,
 9231            RemainingSeconds = durationSeconds,
 9232            IsRunning = true,
 9233            IsCompleted = false,
 9234            WasStarted = true
 9235        };
 236
 9237        NotifyStateChanged();
 9238        await StartJsTimerAsync();
 9239    }
 240
 241    public async Task SwitchSessionTypeAsync(SessionType sessionType)
 14242    {
 243        // Stop current timer
 14244        await StopJsTimer();
 245
 246        // Get duration for the new session type using helper method
 14247        var durationSeconds = _appState.Settings.GetDurationSeconds(sessionType);
 248
 249        // Create new session (not running, just prepared)
 14250        _appState.CurrentSession = new TimerSession
 14251        {
 14252            Id = Guid.NewGuid(),
 14253            TaskId = _appState.CurrentSession?.TaskId,
 14254            Type = sessionType,
 14255            StartedAt = DateTime.UtcNow,
 14256            DurationSeconds = durationSeconds,
 14257            RemainingSeconds = durationSeconds,
 14258            IsRunning = false,
 14259            IsCompleted = false
 14260        };
 261
 14262        NotifyStateChanged();
 14263    }
 264
 265    public async Task PauseAsync()
 11266    {
 11267        if (_appState.CurrentSession != null && _appState.CurrentSession.IsRunning)
 9268        {
 9269            _appState.CurrentSession.IsRunning = false;
 9270            await StopJsTimer();
 9271            NotifyStateChanged();
 9272        }
 11273    }
 274
 275    public async Task ResumeAsync()
 10276    {
 10277        if (_appState.CurrentSession != null && !_appState.CurrentSession.IsRunning)
 8278        {
 8279            _appState.CurrentSession.IsRunning = true;
 8280            NotifyStateChanged();
 8281            await StartJsTimerAsync();
 8282        }
 10283    }
 284
 285    public async Task ResetAsync()
 16286    {
 16287        await StopJsTimer();
 288
 289        // Reset tick count to prevent potential overflow
 16290        TickCount = 0;
 291
 16292        if (_appState.CurrentSession != null)
 15293        {
 294            // Use helper method to get duration for current session type
 15295            var durationSeconds = _appState.Settings.GetDurationSeconds(_appState.CurrentSession.Type);
 296
 15297            _appState.CurrentSession.DurationSeconds = durationSeconds;
 15298            _appState.CurrentSession.RemainingSeconds = durationSeconds;
 15299            _appState.CurrentSession.IsRunning = false;
 15300            _appState.CurrentSession.WasStarted = false;
 15301        }
 302
 16303        NotifyStateChanged();
 16304    }
 305
 306    public async Task UpdateSettingsAsync(TimerSettings settings)
 11307    {
 11308        _appState.Settings = settings;
 11309        await SaveSettingsAsync();
 310
 311        // Update current session duration if timer hasn't started yet
 11312        if (_appState.CurrentSession != null && !_appState.CurrentSession.WasStarted)
 4313        {
 4314            var durationSeconds = settings.GetDurationSeconds(_appState.CurrentSession.Type);
 4315            _appState.CurrentSession.DurationSeconds = durationSeconds;
 4316            _appState.CurrentSession.RemainingSeconds = durationSeconds;
 4317        }
 318
 319        // Initialize JS constants with new settings for chart time calculations
 11320        await _indexedDb.InitializeJsConstantsAsync(settings.PomodoroMinutes, settings.ShortBreakMinutes, settings.LongB
 321
 11322        NotifyStateChanged();
 11323    }
 324
 325    public async Task SaveSettingsAsync()
 11326    {
 11327        await _settingsRepository.SaveAsync(_appState.Settings);
 11328    }
 329
 330    private async Task StartJsTimerAsync()
 91331    {
 332        // Create the reference only once - it will be disposed in DisposeAsync()
 91333        _dotNetRef ??= DotNetObjectReference.Create(this);
 334
 335        // Unlock audio context on user interaction (timer start)
 336        // This is required for browser autoplay policies
 337        try
 91338        {
 91339            await _jsRuntime.InvokeVoidAsync(Constants.NotificationJsFunctions.UnlockAudio);
 90340        }
 1341        catch (Exception ex)
 1342        {
 343            // Audio unlock may fail on some browsers - log for debugging but don't block timer
 1344            _logger.LogDebug(ex, Constants.Messages.AudioUnlockFailed);
 1345        }
 346
 347        try
 91348        {
 91349            await _jsRuntime.InvokeVoidAsync(Constants.JsFunctions.TimerStart, _dotNetRef);
 89350        }
 2351        catch (Exception ex)
 2352        {
 353            // Log the error and retry with a delay
 2354            _logger.LogWarning(ex, Constants.Messages.TimerStartFailed);
 355
 356            try
 2357            {
 358                // Add a small delay before retry to allow JS runtime to stabilize
 2359                await Task.Delay(100);
 2360                await _jsRuntime.InvokeVoidAsync(Constants.JsFunctions.TimerStart, _dotNetRef);
 1361            }
 1362            catch (Exception retryEx)
 1363            {
 1364                _logger.LogError(retryEx, Constants.Messages.TimerStartFailedAfterRetry);
 365                // Don't rethrow - the timer not starting is not critical, user can try again
 1366            }
 2367        }
 91368    }
 369
 370    private async Task StopJsTimer()
 82371    {
 372        try
 82373        {
 82374            await _jsRuntime.InvokeVoidAsync(Constants.JsFunctions.TimerStop);
 81375        }
 1376        catch (Exception ex)
 1377        {
 1378            _logger.LogWarning(ex, Constants.Messages.TimerStopFailed);
 1379        }
 82380    }
 381
 382    private async Task HandleTimerCompleteAsync()
 30383    {
 30384        await StopJsTimer();
 385
 30386        var session = _appState.CurrentSession;
 31387        if (session == null) return;
 388
 29389        session.IsRunning = false;
 29390        session.IsCompleted = true;
 391
 392        // Reset remaining seconds back to full duration for display
 29393        session.RemainingSeconds = session.DurationSeconds;
 394
 395        // Get task name for event args (thread-safe)
 29396        string? taskName = null;
 29397        if (session.TaskId.HasValue)
 24398        {
 34399            var task = _appState.Tasks.FirstOrDefault(t => t.Id == session.TaskId.Value);
 24400            taskName = task?.Name;
 24401        }
 402
 403        // Calculate duration using helper method
 29404        var durationMinutes = _appState.Settings.GetDurationMinutes(session.Type);
 405
 406        // If pomodoro completed, update today's stats
 29407        if (session.Type == SessionType.Pomodoro && session.TaskId.HasValue)
 24408        {
 409            // Update today's stats
 24410            _appState.TodayTotalFocusMinutes += durationMinutes;
 24411            _appState.TodayPomodoroCount++;
 412
 413            // Track unique task worked on today (thread-safe, avoids duplicates)
 24414            _appState.AddTodayTaskId(session.TaskId.Value);
 415
 416            // Persist daily stats to storage
 24417            await SaveDailyStatsAsync();
 22418        }
 419
 420        // Create event args
 27421        var eventArgs = new TimerCompletedEventArgs(
 27422            session.Type,
 27423            session.TaskId,
 27424            taskName,
 27425            durationMinutes,
 27426            WasCompleted: true,
 27427            CompletedAt: DateTime.UtcNow
 27428        );
 429
 430        // Raise event for subscribers (TaskService, ActivityService)
 27431        await NotifyTimerCompletedAsync(eventArgs);
 432
 433        // Backward compatibility - also raise old event
 27434        OnTimerComplete?.Invoke(session.Type);
 435
 27436        NotifyStateChanged();
 437
 438        // Note: Auto-start is handled by ConsentService which shows a consent modal
 439        // When auto-start is enabled, the modal appears with a countdown
 440        // When auto-start is disabled, no modal appears and user manually starts next session
 28441    }
 442
 443    /// <summary>
 444    /// Safe async wrapper for HandleTimerCompleteAsync to avoid fire-and-forget issues
 445    /// Uses semaphore to prevent concurrent timer completion handling
 446    /// </summary>
 447    private async Task HandleTimerCompleteSafeAsync()
 35448    {
 40449        if (_isDisposed) return;
 450
 451        // Try to acquire lock - if another completion is in progress, skip this one
 30452        if (!await _timerCompleteLock.WaitAsync(0))
 1453        {
 1454            return;
 455        }
 456
 457        try
 29458        {
 29459            await HandleTimerCompleteAsync();
 27460        }
 2461        catch (Exception ex)
 2462        {
 2463            _logger.LogError(ex, Constants.Messages.TimerHandleCompleteError);
 2464        }
 465        finally
 29466        {
 87467            try { _timerCompleteLock.Release(); } catch (ObjectDisposedException) { }
 29468        }
 35469    }
 470
 471    private async Task SaveDailyStatsAsync()
 24472    {
 24473        var stats = new DailyStats
 24474        {
 24475            Date = AppState.GetCurrentDayKey(),
 24476            TotalFocusMinutes = _appState.TodayTotalFocusMinutes,
 24477            PomodoroCount = _appState.TodayPomodoroCount,
 24478            TaskIdsWorkedOn = _appState.TodayTaskIdsWorkedOn
 24479        };
 24480        await _indexedDb.PutAsync(Constants.Storage.DailyStatsStore, stats);
 22481    }
 482
 483    private void NotifyTick()
 5484    {
 5485        OnTick?.Invoke();
 5486    }
 487
 488    private async Task NotifyTimerCompletedAsync(TimerCompletedEventArgs args)
 27489    {
 27490        if (OnTimerCompleted != null)
 3491        {
 492            // Get all handlers and invoke them
 3493            var handlers = OnTimerCompleted.GetInvocationList();
 21494            foreach (var handler in handlers)
 6495            {
 496                try
 6497                {
 6498                    await ((Func<TimerCompletedEventArgs, Task>)handler)(args);
 5499                }
 1500                catch (Exception ex)
 1501                {
 1502                    _logger.LogError(ex, Constants.Messages.TimerCompletionHandlerError);
 1503                }
 6504            }
 3505        }
 27506    }
 507
 508    private void NotifyStateChanged()
 295509    {
 295510        OnStateChanged?.Invoke();
 295511    }
 512
 513    public async ValueTask DisposeAsync()
 13514    {
 13515        _isDisposed = true;
 13516        await StopJsTimer();
 13517        _dotNetRef?.Dispose();
 13518        _timerCompleteLock.Dispose();
 13519    }
 520}