feat(board): show upcoming deadline tasks

This commit is contained in:
aj
2026-08-13 16:54:13 -04:00
parent 48a749fde3
commit 94f85731ef
6 changed files with 256 additions and 14 deletions
+1
View File
@@ -3,6 +3,7 @@
<div id="board-content" class="board-page">
<partial name="Partials/_FilterBar" model="Model" />
<partial name="Partials/_UpcomingTasks" model="Model" />
<div id="kanban-board" class="kanban-grid">
@foreach (var col in Model.Columns)
{
+42 -14
View File
@@ -19,6 +19,7 @@ public class BoardModel : PageModel
}
public List<ColumnViewModel> Columns { get; set; } = new();
public List<WorkTask> UpcomingTasks { get; private set; } = new();
public string? ActiveCategory { get; set; }
public bool HasSubtasksFilter { get; set; }
public List<string> AllCategories { get; set; } = new();
@@ -277,6 +278,43 @@ public class BoardModel : PageModel
return Partial("Partials/_SearchResults", results);
}
public static List<WorkTask> SelectUpcomingTasks(
IEnumerable<WorkTask> allTasks,
string? category,
bool hasSubtasks) =>
SelectUpcomingTasks(FilterVisibleTasks(allTasks, category, hasSubtasks));
private static IEnumerable<WorkTask> FilterVisibleTasks(
IEnumerable<WorkTask> 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<WorkTask> SelectUpcomingTasks(IEnumerable<WorkTask> 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<IActionResult> 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);
@@ -3,6 +3,7 @@
<div id="board-content" class="board-page">
<partial name="Partials/_FilterBar" model="Model" />
<partial name="Partials/_UpcomingTasks" model="Model" />
<div id="kanban-board" class="kanban-grid">
@foreach (var col in Model.Columns)
{
@@ -0,0 +1,37 @@
@using TaskTracker.Api.Pages
@using TaskTracker.Core.Enums
@model BoardModel
@if (Model.UpcomingTasks.Count > 0)
{
<section class="upcoming-tasks" aria-labelledby="upcoming-tasks-heading">
<div class="upcoming-tasks-header">
<h2 id="upcoming-tasks-heading">Upcoming tasks</h2>
<span>@Model.UpcomingTasks.Count due soon</span>
</div>
<div class="upcoming-task-list">
@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");
<article class="upcoming-task"
hx-get="/board?handler=TaskDetail&id=@task.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<div class="upcoming-task-title">@task.Title</div>
<div class="upcoming-task-meta">
<span class="upcoming-task-status upcoming-task-status--@task.Status.ToString().ToLowerInvariant()">@task.Status</span>
@if (!string.IsNullOrEmpty(task.Category))
{
<span class="task-card-category-dot" style="background: @categoryColor"></span>
<span>@task.Category</span>
}
</div>
<time class="task-countdown" aria-live="off" data-due-at="@dueAtUtc" datetime="@dueAtUtc" title="@localDueAt">@localDueAt</time>
</article>
}
</div>
</section>
}
+93
View File
@@ -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
============================================================ */
@@ -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,
};
}