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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:17:45 -05:00
parent bef7916cf8
commit e34c5d561f
8 changed files with 459 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
@page
@model TaskTracker.Api.Pages.BoardModel
<div id="board-content" class="board-page">
<partial name="Partials/_FilterBar" model="Model" />
<div id="kanban-board" class="kanban-grid">
@foreach (var col in Model.Columns)
{
<partial name="Partials/_KanbanColumn" model="col" />
}
</div>
</div>

View File

@@ -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<WorkTask> Tasks);
[IgnoreAntiforgeryToken]
public class BoardModel : PageModel
{
private readonly ITaskRepository _taskRepo;
public BoardModel(ITaskRepository taskRepo)
{
_taskRepo = taskRepo;
}
public List<ColumnViewModel> Columns { get; set; } = new();
public string? ActiveCategory { get; set; }
public bool HasSubtasksFilter { get; set; }
public List<string> 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<string, string> 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<IActionResult> OnGetAsync(string? category, bool hasSubtasks = false)
{
ActiveCategory = category;
HasSubtasksFilter = hasSubtasks;
await LoadBoardDataAsync();
return Page();
}
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> OnDeleteAbandonAsync(int id)
{
await _taskRepo.DeleteAsync(id);
return await ReturnBoardContentAsync();
}
public async Task<IActionResult> 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<IActionResult> 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();
}
}

View File

@@ -0,0 +1,11 @@
<form hx-post="/board?handler=CreateTask"
hx-target="#board-content"
hx-select="#board-content"
hx-swap="outerHTML"
class="create-task-form mt-2">
<input type="text"
name="title"
placeholder="New task..."
class="input"
autocomplete="off" />
</form>

View File

@@ -0,0 +1,55 @@
@using TaskTracker.Api.Pages
@model BoardModel
<div class="filter-bar flex items-center gap-2 mb-4 flex-wrap">
@{
var isAllActive = string.IsNullOrEmpty(Model.ActiveCategory) && !Model.HasSubtasksFilter;
}
<a href="/board"
hx-get="/board"
hx-target="#board-content"
hx-select="#board-content"
hx-swap="outerHTML"
hx-push-url="true"
class="filter-chip @(isAllActive ? "filter-chip--active" : "")">
All
</a>
@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}" : "";
<a href="/board?category=@Uri.EscapeDataString(cat)"
hx-get="/board?category=@Uri.EscapeDataString(cat)"
hx-target="#board-content"
hx-select="#board-content"
hx-swap="outerHTML"
hx-push-url="true"
class="filter-chip @(isActive ? "filter-chip--active" : "")"
style="@bgStyle">
<span class="filter-chip-dot" style="background: @catColor"></span>
@cat
</a>
}
<span class="filter-separator"></span>
@{
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");
}
<a href="@subtaskUrl"
hx-get="@subtaskUrl"
hx-target="#board-content"
hx-select="#board-content"
hx-swap="outerHTML"
hx-push-url="true"
class="filter-chip @(Model.HasSubtasksFilter ? "filter-chip--active" : "")">
Has subtasks
</a>
</div>

View File

@@ -0,0 +1,12 @@
@using TaskTracker.Api.Pages
@model BoardModel
<div id="board-content" class="board-page">
<partial name="Partials/_FilterBar" model="Model" />
<div id="kanban-board" class="kanban-grid">
@foreach (var col in Model.Columns)
{
<partial name="Partials/_KanbanColumn" model="col" />
}
</div>
</div>

View File

@@ -0,0 +1,33 @@
@using TaskTracker.Api.Pages
@using TaskTracker.Core.Enums
@model ColumnViewModel
<div class="kanban-column">
<div class="kanban-column-header">
<span class="kanban-column-dot" style="background: @Model.Color"></span>
<span class="kanban-column-title">@Model.Label</span>
<span class="kanban-column-count">@Model.Tasks.Count</span>
</div>
<div class="kanban-column-bar" style="background: @(Model.Color)33"></div>
<div class="kanban-column-body"
data-status="@Model.Status"
id="column-@Model.Status">
@if (Model.Tasks.Count > 0)
{
@foreach (var task in Model.Tasks)
{
<partial name="Partials/_TaskCard" model="task" />
}
}
else
{
<div class="kanban-column-empty">
<span>No tasks</span>
</div>
}
</div>
@if (Model.Status == WorkTaskStatus.Pending)
{
<partial name="Partials/_CreateTaskForm" />
}
</div>

View File

@@ -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;
}
<div class="@cardClasses"
id="task-@Model.Id"
data-task-id="@Model.Id"
hx-get="/board?handler=TaskDetail&id=@Model.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<div class="task-card-title">
@if (isActive)
{
<span class="live-dot"></span>
}
@Model.Title
</div>
<div class="task-card-meta">
@if (!string.IsNullOrEmpty(Model.Category))
{
<span class="task-card-category-dot" style="background: @catColor"></span>
<span>@Model.Category</span>
}
@if (Model.StartedAt is not null)
{
<span class="task-card-elapsed ml-auto">
<svg class="icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
@elapsed
</span>
}
</div>
@if (totalSubtasks > 0)
{
<div class="task-card-subtasks">
@foreach (var st in Model.SubTasks)
{
var isDone = st.Status == WorkTaskStatus.Completed || st.Status == WorkTaskStatus.Abandoned;
<span class="task-card-subtask-dot @(isDone ? "task-card-subtask-dot--done" : "")"></span>
}
<span class="text-xs text-secondary ml-auto">@completedSubtasks/@totalSubtasks</span>
</div>
}
</div>

View File

@@ -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 {