From 94f85731ef19f99bcd8ff573a58222b355ea8d5e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 13 Aug 2026 16:54:13 -0400 Subject: [PATCH] feat(board): show upcoming deadline tasks --- TaskTracker.Api/Pages/Board.cshtml | 1 + TaskTracker.Api/Pages/Board.cshtml.cs | 56 ++++++++--- .../Pages/Partials/_KanbanBoard.cshtml | 1 + .../Pages/Partials/_UpcomingTasks.cshtml | 37 ++++++++ TaskTracker.Api/wwwroot/css/site.css | 93 +++++++++++++++++++ TaskTracker.Tests/BoardUpcomingTasksTests.cs | 82 ++++++++++++++++ 6 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 TaskTracker.Api/Pages/Partials/_UpcomingTasks.cshtml create mode 100644 TaskTracker.Tests/BoardUpcomingTasksTests.cs diff --git a/TaskTracker.Api/Pages/Board.cshtml b/TaskTracker.Api/Pages/Board.cshtml index 45f4be3..214f3a3 100644 --- a/TaskTracker.Api/Pages/Board.cshtml +++ b/TaskTracker.Api/Pages/Board.cshtml @@ -3,6 +3,7 @@
+
@foreach (var col in Model.Columns) { diff --git a/TaskTracker.Api/Pages/Board.cshtml.cs b/TaskTracker.Api/Pages/Board.cshtml.cs index ead690e..ff8df25 100644 --- a/TaskTracker.Api/Pages/Board.cshtml.cs +++ b/TaskTracker.Api/Pages/Board.cshtml.cs @@ -19,6 +19,7 @@ public class BoardModel : PageModel } public List Columns { get; set; } = new(); + public List UpcomingTasks { get; private set; } = new(); public string? ActiveCategory { get; set; } public bool HasSubtasksFilter { get; set; } public List AllCategories { get; set; } = new(); @@ -277,6 +278,43 @@ public class BoardModel : PageModel return Partial("Partials/_SearchResults", results); } + public static List SelectUpcomingTasks( + IEnumerable allTasks, + string? category, + bool hasSubtasks) => + SelectUpcomingTasks(FilterVisibleTasks(allTasks, category, hasSubtasks)); + + private static IEnumerable FilterVisibleTasks( + IEnumerable allTasks, + string? category, + bool hasSubtasks) + { + var visibleTasks = allTasks; + + if (!string.IsNullOrEmpty(category)) + { + visibleTasks = visibleTasks.Where(task => + string.Equals(task.Category, category, StringComparison.OrdinalIgnoreCase)); + } + + if (hasSubtasks) + { + visibleTasks = visibleTasks.Where(task => task.SubTasks.Count > 0); + } + + return visibleTasks; + } + + private static List SelectUpcomingTasks(IEnumerable visibleTasks) + { + return visibleTasks + .Where(task => task.DueAt.HasValue && + task.Status is not WorkTaskStatus.Completed and not WorkTaskStatus.Abandoned) + .OrderBy(task => task.DueAt) + .Take(8) + .ToList(); + } + private async Task ReturnBoardContentAsync() { await LoadBoardDataAsync(); @@ -296,21 +334,11 @@ public class BoardModel : PageModel .OrderBy(c => c) .ToList(); - // Filter to top-level only - var tasks = allTasks.Where(t => t.ParentTaskId == null).ToList(); + var visibleTasks = FilterVisibleTasks(allTasks, ActiveCategory, HasSubtasksFilter).ToList(); + UpcomingTasks = SelectUpcomingTasks(visibleTasks); - // 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(); - } + // Keep the Kanban collection top-level only after filters apply across all tasks. + var tasks = visibleTasks.Where(task => task.ParentTaskId == null).ToList(); // Group by status into columns var tasksByStatus = tasks.ToLookup(t => t.Status); diff --git a/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml index fcfcd25..16ebe96 100644 --- a/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml +++ b/TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml @@ -3,6 +3,7 @@
+
@foreach (var col in Model.Columns) { diff --git a/TaskTracker.Api/Pages/Partials/_UpcomingTasks.cshtml b/TaskTracker.Api/Pages/Partials/_UpcomingTasks.cshtml new file mode 100644 index 0000000..4c58475 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_UpcomingTasks.cshtml @@ -0,0 +1,37 @@ +@using TaskTracker.Api.Pages +@using TaskTracker.Core.Enums +@model BoardModel + +@if (Model.UpcomingTasks.Count > 0) +{ +
+
+

Upcoming tasks

+ @Model.UpcomingTasks.Count due soon +
+
+ @foreach (var task in Model.UpcomingTasks) + { + var categoryColor = BoardModel.GetCategoryColor(task.Category); + var dueAt = task.DueAt!.Value; + var dueAtUtc = dueAt.ToUniversalTime().ToString("O"); + var localDueAt = dueAt.ToLocalTime().ToString("ddd, MMM d, h:mm tt"); +
+
@task.Title
+
+ @task.Status + @if (!string.IsNullOrEmpty(task.Category)) + { + + @task.Category + } +
+ +
+ } +
+
+} diff --git a/TaskTracker.Api/wwwroot/css/site.css b/TaskTracker.Api/wwwroot/css/site.css index 2fd3bf1..1007d7f 100644 --- a/TaskTracker.Api/wwwroot/css/site.css +++ b/TaskTracker.Api/wwwroot/css/site.css @@ -474,6 +474,99 @@ body::before { } +/* ============================================================ + UPCOMING TASKS + ============================================================ */ + +.upcoming-tasks { + margin-bottom: 20px; + padding: 14px; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface); +} + +.upcoming-tasks-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.upcoming-tasks-header h2 { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.upcoming-tasks-header span { + color: var(--color-text-secondary); + font-size: 11px; +} + +.upcoming-task-list { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 2px; +} + +.upcoming-task { + display: grid; + flex: 0 0 210px; + gap: 6px; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-elevated); + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.upcoming-task:hover { + border-color: var(--color-border-hover); + transform: translateY(-1px); +} + +.upcoming-task-title { + overflow: hidden; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upcoming-task-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--color-text-secondary); + font-size: 11px; +} + +.upcoming-task-status { + color: var(--color-status-pending); +} + +.upcoming-task-status--active { color: var(--color-status-active); } +.upcoming-task-status--paused { color: var(--color-status-paused); } +.upcoming-task-status--completed { color: var(--color-status-completed); } +.upcoming-task-status--abandoned { color: var(--color-status-abandoned); } + +.task-countdown { + color: var(--color-text-secondary); + font-size: 11px; +} + +.task-countdown--overdue { + color: var(--color-status-abandoned); +} + /* ============================================================ KANBAN LAYOUT ============================================================ */ diff --git a/TaskTracker.Tests/BoardUpcomingTasksTests.cs b/TaskTracker.Tests/BoardUpcomingTasksTests.cs new file mode 100644 index 0000000..6fbfb7f --- /dev/null +++ b/TaskTracker.Tests/BoardUpcomingTasksTests.cs @@ -0,0 +1,82 @@ +using TaskTracker.Api.Pages; +using TaskTracker.Core.Entities; +using TaskTracker.Core.Enums; +using Xunit; + +namespace TaskTracker.Tests; + +public class BoardUpcomingTasksTests +{ + [Fact] + public void SelectUpcomingTasks_excludes_completed_abandoned_and_tasks_without_a_deadline() + { + var dueAt = DateTimeOffset.Parse("2026-08-14T09:00:00Z"); + var tasks = new[] + { + Task("pending", WorkTaskStatus.Pending, dueAt), + Task("completed", WorkTaskStatus.Completed, dueAt.AddHours(-1)), + Task("abandoned", WorkTaskStatus.Abandoned, dueAt.AddHours(1)), + Task("no deadline", WorkTaskStatus.Active), + }; + + var upcoming = BoardModel.SelectUpcomingTasks(tasks, category: null, hasSubtasks: false); + + var task = Assert.Single(upcoming); + Assert.Equal("pending", task.Title); + } + + [Fact] + public void SelectUpcomingTasks_includes_top_level_tasks_and_subtasks_in_due_date_order() + { + var parent = Task("top level", WorkTaskStatus.Pending, DateTimeOffset.Parse("2026-08-14T12:00:00Z")); + var subtask = Task("subtask", WorkTaskStatus.Active, DateTimeOffset.Parse("2026-08-14T10:00:00Z"), parentTaskId: 1); + var later = Task("later", WorkTaskStatus.Paused, DateTimeOffset.Parse("2026-08-14T14:00:00Z")); + + var upcoming = BoardModel.SelectUpcomingTasks([parent, subtask, later], category: null, hasSubtasks: false); + + Assert.Equal(["subtask", "top level", "later"], upcoming.Select(task => task.Title)); + } + + [Fact] + public void SelectUpcomingTasks_limits_results_to_eight_earliest_deadlines() + { + var start = DateTimeOffset.Parse("2026-08-14T09:00:00Z"); + var tasks = Enumerable.Range(0, 9) + .Select(index => Task($"task {index}", WorkTaskStatus.Pending, start.AddHours(index))) + .ToList(); + + var upcoming = BoardModel.SelectUpcomingTasks(tasks, category: null, hasSubtasks: false); + + Assert.Equal(8, upcoming.Count); + Assert.DoesNotContain(upcoming, task => task.Title == "task 8"); + } + + [Fact] + public void SelectUpcomingTasks_honors_active_category_and_has_subtasks_filters() + { + var dueAt = DateTimeOffset.Parse("2026-08-14T09:00:00Z"); + var developmentParent = Task("development parent", WorkTaskStatus.Pending, dueAt, category: "Development"); + developmentParent.SubTasks.Add(Task("development child", WorkTaskStatus.Pending, dueAt.AddHours(1), parentTaskId: 1, category: "Development")); + var developmentLeaf = Task("development leaf", WorkTaskStatus.Pending, dueAt.AddHours(2), category: "Development"); + var researchParent = Task("research parent", WorkTaskStatus.Pending, dueAt.AddHours(-1), category: "Research"); + researchParent.SubTasks.Add(Task("research child", WorkTaskStatus.Pending, dueAt, parentTaskId: 2, category: "Research")); + + var upcoming = BoardModel.SelectUpcomingTasks( + [developmentParent, developmentParent.SubTasks[0], developmentLeaf, researchParent, researchParent.SubTasks[0]], + category: "development", + hasSubtasks: true); + + var task = Assert.Single(upcoming); + Assert.Equal("development parent", task.Title); + } + + private static WorkTask Task(string title, WorkTaskStatus status, DateTimeOffset? dueAt = null, int? parentTaskId = null, string? category = null) => + new() + { + Title = title, + Status = status, + DueAt = dueAt, + ParentTaskId = parentTaskId, + Category = category, + }; +}