feat(web): add task detail panel with inline editing, subtasks, and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:22:45 -05:00
parent e34c5d561f
commit 1d1b2a153e
5 changed files with 573 additions and 0 deletions

View File

@@ -167,6 +167,113 @@ public class BoardModel : PageModel
return await ReturnBoardContentAsync();
}
// Load task detail panel
public async Task<IActionResult> OnGetTaskDetailAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
return Partial("Partials/_TaskDetail", task);
}
// Update task fields (inline edit)
public async Task<IActionResult> OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (category is not null) task.Category = category;
if (estimatedMinutes.HasValue) task.EstimatedMinutes = estimatedMinutes;
await _taskRepo.UpdateAsync(task);
return Partial("Partials/_TaskDetail", task);
}
// Add subtask
public async Task<IActionResult> OnPostAddSubtaskAsync(int id, string title)
{
var parent = await _taskRepo.GetByIdAsync(id);
if (parent is null) return NotFound();
var subtask = new WorkTask
{
Title = title,
ParentTaskId = id,
Status = WorkTaskStatus.Pending
};
await _taskRepo.CreateAsync(subtask);
// Reload parent to get updated subtask list
parent = await _taskRepo.GetByIdAsync(id);
return Partial("Partials/_SubtaskList", parent);
}
// Complete subtask
public async Task<IActionResult> OnPutCompleteSubtaskAsync(int id)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Status = WorkTaskStatus.Completed;
task.CompletedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(task);
// Return parent's subtask list
if (task.ParentTaskId.HasValue)
{
var parent = await _taskRepo.GetByIdAsync(task.ParentTaskId.Value);
if (parent is not null)
return Partial("Partials/_SubtaskList", parent);
}
return Content("");
}
// Add note
public async Task<IActionResult> OnPostAddNoteAsync(int id, string content)
{
var task = await _taskRepo.GetByIdAsync(id);
if (task is null) return NotFound();
task.Notes.Add(new TaskNote
{
Content = content,
Type = NoteType.General,
CreatedAt = DateTime.UtcNow
});
await _taskRepo.UpdateAsync(task);
return Partial("Partials/_NotesList", task);
}
// Search tasks (for Ctrl+K modal)
public async Task<IActionResult> OnGetSearchAsync(string? q)
{
var allTasks = await _taskRepo.GetAllAsync();
List<WorkTask> results;
if (string.IsNullOrWhiteSpace(q))
{
results = allTasks
.Where(t => t.Status is WorkTaskStatus.Active or WorkTaskStatus.Paused or WorkTaskStatus.Pending)
.OrderByDescending(t => t.Status == WorkTaskStatus.Active)
.Take(8)
.ToList();
}
else
{
results = allTasks
.Where(t =>
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(t.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
(t.Category?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
.Take(10)
.ToList();
}
return Partial("Partials/_SearchResults", results);
}
private async Task<IActionResult> ReturnBoardContentAsync()
{
await LoadBoardDataAsync();

View File

@@ -0,0 +1,64 @@
@using TaskTracker.Core.Enums
@model TaskTracker.Core.Entities.WorkTask
@functions {
static string FormatRelativeTime(DateTime dt)
{
var diff = DateTime.UtcNow - dt;
if (diff.TotalMinutes < 1) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 2) return "yesterday";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)}w ago";
return dt.ToLocalTime().ToString("MMM d, yyyy");
}
static string GetNoteTypeCssClass(NoteType type)
{
return type switch
{
NoteType.PauseNote => "note-type-badge--pause",
NoteType.ResumeNote => "note-type-badge--resume",
_ => "note-type-badge--general"
};
}
static string GetNoteTypeLabel(NoteType type)
{
return type switch
{
NoteType.PauseNote => "Pause",
NoteType.ResumeNote => "Resume",
_ => "General"
};
}
}
<div id="notes-list-@Model.Id">
<h3 class="detail-section-label">Notes</h3>
@foreach (var note in Model.Notes.OrderBy(n => n.CreatedAt))
{
<div class="note">
<div class="note-header">
<span class="note-type-badge @GetNoteTypeCssClass(note.Type)">
@GetNoteTypeLabel(note.Type)
</span>
<span class="note-time">@FormatRelativeTime(note.CreatedAt)</span>
</div>
<div class="note-content">@note.Content</div>
</div>
}
<!-- Add note form -->
<form hx-post="/board?handler=AddNote&id=@Model.Id"
hx-target="#notes-list-@Model.Id"
hx-swap="outerHTML"
class="note-add-form"
style="margin-top: 8px;">
<input type="text" name="content" placeholder="Add a note..." class="input"
style="font-size: 13px; padding: 6px 10px;"
autocomplete="off" />
</form>
</div>

View File

@@ -0,0 +1,33 @@
@using TaskTracker.Api.Pages
@using TaskTracker.Core.Enums
@model List<TaskTracker.Core.Entities.WorkTask>
@* Placeholder — full implementation in Task 6 (Search Modal) *@
<div class="search-results">
@if (Model.Count == 0)
{
<div style="padding: 16px; text-align: center; color: var(--color-text-secondary); font-size: 13px;">
No results found
</div>
}
else
{
@foreach (var task in Model)
{
<div class="search-result-item"
hx-get="/board?handler=TaskDetail&id=@task.Id"
hx-target="#detail-panel"
hx-swap="innerHTML"
style="padding: 10px 16px; cursor: pointer; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--color-border);">
<span class="badge badge--@task.Status.ToString().ToLower()" style="flex-shrink: 0;">
@task.Status
</span>
<span style="font-size: 14px; color: var(--color-text-primary);">@task.Title</span>
@if (!string.IsNullOrEmpty(task.Category))
{
<span style="margin-left: auto; font-size: 11px; color: var(--color-text-tertiary);">@task.Category</span>
}
</div>
}
}
</div>

View File

@@ -0,0 +1,46 @@
@using TaskTracker.Core.Enums
@model TaskTracker.Core.Entities.WorkTask
<div id="subtask-list-@Model.Id">
<h3 class="detail-section-label">Subtasks</h3>
@foreach (var sub in Model.SubTasks)
{
<div class="subtask-row">
@if (sub.Status == WorkTaskStatus.Completed)
{
<span class="subtask-check subtask-check--done">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<span class="subtask-title subtask-title--done">@sub.Title</span>
}
else
{
<button class="subtask-check"
hx-put="/board?handler=CompleteSubtask&id=@sub.Id"
hx-target="#subtask-list-@Model.Id"
hx-swap="outerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3" ry="3" />
</svg>
</button>
<span class="subtask-title">@sub.Title</span>
}
</div>
}
<!-- Add subtask form -->
<form hx-post="/board?handler=AddSubtask&id=@Model.Id"
hx-target="#subtask-list-@Model.Id"
hx-swap="outerHTML"
class="subtask-add-form"
style="margin-top: 8px;">
<input type="text" name="title" placeholder="Add subtask..." class="input"
style="font-size: 13px; padding: 6px 10px;"
autocomplete="off" />
</form>
</div>

View File

@@ -0,0 +1,323 @@
@using TaskTracker.Api.Pages
@using TaskTracker.Core.Enums
@model TaskTracker.Core.Entities.WorkTask
@{
var statusColors = new Dictionary<WorkTaskStatus, (string Color, string Label)>
{
[WorkTaskStatus.Pending] = ("#64748b", "Pending"),
[WorkTaskStatus.Active] = ("#3b82f6", "Active"),
[WorkTaskStatus.Paused] = ("#eab308", "Paused"),
[WorkTaskStatus.Completed] = ("#22c55e", "Completed"),
[WorkTaskStatus.Abandoned] = ("#ef4444", "Abandoned"),
};
var (statusColor, statusLabel) = statusColors[Model.Status];
var catColor = BoardModel.GetCategoryColor(Model.Category);
var elapsed = BoardModel.FormatElapsed(Model.StartedAt, Model.CompletedAt);
double? progressPercent = null;
if (Model.EstimatedMinutes.HasValue && Model.StartedAt.HasValue)
{
var start = Model.StartedAt.Value;
var end = Model.CompletedAt ?? DateTime.UtcNow;
var elapsedMins = (end - start).TotalMinutes;
progressPercent = Math.Min(100, (elapsedMins / Model.EstimatedMinutes.Value) * 100);
}
var isTerminal = Model.Status is WorkTaskStatus.Completed or WorkTaskStatus.Abandoned;
}
<!-- Overlay -->
<div class="detail-overlay" onclick="closeDetailPanel()"></div>
<!-- Panel -->
<div class="detail-panel">
<!-- Header -->
<div class="detail-header">
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;">
<div class="inline-edit" id="edit-title" style="flex: 1; min-width: 0;">
<h2 class="detail-title inline-edit-display inline-edit-display--title"
onclick="startEdit('title')">@Model.Title</h2>
<form class="inline-edit-form" style="display:none"
hx-put="/board?handler=UpdateTask&id=@Model.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<input type="text" name="title" value="@Model.Title"
class="inline-edit-input"
onblur="this.form.requestSubmit()"
onkeydown="if(event.key==='Escape'){cancelEdit('title');event.stopPropagation()}"
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
</form>
</div>
<button class="detail-close" onclick="closeDetailPanel()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Status badge + Category -->
<div style="display: flex; align-items: center; gap: 8px; margin-top: 12px;">
<span class="badge badge--@statusLabel.ToLower()">@statusLabel</span>
<div class="inline-edit" id="edit-category">
@if (!string.IsNullOrEmpty(Model.Category))
{
<span class="inline-edit-display inline-edit-display--field"
onclick="startEdit('category')"
style="font-size: 12px; display: inline-flex; align-items: center; gap: 4px;">
<span style="width: 8px; height: 8px; border-radius: 50%; background: @catColor; flex-shrink: 0;"></span>
@Model.Category
</span>
}
else
{
<span class="inline-edit-display inline-edit-display--field"
onclick="startEdit('category')"
style="font-size: 12px; color: var(--color-text-tertiary);">
+ category
</span>
}
<form class="inline-edit-form" style="display:none"
hx-put="/board?handler=UpdateTask&id=@Model.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<input type="text" name="category" value="@(Model.Category ?? "")"
class="inline-edit-input" placeholder="Category"
onblur="this.form.requestSubmit()"
onkeydown="if(event.key==='Escape'){cancelEdit('category');event.stopPropagation()}"
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
</form>
</div>
</div>
</div>
<!-- Scrollable body -->
<div class="detail-body">
<!-- Description -->
<div class="detail-section">
<h3 class="detail-section-label">Description</h3>
<div class="inline-edit" id="edit-description">
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="inline-edit-display inline-edit-display--field"
onclick="startEdit('description')"
style="font-size: 13px; line-height: 1.6; white-space: pre-wrap;">@Model.Description</div>
}
else
{
<div class="inline-edit-display inline-edit-display--field"
onclick="startEdit('description')"
style="font-size: 13px; color: var(--color-text-tertiary);">
Click to add description...
</div>
}
<form class="inline-edit-form" style="display:none"
hx-put="/board?handler=UpdateTask&id=@Model.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<textarea name="description" rows="4"
class="inline-edit-input"
onkeydown="if(event.key==='Escape'){cancelEdit('description');event.stopPropagation()}"
style="resize: vertical;">@(Model.Description ?? "")</textarea>
<div style="display: flex; gap: 6px; margin-top: 6px;">
<button type="submit" class="btn btn--sm btn--primary">Save</button>
<button type="button" class="btn btn--sm btn--ghost" onclick="cancelEdit('description')">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Time section -->
<div class="detail-section">
<h3 class="detail-section-label">Time</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; font-size: 13px;">
<span style="color: var(--color-text-secondary);">Elapsed</span>
<span style="color: var(--color-text-primary); font-variant-numeric: tabular-nums;">@elapsed</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 13px;">
<span style="color: var(--color-text-secondary);">Estimate</span>
<div class="inline-edit" id="edit-estimate" style="text-align: right;">
<span class="inline-edit-display inline-edit-display--field"
onclick="startEdit('estimate')"
style="color: var(--color-text-primary); font-variant-numeric: tabular-nums; padding: 2px 8px;">
@(Model.EstimatedMinutes.HasValue ? $"{Model.EstimatedMinutes}m" : "--")
</span>
<form class="inline-edit-form" style="display:none"
hx-put="/board?handler=UpdateTask&id=@Model.Id"
hx-target="#detail-panel"
hx-swap="innerHTML">
<input type="number" name="estimatedMinutes"
value="@(Model.EstimatedMinutes?.ToString() ?? "")"
class="inline-edit-input" placeholder="minutes"
style="width: 100px; text-align: right;"
onblur="this.form.requestSubmit()"
onkeydown="if(event.key==='Escape'){cancelEdit('estimate');event.stopPropagation()}"
onkeypress="if(event.key==='Enter'){this.form.requestSubmit();event.preventDefault()}" />
</form>
</div>
</div>
@if (progressPercent.HasValue)
{
var isOver = progressPercent.Value >= 100;
<div class="progress-bar">
<div class="progress-bar-fill @(isOver ? "progress-bar-fill--over" : "")"
style="width: @progressPercent.Value.ToString("F0")%"></div>
</div>
}
</div>
</div>
<!-- Subtasks -->
<div class="detail-section">
<partial name="Partials/_SubtaskList" model="Model" />
</div>
<!-- Notes -->
<div class="detail-section">
<partial name="Partials/_NotesList" model="Model" />
</div>
</div>
<!-- Action buttons (sticky bottom) -->
@if (!isTerminal)
{
<div class="detail-actions">
@switch (Model.Status)
{
case WorkTaskStatus.Pending:
<button hx-put="/board?handler=Start&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--primary btn--full"
onclick="closeDetailPanel()">
Start
</button>
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--danger btn--full"
onclick="closeDetailPanel()">
Abandon
</button>
break;
case WorkTaskStatus.Active:
<button hx-put="/board?handler=Pause&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--amber btn--full"
onclick="closeDetailPanel()">
Pause
</button>
<button hx-put="/board?handler=Complete&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--emerald btn--full"
onclick="closeDetailPanel()">
Complete
</button>
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--danger btn--full"
onclick="closeDetailPanel()">
Abandon
</button>
break;
case WorkTaskStatus.Paused:
<button hx-put="/board?handler=Resume&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--primary btn--full"
onclick="closeDetailPanel()">
Resume
</button>
<button hx-put="/board?handler=Complete&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--emerald btn--full"
onclick="closeDetailPanel()">
Complete
</button>
<button hx-delete="/board?handler=Abandon&id=@Model.Id"
hx-target="#kanban-board"
hx-swap="innerHTML"
class="btn btn--danger btn--full"
onclick="closeDetailPanel()">
Abandon
</button>
break;
}
</div>
}
</div>
<script>
// Open the detail panel when this partial is loaded
(function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
// Use requestAnimationFrame to ensure the DOM is painted before adding classes
requestAnimationFrame(function() {
if (overlay) overlay.classList.add('detail-overlay--open');
if (panel) panel.classList.add('detail-panel--open');
});
})();
function closeDetailPanel() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.remove('detail-overlay--open');
if (panel) panel.classList.remove('detail-panel--open');
// Clear the panel content after the transition
setTimeout(function() {
var container = document.getElementById('detail-panel');
if (container) container.innerHTML = '';
}, 300);
}
function startEdit(field) {
var wrapper = document.getElementById('edit-' + field);
if (!wrapper) return;
var display = wrapper.querySelector('.inline-edit-display');
var form = wrapper.querySelector('.inline-edit-form');
if (display) display.style.display = 'none';
if (form) {
form.style.display = '';
var input = form.querySelector('input, textarea');
if (input) {
input.focus();
if (input.type === 'text' || input.tagName === 'TEXTAREA') {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
}
function cancelEdit(field) {
var wrapper = document.getElementById('edit-' + field);
if (!wrapper) return;
var display = wrapper.querySelector('.inline-edit-display');
var form = wrapper.querySelector('.inline-edit-form');
if (display) display.style.display = '';
if (form) form.style.display = 'none';
}
// Close panel on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var panel = document.querySelector('.detail-panel--open');
if (panel) {
closeDetailPanel();
e.stopPropagation();
}
}
});
</script>