diff --git a/TaskTracker.Api/Pages/Board.cshtml.cs b/TaskTracker.Api/Pages/Board.cshtml.cs index 8252812..e27a408 100644 --- a/TaskTracker.Api/Pages/Board.cshtml.cs +++ b/TaskTracker.Api/Pages/Board.cshtml.cs @@ -167,6 +167,113 @@ public class BoardModel : PageModel return await ReturnBoardContentAsync(); } + // Load task detail panel + public async Task OnGetTaskDetailAsync(int id) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + return Partial("Partials/_TaskDetail", task); + } + + // Update task fields (inline edit) + public async Task OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + if (title is not null) task.Title = title; + if (description is not null) task.Description = description; + if (category is not null) task.Category = category; + if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes; + + await _taskRepo.UpdateAsync(task); + return Partial("Partials/_TaskDetail", task); + } + + // Add subtask + public async Task OnPostAddSubtaskAsync(int id, string title) + { + var parent = await _taskRepo.GetByIdAsync(id); + if (parent is null) return NotFound(); + + var subtask = new WorkTask + { + Title = title, + ParentTaskId = id, + Status = WorkTaskStatus.Pending + }; + await _taskRepo.CreateAsync(subtask); + + // Reload parent to get updated subtask list + parent = await _taskRepo.GetByIdAsync(id); + return Partial("Partials/_SubtaskList", parent); + } + + // Complete subtask + public async Task OnPutCompleteSubtaskAsync(int id) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + task.Status = WorkTaskStatus.Completed; + task.CompletedAt = DateTime.UtcNow; + await _taskRepo.UpdateAsync(task); + + // Return parent's subtask list + if (task.ParentTaskId.HasValue) + { + var parent = await _taskRepo.GetByIdAsync(task.ParentTaskId.Value); + if (parent is not null) + return Partial("Partials/_SubtaskList", parent); + } + return Content(""); + } + + // Add note + public async Task OnPostAddNoteAsync(int id, string content) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + task.Notes.Add(new TaskNote + { + Content = content, + Type = NoteType.General, + CreatedAt = DateTime.UtcNow + }); + await _taskRepo.UpdateAsync(task); + + return Partial("Partials/_NotesList", task); + } + + // Search tasks (for Ctrl+K modal) + public async Task OnGetSearchAsync(string? q) + { + var allTasks = await _taskRepo.GetAllAsync(); + List results; + + if (string.IsNullOrWhiteSpace(q)) + { + results = allTasks + .Where(t => t.Status is WorkTaskStatus.Active or WorkTaskStatus.Paused or WorkTaskStatus.Pending) + .OrderByDescending(t => t.Status == WorkTaskStatus.Active) + .Take(8) + .ToList(); + } + else + { + results = allTasks + .Where(t => + t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || + (t.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) || + (t.Category?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)) + .Take(10) + .ToList(); + } + + return Partial("Partials/_SearchResults", results); + } + private async Task ReturnBoardContentAsync() { await LoadBoardDataAsync(); diff --git a/TaskTracker.Api/Pages/Partials/_NotesList.cshtml b/TaskTracker.Api/Pages/Partials/_NotesList.cshtml new file mode 100644 index 0000000..f734f4f --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_NotesList.cshtml @@ -0,0 +1,64 @@ +@using TaskTracker.Core.Enums +@model TaskTracker.Core.Entities.WorkTask + +@functions { + static string FormatRelativeTime(DateTime dt) + { + var diff = DateTime.UtcNow - dt; + if (diff.TotalMinutes < 1) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 2) return "yesterday"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)}w ago"; + return dt.ToLocalTime().ToString("MMM d, yyyy"); + } + + static string GetNoteTypeCssClass(NoteType type) + { + return type switch + { + NoteType.PauseNote => "note-type-badge--pause", + NoteType.ResumeNote => "note-type-badge--resume", + _ => "note-type-badge--general" + }; + } + + static string GetNoteTypeLabel(NoteType type) + { + return type switch + { + NoteType.PauseNote => "Pause", + NoteType.ResumeNote => "Resume", + _ => "General" + }; + } +} + +
+ + + @foreach (var note in Model.Notes.OrderBy(n => n.CreatedAt)) + { +
+
+ + @GetNoteTypeLabel(note.Type) + + @FormatRelativeTime(note.CreatedAt) +
+
@note.Content
+
+ } + + +
+ +
+
diff --git a/TaskTracker.Api/Pages/Partials/_SearchResults.cshtml b/TaskTracker.Api/Pages/Partials/_SearchResults.cshtml new file mode 100644 index 0000000..2eb8092 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_SearchResults.cshtml @@ -0,0 +1,33 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model List + +@* Placeholder — full implementation in Task 6 (Search Modal) *@ +
+ @if (Model.Count == 0) + { +
+ No results found +
+ } + else + { + @foreach (var task in Model) + { +
+ + @task.Status + + @task.Title + @if (!string.IsNullOrEmpty(task.Category)) + { + @task.Category + } +
+ } + } +
diff --git a/TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml b/TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml new file mode 100644 index 0000000..1eee723 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml @@ -0,0 +1,46 @@ +@using TaskTracker.Core.Enums +@model TaskTracker.Core.Entities.WorkTask + +
+ + + @foreach (var sub in Model.SubTasks) + { +
+ @if (sub.Status == WorkTaskStatus.Completed) + { + + + + + + @sub.Title + } + else + { + + @sub.Title + } +
+ } + + +
+ +
+
diff --git a/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml b/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml new file mode 100644 index 0000000..cad3511 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml @@ -0,0 +1,323 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model TaskTracker.Core.Entities.WorkTask + +@{ + var statusColors = new Dictionary + { + [WorkTaskStatus.Pending] = ("#64748b", "Pending"), + [WorkTaskStatus.Active] = ("#3b82f6", "Active"), + [WorkTaskStatus.Paused] = ("#eab308", "Paused"), + [WorkTaskStatus.Completed] = ("#22c55e", "Completed"), + [WorkTaskStatus.Abandoned] = ("#ef4444", "Abandoned"), + }; + var (statusColor, statusLabel) = statusColors[Model.Status]; + var catColor = BoardModel.GetCategoryColor(Model.Category); + var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt); + + double? progressPercent = null; + if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue) + { + var start = Model.StartedAt.Value; + var end = Model.CompletedAt ?? DateTime.UtcNow; + var elapsedMins = (end - start).TotalMinutes; + progressPercent = Math.Min(100, (elapsedMins / Model.EstimatedMinutes.Value) * 100); + } + + var isTerminal = Model.Status is WorkTaskStatus.Completed or WorkTaskStatus.Abandoned; +} + + +
+ + +
+ +
+
+
+

@Model.Title

+ +
+ +
+ + +
+ @statusLabel + +
+ @if (!string.IsNullOrEmpty(Model.Category)) + { + + + @Model.Category + + } + else + { + + + category + + } + +
+
+
+ + +
+ +
+ +
+ @if (!string.IsNullOrEmpty(Model.Description)) + { +
@Model.Description
+ } + else + { +
+ Click to add description... +
+ } + +
+
+ + +
+ +
+
+ Elapsed + @elapsed +
+ +
+ Estimate +
+ + @(Model.EstimatedMinutes.HasValue ? $"{Model.EstimatedMinutes}m" : "--") + + +
+
+ + @if (progressPercent.HasValue) + { + var isOver = progressPercent.Value >= 100; +
+
+
+ } +
+
+ + +
+ +
+ + +
+ +
+
+ + + @if (!isTerminal) + { +
+ @switch (Model.Status) + { + case WorkTaskStatus.Pending: + + + break; + + case WorkTaskStatus.Active: + + + + break; + + case WorkTaskStatus.Paused: + + + + break; + } +
+ } +
+ +