| | | 1 | | using System.Web; |
| | | 2 | | using Pomodoro.Web.Models; |
| | | 3 | | using Pomodoro.Web.Services.Repositories; |
| | | 4 | | |
| | | 5 | | namespace Pomodoro.Web.Services; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Service for managing tasks using IndexedDB for persistent storage |
| | | 9 | | /// Implements ITimerEventSubscriber to handle timer completion events |
| | | 10 | | /// </summary> |
| | | 11 | | public class TaskService : ITaskService, ITimerEventSubscriber |
| | | 12 | | { |
| | | 13 | | private readonly ITaskRepository _taskRepository; |
| | | 14 | | private readonly IIndexedDbService _indexedDb; |
| | | 15 | | private readonly AppState _appState; |
| | | 16 | | |
| | | 17 | | public event Action? OnChange; |
| | | 18 | | |
| | 35 | 19 | | public List<TaskItem> Tasks => _appState.Tasks.Where(t => !t.IsDeleted).ToList(); |
| | 15 | 20 | | public IReadOnlyList<TaskItem> AllTasks => _appState.Tasks; // Includes soft-deleted tasks for history |
| | 10 | 21 | | public Guid? CurrentTaskId => _appState.CurrentTaskId; |
| | 2 | 22 | | public TaskItem? CurrentTask => _appState.CurrentTask; |
| | | 23 | | |
| | 56 | 24 | | public TaskService(ITaskRepository taskRepository, IIndexedDbService indexedDb, AppState appState) |
| | 56 | 25 | | { |
| | 56 | 26 | | _taskRepository = taskRepository; |
| | 56 | 27 | | _indexedDb = indexedDb; |
| | 56 | 28 | | _appState = appState; |
| | 56 | 29 | | } |
| | | 30 | | |
| | | 31 | | public async Task InitializeAsync() |
| | 56 | 32 | | { |
| | | 33 | | // Load tasks from repository |
| | 56 | 34 | | var tasks = await _taskRepository.GetAllIncludingDeletedAsync(); |
| | 56 | 35 | | if (tasks != null && tasks.Count > 0) |
| | 44 | 36 | | { |
| | 44 | 37 | | _appState.Tasks = tasks; |
| | 44 | 38 | | } |
| | | 39 | | |
| | | 40 | | // Load current task from app state store |
| | 56 | 41 | | var appState = await _indexedDb.GetAsync<AppStateRecord>(Constants.Storage.AppStateStore, Constants.Storage.Defa |
| | 56 | 42 | | if (appState?.CurrentTaskId.HasValue == true) |
| | 4 | 43 | | { |
| | 4 | 44 | | var taskId = appState.CurrentTaskId.Value; |
| | | 45 | | // Use thread-safe access to check if task exists |
| | 8 | 46 | | if (_appState.Tasks.Any(t => t.Id == taskId)) |
| | 3 | 47 | | { |
| | 3 | 48 | | _appState.CurrentTaskId = taskId; |
| | 3 | 49 | | } |
| | 4 | 50 | | } |
| | | 51 | | |
| | 56 | 52 | | NotifyStateChanged(); |
| | 56 | 53 | | } |
| | | 54 | | |
| | | 55 | | /// <summary> |
| | | 56 | | /// Reloads all task data from storage, refreshing the in-memory cache. |
| | | 57 | | /// Call this after import operations to reflect changes. |
| | | 58 | | /// </summary> |
| | | 59 | | public async Task ReloadAsync() |
| | 4 | 60 | | { |
| | | 61 | | // Reload tasks from repository |
| | 4 | 62 | | var tasks = await _taskRepository.GetAllIncludingDeletedAsync(); |
| | 4 | 63 | | _appState.Tasks = tasks ?? new List<TaskItem>(); |
| | | 64 | | |
| | | 65 | | // Clear current task selection if the task no longer exists |
| | 4 | 66 | | if (_appState.CurrentTaskId.HasValue) |
| | 1 | 67 | | { |
| | 2 | 68 | | if (!_appState.Tasks.Any(t => t.Id == _appState.CurrentTaskId.Value)) |
| | 1 | 69 | | { |
| | 1 | 70 | | _appState.CurrentTaskId = null; |
| | 1 | 71 | | } |
| | 1 | 72 | | } |
| | | 73 | | |
| | 4 | 74 | | NotifyStateChanged(); |
| | 4 | 75 | | } |
| | | 76 | | |
| | | 77 | | public async Task AddTaskAsync(string name) |
| | 8 | 78 | | { |
| | 8 | 79 | | var sanitized = SanitizeTaskName(name); |
| | | 80 | | |
| | | 81 | | // Validate that the task name is not empty after sanitization and within length limit |
| | 8 | 82 | | if (string.IsNullOrEmpty(sanitized) || sanitized.Length > Constants.UI.MaxTaskNameLength) |
| | 3 | 83 | | { |
| | 3 | 84 | | return; |
| | | 85 | | } |
| | | 86 | | |
| | 5 | 87 | | var task = new TaskItem |
| | 5 | 88 | | { |
| | 5 | 89 | | Id = Guid.NewGuid(), |
| | 5 | 90 | | Name = sanitized, |
| | 5 | 91 | | CreatedAt = DateTime.UtcNow, |
| | 5 | 92 | | IsCompleted = false, |
| | 5 | 93 | | TotalFocusMinutes = Constants.Tasks.InitialFocusMinutes, |
| | 5 | 94 | | PomodoroCount = Constants.Tasks.InitialPomodoroCount |
| | 5 | 95 | | }; |
| | | 96 | | |
| | | 97 | | // Insert task at beginning (thread-safe via AppState method) |
| | 5 | 98 | | _appState.InsertTask(task, Constants.Tasks.InsertAtBeginning); |
| | | 99 | | |
| | | 100 | | // Auto-select the new task |
| | 5 | 101 | | _appState.CurrentTaskId = task.Id; |
| | | 102 | | |
| | 5 | 103 | | await SaveTaskAsync(task); |
| | 5 | 104 | | await SaveCurrentTaskIdAsync(); |
| | 5 | 105 | | NotifyStateChanged(); |
| | 8 | 106 | | } |
| | | 107 | | |
| | | 108 | | public async Task UpdateTaskAsync(TaskItem task) |
| | 9 | 109 | | { |
| | 9 | 110 | | var sanitized = SanitizeTaskName(task.Name ?? string.Empty); |
| | | 111 | | |
| | | 112 | | // Validate that the task name is not empty after sanitization and within length limit |
| | 9 | 113 | | if (string.IsNullOrEmpty(sanitized) || sanitized.Length > Constants.UI.MaxTaskNameLength) |
| | 4 | 114 | | { |
| | 4 | 115 | | return; |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | // Thread-safe task update via AppState method |
| | 9 | 119 | | var updated = _appState.UpdateTask(task.Id, t => t.Name = sanitized); |
| | | 120 | | |
| | 5 | 121 | | if (updated) |
| | 4 | 122 | | { |
| | 4 | 123 | | var updatedTask = _appState.FindTaskById(task.Id); |
| | 4 | 124 | | if (updatedTask != null) |
| | 4 | 125 | | { |
| | 4 | 126 | | await SaveTaskAsync(updatedTask); |
| | 4 | 127 | | } |
| | 4 | 128 | | NotifyStateChanged(); |
| | 4 | 129 | | } |
| | 9 | 130 | | } |
| | | 131 | | |
| | | 132 | | public async Task DeleteTaskAsync(Guid taskId) |
| | 4 | 133 | | { |
| | 4 | 134 | | var updated = _appState.UpdateTask(taskId, t => |
| | 3 | 135 | | { |
| | 4 | 136 | | // Soft delete - mark as deleted but keep for history |
| | 3 | 137 | | t.IsDeleted = true; |
| | 3 | 138 | | t.DeletedAt = DateTime.UtcNow; |
| | 7 | 139 | | }); |
| | | 140 | | |
| | 4 | 141 | | if (updated) |
| | 3 | 142 | | { |
| | 3 | 143 | | if (_appState.CurrentTaskId == taskId) |
| | 1 | 144 | | { |
| | 1 | 145 | | _appState.CurrentTaskId = null; |
| | 1 | 146 | | await SaveCurrentTaskIdAsync(); |
| | 1 | 147 | | } |
| | | 148 | | |
| | 3 | 149 | | var deletedTask = _appState.FindTaskById(taskId); |
| | 3 | 150 | | if (deletedTask != null) |
| | 3 | 151 | | { |
| | 3 | 152 | | await SaveTaskAsync(deletedTask); |
| | 3 | 153 | | } |
| | 3 | 154 | | NotifyStateChanged(); |
| | 3 | 155 | | } |
| | 4 | 156 | | } |
| | | 157 | | |
| | | 158 | | public async Task CompleteTaskAsync(Guid taskId) |
| | 4 | 159 | | { |
| | 7 | 160 | | var updated = _appState.UpdateTask(taskId, t => t.IsCompleted = true); |
| | | 161 | | |
| | 4 | 162 | | if (updated) |
| | 3 | 163 | | { |
| | 3 | 164 | | var task = _appState.FindTaskById(taskId); |
| | 3 | 165 | | if (task != null) |
| | 3 | 166 | | { |
| | 3 | 167 | | await SaveTaskAsync(task); |
| | 3 | 168 | | } |
| | 3 | 169 | | NotifyStateChanged(); |
| | 3 | 170 | | } |
| | 4 | 171 | | } |
| | | 172 | | |
| | | 173 | | public async Task UncompleteTaskAsync(Guid taskId) |
| | 4 | 174 | | { |
| | 7 | 175 | | var updated = _appState.UpdateTask(taskId, t => t.IsCompleted = false); |
| | | 176 | | |
| | 4 | 177 | | if (updated) |
| | 3 | 178 | | { |
| | 3 | 179 | | var task = _appState.FindTaskById(taskId); |
| | 3 | 180 | | if (task != null) |
| | 3 | 181 | | { |
| | 3 | 182 | | await SaveTaskAsync(task); |
| | 3 | 183 | | } |
| | 3 | 184 | | NotifyStateChanged(); |
| | 3 | 185 | | } |
| | 4 | 186 | | } |
| | | 187 | | |
| | | 188 | | public async Task SelectTaskAsync(Guid taskId) |
| | 5 | 189 | | { |
| | 5 | 190 | | var task = _appState.FindTaskById(taskId); |
| | | 191 | | |
| | 5 | 192 | | if (task != null && !task.IsCompleted) |
| | 3 | 193 | | { |
| | 3 | 194 | | _appState.CurrentTaskId = taskId; |
| | 3 | 195 | | await SaveCurrentTaskIdAsync(); |
| | 3 | 196 | | NotifyStateChanged(); |
| | 3 | 197 | | } |
| | 5 | 198 | | } |
| | | 199 | | |
| | | 200 | | public async Task AddTimeToTaskAsync(Guid taskId, int minutes) |
| | 8 | 201 | | { |
| | | 202 | | // Validate that minutes is a positive value |
| | 8 | 203 | | if (minutes <= 0) |
| | 2 | 204 | | { |
| | 2 | 205 | | return; |
| | | 206 | | } |
| | | 207 | | |
| | 6 | 208 | | var updated = _appState.UpdateTask(taskId, t => |
| | 5 | 209 | | { |
| | 5 | 210 | | t.TotalFocusMinutes += minutes; |
| | 5 | 211 | | t.PomodoroCount++; |
| | 5 | 212 | | t.LastWorkedOn = DateTime.UtcNow; |
| | 11 | 213 | | }); |
| | | 214 | | |
| | 6 | 215 | | if (updated) |
| | 5 | 216 | | { |
| | 5 | 217 | | var task = _appState.FindTaskById(taskId); |
| | 5 | 218 | | if (task != null) |
| | 5 | 219 | | { |
| | 5 | 220 | | await SaveTaskAsync(task); |
| | 5 | 221 | | } |
| | 5 | 222 | | NotifyStateChanged(); |
| | 5 | 223 | | } |
| | 8 | 224 | | } |
| | | 225 | | |
| | | 226 | | public async Task SaveAsync() |
| | 1 | 227 | | { |
| | | 228 | | // Save all tasks to IndexedDB (thread-safe copy) |
| | 1 | 229 | | var tasksToSave = _appState.Tasks.ToList(); |
| | 1 | 230 | | await _indexedDb.PutAllAsync(Constants.Storage.TasksStore, tasksToSave); |
| | 1 | 231 | | } |
| | | 232 | | |
| | | 233 | | private async Task SaveTaskAsync(TaskItem task) |
| | 23 | 234 | | { |
| | 23 | 235 | | await _taskRepository.SaveAsync(task); |
| | 23 | 236 | | } |
| | | 237 | | |
| | | 238 | | private async Task SaveCurrentTaskIdAsync() |
| | 9 | 239 | | { |
| | 9 | 240 | | var appStateRecord = new AppStateRecord |
| | 9 | 241 | | { |
| | 9 | 242 | | Id = Constants.Storage.DefaultSettingsId, |
| | 9 | 243 | | CurrentTaskId = _appState.CurrentTaskId |
| | 9 | 244 | | }; |
| | 9 | 245 | | await _indexedDb.PutAsync(Constants.Storage.AppStateStore, appStateRecord); |
| | 9 | 246 | | } |
| | | 247 | | |
| | | 248 | | /// <summary> |
| | | 249 | | /// Handles timer completion events from ITimerEventSubscriber |
| | | 250 | | /// Updates task time when a pomodoro completes |
| | | 251 | | /// </summary> |
| | | 252 | | public async Task HandleTimerCompletedAsync(TimerCompletedEventArgs args) |
| | 3 | 253 | | { |
| | | 254 | | // Only process completed pomodoro sessions with a task |
| | 3 | 255 | | if (args.SessionType != SessionType.Pomodoro || !args.TaskId.HasValue) |
| | 2 | 256 | | return; |
| | | 257 | | |
| | 1 | 258 | | await AddTimeToTaskAsync(args.TaskId.Value, args.DurationMinutes); |
| | 3 | 259 | | } |
| | | 260 | | |
| | | 261 | | /// <summary> |
| | | 262 | | /// Sanitizes task name by trimming and HTML-encoding to prevent XSS attacks. |
| | | 263 | | /// Blazor generally escapes content automatically, but this provides defense-in-depth. |
| | | 264 | | /// </summary> |
| | | 265 | | private static string SanitizeTaskName(string name) |
| | 17 | 266 | | { |
| | 21 | 267 | | if (string.IsNullOrEmpty(name)) return string.Empty; |
| | | 268 | | |
| | | 269 | | // Trim whitespace |
| | 13 | 270 | | var trimmed = name.Trim(); |
| | | 271 | | |
| | | 272 | | // HTML encode to prevent XSS (defense-in-depth, Blazor escapes automatically) |
| | 13 | 273 | | return HttpUtility.HtmlEncode(trimmed); |
| | 17 | 274 | | } |
| | | 275 | | |
| | | 276 | | private void NotifyStateChanged() |
| | 86 | 277 | | { |
| | 86 | 278 | | OnChange?.Invoke(); |
| | 86 | 279 | | } |
| | | 280 | | |
| | | 281 | | /// <summary> |
| | | 282 | | /// Record for storing app state in IndexedDB |
| | | 283 | | /// </summary> |
| | | 284 | | public class AppStateRecord |
| | | 285 | | { |
| | 22 | 286 | | public string Id { get; set; } = Constants.Storage.DefaultSettingsId; |
| | 21 | 287 | | public Guid? CurrentTaskId { get; set; } |
| | | 288 | | } |
| | | 289 | | } |