From e34c5d561f23cbdf97ced1f7b7446880064817be Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Mar 2026 22:17:45 -0500 Subject: [PATCH] feat(web): add Board page with Kanban columns and task cards Server-rendered Kanban board with 4 status columns (Pending, Active, Paused, Completed), task cards with category colors and elapsed time, filter bar with category chips and subtask toggle, and inline create task form. All handlers support htmx partial updates for status transitions (start, pause, resume, complete, abandon, create). Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Api/Pages/Board.cshtml | 12 + TaskTracker.Api/Pages/Board.cshtml.cs | 216 ++++++++++++++++++ .../Pages/Partials/_CreateTaskForm.cshtml | 11 + .../Pages/Partials/_FilterBar.cshtml | 55 +++++ .../Pages/Partials/_KanbanBoard.cshtml | 12 + .../Pages/Partials/_KanbanColumn.cshtml | 33 +++ .../Pages/Partials/_TaskCard.cshtml | 55 +++++ TaskTracker.Api/wwwroot/css/site.css | 66 +++++- 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 TaskTracker.Api/Pages/Board.cshtml create mode 100644 TaskTracker.Api/Pages/Board.cshtml.cs create mode 100644 TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml create mode 100644 TaskTracker.Api/Pages/Partials/_FilterBar.cshtml create mode 100644 TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml create mode 100644 TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml create mode 100644 TaskTracker.Api/Pages/Partials/_TaskCard.cshtml diff --git a/TaskTracker.Api/Pages/Board.cshtml b/TaskTracker.Api/Pages/Board.cshtml new file mode 100644 index 0000000..45f4be3 --- /dev/null +++ b/TaskTracker.Api/Pages/Board.cshtml @@ -0,0 +1,12 @@ +@page +@model TaskTracker.Api.Pages.BoardModel + +
+ +
+ @foreach (var col in Model.Columns) + { + + } +
+
diff --git a/TaskTracker.Api/Pages/Board.cshtml.cs b/TaskTracker.Api/Pages/Board.cshtml.cs new file mode 100644 index 0000000..8252812 --- /dev/null +++ b/TaskTracker.Api/Pages/Board.cshtml.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using TaskTracker.Core.Entities; +using TaskTracker.Core.Enums; +using TaskTracker.Core.Interfaces; + +namespace TaskTracker.Api.Pages; + +public record ColumnViewModel(WorkTaskStatus Status, string Label, string Color, List Tasks); + +[IgnoreAntiforgeryToken] +public class BoardModel : PageModel +{ + private readonly ITaskRepository _taskRepo; + + public BoardModel(ITaskRepository taskRepo) + { + _taskRepo = taskRepo; + } + + public List Columns { get; set; } = new(); + public string? ActiveCategory { get; set; } + public bool HasSubtasksFilter { get; set; } + public List AllCategories { get; set; } = new(); + + private static readonly (WorkTaskStatus Status, string Label, string Color)[] ColumnConfig = + [ + (WorkTaskStatus.Pending, "Pending", "#64748b"), + (WorkTaskStatus.Active, "Active", "#3b82f6"), + (WorkTaskStatus.Paused, "Paused", "#eab308"), + (WorkTaskStatus.Completed, "Completed", "#22c55e"), + ]; + + public static readonly Dictionary CategoryColors = new() + { + ["Development"] = "#6366f1", + ["Research"] = "#06b6d4", + ["Communication"] = "#8b5cf6", + ["DevOps"] = "#f97316", + ["Documentation"] = "#14b8a6", + ["Design"] = "#ec4899", + ["Testing"] = "#3b82f6", + ["General"] = "#64748b", + ["Email"] = "#f59e0b", + ["Engineering"] = "#6366f1", + ["LaserCutting"] = "#ef4444", + ["Unknown"] = "#475569", + }; + + public static string GetCategoryColor(string? category) + { + if (string.IsNullOrEmpty(category)) return CategoryColors["Unknown"]; + return CategoryColors.TryGetValue(category, out var color) ? color : CategoryColors["Unknown"]; + } + + public static string FormatElapsed(DateTime? startedAt, DateTime? completedAt) + { + if (startedAt is null) return "--"; + var start = startedAt.Value; + var end = completedAt ?? DateTime.UtcNow; + var mins = (int)(end - start).TotalMinutes; + if (mins < 60) return $"{mins}m"; + var hours = mins / 60; + var remainder = mins % 60; + if (hours < 24) return $"{hours}h {remainder}m"; + var days = hours / 24; + return $"{days}d {hours % 24}h"; + } + + public async Task OnGetAsync(string? category, bool hasSubtasks = false) + { + ActiveCategory = category; + HasSubtasksFilter = hasSubtasks; + + await LoadBoardDataAsync(); + + return Page(); + } + + public async Task OnPutStartAsync(int id) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + var active = await _taskRepo.GetActiveTaskAsync(); + if (active is not null && active.Id != id) + { + active.Status = WorkTaskStatus.Paused; + await _taskRepo.UpdateAsync(active); + } + + task.Status = WorkTaskStatus.Active; + task.StartedAt ??= DateTime.UtcNow; + await _taskRepo.UpdateAsync(task); + + return await ReturnBoardContentAsync(); + } + + public async Task OnPutPauseAsync(int id) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + task.Status = WorkTaskStatus.Paused; + await _taskRepo.UpdateAsync(task); + + return await ReturnBoardContentAsync(); + } + + public async Task OnPutResumeAsync(int id) + { + var active = await _taskRepo.GetActiveTaskAsync(); + if (active is not null) + { + active.Status = WorkTaskStatus.Paused; + await _taskRepo.UpdateAsync(active); + } + + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + task.Status = WorkTaskStatus.Active; + task.StartedAt ??= DateTime.UtcNow; + await _taskRepo.UpdateAsync(task); + + return await ReturnBoardContentAsync(); + } + + public async Task OnPutCompleteAsync(int id) + { + var task = await _taskRepo.GetByIdAsync(id); + if (task is null) return NotFound(); + + var incompleteSubtasks = task.SubTasks + .Count(st => st.Status != WorkTaskStatus.Completed && st.Status != WorkTaskStatus.Abandoned); + if (incompleteSubtasks > 0) + { + return BadRequest($"Cannot complete: {incompleteSubtasks} subtask(s) still incomplete."); + } + + task.Status = WorkTaskStatus.Completed; + task.CompletedAt = DateTime.UtcNow; + await _taskRepo.UpdateAsync(task); + + return await ReturnBoardContentAsync(); + } + + public async Task OnDeleteAbandonAsync(int id) + { + await _taskRepo.DeleteAsync(id); + return await ReturnBoardContentAsync(); + } + + public async Task OnPostCreateTaskAsync(string title, string? category) + { + if (string.IsNullOrWhiteSpace(title)) + return BadRequest("Title is required."); + + var task = new WorkTask + { + Title = title.Trim(), + Category = category, + Status = WorkTaskStatus.Pending, + }; + await _taskRepo.CreateAsync(task); + + return await ReturnBoardContentAsync(); + } + + private async Task ReturnBoardContentAsync() + { + await LoadBoardDataAsync(); + return Partial("Partials/_KanbanBoard", this); + } + + private async Task LoadBoardDataAsync() + { + // Load all tasks with subtasks for category list + var allTasks = await _taskRepo.GetAllAsync(includeSubTasks: true); + + // Collect all distinct categories from all tasks (unfiltered) + AllCategories = allTasks + .Where(t => !string.IsNullOrEmpty(t.Category)) + .Select(t => t.Category!) + .Distinct() + .OrderBy(c => c) + .ToList(); + + // Filter to top-level only + var tasks = allTasks.Where(t => t.ParentTaskId == null).ToList(); + + // Apply category filter + if (!string.IsNullOrEmpty(ActiveCategory)) + { + tasks = tasks.Where(t => + string.Equals(t.Category, ActiveCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + // Apply hasSubtasks filter + if (HasSubtasksFilter) + { + tasks = tasks.Where(t => t.SubTasks.Count > 0).ToList(); + } + + // Group by status into columns + var tasksByStatus = tasks.ToLookup(t => t.Status); + + Columns = ColumnConfig + .Select(cfg => new ColumnViewModel( + cfg.Status, + cfg.Label, + cfg.Color, + tasksByStatus[cfg.Status].ToList())) + .ToList(); + } +} diff --git a/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml new file mode 100644 index 0000000..d2600cc --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml @@ -0,0 +1,11 @@ +
+ +
diff --git a/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml b/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml new file mode 100644 index 0000000..5c18795 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_FilterBar.cshtml @@ -0,0 +1,55 @@ +@using TaskTracker.Api.Pages +@model BoardModel + +
+ @{ + var isAllActive = string.IsNullOrEmpty(Model.ActiveCategory) && !Model.HasSubtasksFilter; + } + + + All + + + @foreach (var cat in Model.AllCategories) + { + var isActive = string.Equals(Model.ActiveCategory, cat, StringComparison.OrdinalIgnoreCase); + var catColor = BoardModel.GetCategoryColor(cat); + var bgStyle = isActive ? $"background: {catColor}33; border-color: {catColor}66; color: {catColor}" : ""; + + + + @cat + + } + + + + @{ + var subtaskUrl = Model.HasSubtasksFilter + ? (string.IsNullOrEmpty(Model.ActiveCategory) ? "/board" : $"/board?category={Uri.EscapeDataString(Model.ActiveCategory!)}") + : (string.IsNullOrEmpty(Model.ActiveCategory) ? "/board?hasSubtasks=true" : $"/board?category={Uri.EscapeDataString(Model.ActiveCategory!)}&hasSubtasks=true"); + } + + + Has subtasks + +
diff --git a/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml new file mode 100644 index 0000000..fcfcd25 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml @@ -0,0 +1,12 @@ +@using TaskTracker.Api.Pages +@model BoardModel + +
+ +
+ @foreach (var col in Model.Columns) + { + + } +
+
diff --git a/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml b/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml new file mode 100644 index 0000000..10b80ff --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml @@ -0,0 +1,33 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model ColumnViewModel + +
+
+ + @Model.Label + @Model.Tasks.Count +
+
+
+ @if (Model.Tasks.Count > 0) + { + @foreach (var task in Model.Tasks) + { + + } + } + else + { +
+ No tasks +
+ } +
+ @if (Model.Status == WorkTaskStatus.Pending) + { + + } +
diff --git a/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml b/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml new file mode 100644 index 0000000..0729030 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_TaskCard.cshtml @@ -0,0 +1,55 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model TaskTracker.Core.Entities.WorkTask + +@{ + var isActive = Model.Status == WorkTaskStatus.Active; + var cardClasses = "task-card card-glow" + (isActive ? " task-card--active" : ""); + var catColor = BoardModel.GetCategoryColor(Model.Category); + var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt); + var completedSubtasks = Model.SubTasks.Count(st => st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned); + var totalSubtasks = Model.SubTasks.Count; +} + +
+
+ @if (isActive) + { + + } + @Model.Title +
+
+ @if (!string.IsNullOrEmpty(Model.Category)) + { + + @Model.Category + } + @if (Model.StartedAt is not null) + { + + + + + + @elapsed + + } +
+ @if (totalSubtasks > 0) + { +
+ @foreach (var st in Model.SubTasks) + { + var isDone = st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned; + + } + @completedSubtasks/@totalSubtasks +
+ } +
diff --git a/TaskTracker.Api/wwwroot/css/site.css b/TaskTracker.Api/wwwroot/css/site.css index 4fa0cbb..e94ccf1 100644 --- a/TaskTracker.Api/wwwroot/css/site.css +++ b/TaskTracker.Api/wwwroot/css/site.css @@ -1334,6 +1334,70 @@ body::before { } +/* ============================================================ + FILTER BAR + ============================================================ */ + +.filter-bar { + padding: 0 4px; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--color-border); + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + white-space: nowrap; +} + +.filter-chip:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text-primary); + border-color: var(--color-border-hover); +} + +.filter-chip--active { + background: rgba(139, 92, 246, 0.15); + border-color: rgba(139, 92, 246, 0.3); + color: var(--color-accent); +} + +.filter-chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.filter-separator { + width: 1px; + height: 20px; + background: var(--color-border); + margin: 0 4px; +} + +.board-page { + display: flex; + flex-direction: column; + height: 100%; + gap: 0; +} + +.board-page .kanban-grid { + flex: 1; + min-height: 0; +} + + /* ============================================================ CREATE TASK FORM (inline in kanban column) ============================================================ */ @@ -1346,7 +1410,7 @@ body::before { } .create-task-form .input { - margin-bottom: 8px; + margin-bottom: 0; } .create-task-form-actions {