feat(board): show upcoming deadline tasks
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user