Compare commits

...

14 Commits

Author SHA1 Message Date
d784f9fea8 feat(web): add real-time activity feed via SignalR
- Add ActivityHub and wire up SignalR in Program.cs
- Broadcast new context events from ContextController
- Connect SignalR client on Analytics page for live feed updates
- Restructure activity feed HTML to support live prepending
- Add slide-in animation for new activity items
- Update CORS to allow credentials for SignalR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:30:01 -05:00
5a273ba667 feat(web): add "New Task" button scroll-to-input behavior
Clicking the header "New Task" button now scrolls to and focuses
the create-task input in the Pending column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:29:49 -05:00
6ea0e40d38 fix(web): address code review findings from Razor Pages migration
- Remove legacy wwwroot files (old index.html, app.css, api.js, page scripts)
- Add Index page that redirects / to /board
- Fix mapping delete button missing handler=Delete in URL
- Add [IgnoreAntiforgeryToken] to AnalyticsModel for consistency
- Remove duplicate JS from _TaskDetail.cshtml (already in app.js)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:47:49 -05:00
cffd09941a feat(web): remove React app — migration to Razor Pages complete
Delete the entire TaskTracker.Web React/npm frontend. All UI
functionality has been reimplemented as Razor Pages served from
the TaskTracker.Api project using htmx, SortableJS, and Chart.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:40:03 -05:00
a6adaea2da feat(web): add Mappings page with inline CRUD table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:37:29 -05:00
91f2eec922 feat(web): add Analytics page with Chart.js charts and activity feed
Add Analytics page with stat cards (open tasks, active time, top category),
Chart.js timeline bar chart bucketed by hour, category donut chart with
legend, and paginated activity feed with htmx "Load more" support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:33:12 -05:00
fc674847f5 feat(web): add Ctrl+K command palette search modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:28:02 -05:00
1fafae9705 feat(web): add drag-and-drop between Kanban columns via SortableJS
Wire up SortableJS in app.js to enable dragging task cards between
Kanban columns. On drop, fires htmx PUT requests to the appropriate
Board handler (Start/Pause/Resume/Complete) based on the column
transition. Invalid transitions are reverted. Sortable instances are
destroyed and recreated after htmx swaps to prevent memory leaks.
Also centralizes detail panel open/close/edit helpers and Escape key
handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:25:26 -05:00
1d1b2a153e feat(web): add task detail panel with inline editing, subtasks, and notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:22:45 -05:00
e34c5d561f 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>
2026-03-01 22:17:45 -05:00
bef7916cf8 feat(web): add CSS design system and vendored JS libraries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:12:57 -05:00
c76956fb5b feat(web): add Razor Pages scaffolding to API project
Register Razor Pages services and middleware in Program.cs, removing
UseDefaultFiles() since Razor Pages handles routing. Add shared layout
with dark-themed app shell (header, nav links, search/new-task buttons)
and vendor script references for htmx, Sortable, and Chart.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:01:53 -05:00
13b1f14344 docs: add Razor Pages migration implementation plan
11-task plan covering project setup, CSS/JS assets, Board page with
Kanban drag-and-drop, detail panel, search modal, Analytics page with
Chart.js, Mappings page with inline CRUD, and React app removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:50:15 -05:00
e04e9573ea docs: add Razor Pages migration design
Design for moving the TaskTracker web UI from React/npm to
Razor Pages + htmx, eliminating the Node toolchain and unifying
on a single .NET stack with one-binary deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:47:08 -05:00
79 changed files with 4674 additions and 9473 deletions

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using TaskTracker.Api.Hubs;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
@@ -11,6 +13,7 @@ public class ContextController(
IContextEventRepository contextRepo,
IAppMappingRepository mappingRepo,
ITaskRepository taskRepo,
IHubContext<ActivityHub> hubContext,
ILogger<ContextController> logger) : ControllerBase
{
[HttpPost]
@@ -37,6 +40,17 @@ public class ContextController(
}
var created = await contextRepo.CreateAsync(contextEvent);
await hubContext.Clients.All.SendAsync("NewActivity", new
{
id = created.Id,
appName = created.AppName,
windowTitle = created.WindowTitle,
url = created.Url,
timestamp = created.Timestamp,
workTaskId = created.WorkTaskId
});
return Ok(ApiResponse<ContextEvent>.Ok(created));
}

View File

@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.SignalR;
namespace TaskTracker.Api.Hubs;
public class ActivityHub : Hub { }

View File

@@ -0,0 +1,216 @@
@page
@using TaskTracker.Api.Pages
@model AnalyticsModel
<div class="analytics-page">
<!-- Header + Filters -->
<div class="analytics-header">
<h1 class="page-title">Analytics</h1>
<div class="analytics-filters">
<select class="select" style="width:auto;"
onchange="window.location.href='/analytics?minutes='+this.value+'@(Model.TaskId.HasValue ? "&taskId=" + Model.TaskId : "")'">
@{
var timeOptions = new[] { (1440, "Today"), (10080, "7 days"), (43200, "30 days") };
}
@foreach (var (val, label) in timeOptions)
{
if (Model.Minutes == val)
{
<option value="@val" selected="selected">@label</option>
}
else
{
<option value="@val">@label</option>
}
}
</select>
<select class="select" style="width:auto;"
onchange="window.location.href='/analytics?minutes=@Model.Minutes'+(this.value ? '&taskId='+this.value : '')">
<option value="">All Tasks</option>
@foreach (var t in Model.Tasks)
{
if (Model.TaskId == t.Id)
{
<option value="@t.Id" selected="selected">@t.Title</option>
}
else
{
<option value="@t.Id">@t.Title</option>
}
}
</select>
</div>
</div>
<!-- Stat Cards -->
<div class="stat-grid">
<div class="stat-card">
<span class="stat-card-label">Open Tasks</span>
<p class="stat-card-value">@Model.OpenTaskCount</p>
</div>
<div class="stat-card">
<span class="stat-card-label">Active Time</span>
<p class="stat-card-value">@Model.ActiveTimeDisplay</p>
</div>
<div class="stat-card">
<span class="stat-card-label">Top Category</span>
<p class="stat-card-value">@Model.TopCategory</p>
</div>
</div>
<!-- Timeline Chart -->
<section>
<h2 class="section-title">Activity Timeline</h2>
<div class="surface">
<div class="chart-container">
<canvas id="timeline-chart"></canvas>
</div>
</div>
</section>
<!-- Category Breakdown -->
<section>
<h2 class="section-title">Category Breakdown</h2>
<div class="surface">
<div style="display:flex; gap:40px; align-items:center;">
<div class="chart-container" style="width:280px; height:280px;">
<canvas id="category-chart"></canvas>
</div>
<div id="category-legend" class="category-legend" style="flex:1;">
<!-- Legend rendered by JS -->
</div>
</div>
</div>
</section>
<!-- Activity Feed -->
<section>
<h2 class="section-title">Recent Activity</h2>
<div class="surface">
<div id="activity-feed" class="activity-feed">
@if (Model.ActivityItems.Count == 0)
{
<div class="search-empty" id="activity-empty">No activity recorded in this time range.</div>
}
@foreach (var item in Model.ActivityItems)
{
<div class="activity-item">
<span class="activity-dot" style="background: var(--color-accent)"></span>
<div class="activity-line"></div>
<div class="activity-info">
<span class="activity-app">@item.AppName</span>
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
</div>
</div>
}
</div>
@if (Model.TotalActivityCount > Model.ActivityItems.Count)
{
<button class="btn btn--ghost btn--full"
hx-get="/analytics?handler=ActivityFeed&minutes=@Model.Minutes&taskId=@Model.TaskId&offset=@Model.ActivityItems.Count"
hx-target="#activity-feed"
hx-swap="beforeend">
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
</button>
}
</div>
</section>
</div>
<!-- Chart.js data -->
<script>
var timelineData = @Html.Raw(Model.TimelineChartJson);
var categoryData = @Html.Raw(Model.CategoryChartJson);
</script>
@section Scripts {
<script>
// Timeline bar chart
if (timelineData.length > 0) {
new Chart(document.getElementById('timeline-chart'), {
type: 'bar',
data: {
labels: timelineData.map(function(d) { return d.hour; }),
datasets: [{
data: timelineData.map(function(d) { return d.count; }),
backgroundColor: timelineData.map(function(d) { return d.color; }),
borderRadius: 4,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function(items) { return items[0].label; },
label: function(item) {
var d = timelineData[item.dataIndex];
return d.category + ': ' + d.count + ' events';
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#64748b', font: { size: 10 } }
},
y: {
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#64748b', font: { size: 10 } }
}
}
}
});
}
// Category donut chart
if (categoryData.length > 0) {
new Chart(document.getElementById('category-chart'), {
type: 'doughnut',
data: {
labels: categoryData.map(function(d) { return d.category; }),
datasets: [{
data: categoryData.map(function(d) { return d.count; }),
backgroundColor: categoryData.map(function(d) { return d.color; }),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(item) {
var total = categoryData.reduce(function(a, b) { return a + b.count; }, 0);
var pct = Math.round((item.raw / total) * 100);
return item.label + ': ' + item.raw + ' (' + pct + '%)';
}
}
}
}
}
});
// Render legend
var total = categoryData.reduce(function(a, b) { return a + b.count; }, 0);
var legendHtml = categoryData.map(function(d) {
var pct = Math.round((d.count / total) * 100);
return '<div class="category-legend-item">' +
'<span class="category-legend-dot" style="background:' + d.color + '"></span>' +
'<span class="category-legend-name">' + d.category + '</span>' +
'<span class="category-legend-count">' + d.count + '</span>' +
'<span class="category-legend-pct">' + pct + '%</span>' +
'</div>';
}).join('');
document.getElementById('category-legend').innerHTML = legendHtml;
}
</script>
}

View File

@@ -0,0 +1,206 @@
using System.Text.Json;
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;
[IgnoreAntiforgeryToken]
public class AnalyticsModel : PageModel
{
private readonly ITaskRepository _taskRepo;
private readonly IContextEventRepository _contextRepo;
private readonly IAppMappingRepository _mappingRepo;
public AnalyticsModel(
ITaskRepository taskRepo,
IContextEventRepository contextRepo,
IAppMappingRepository mappingRepo)
{
_taskRepo = taskRepo;
_contextRepo = contextRepo;
_mappingRepo = mappingRepo;
}
public int Minutes { get; set; } = 1440;
public int? TaskId { get; set; }
public List<WorkTask> Tasks { get; set; } = [];
// Stat cards
public int OpenTaskCount { get; set; }
public string ActiveTimeDisplay { get; set; } = "--";
public string TopCategory { get; set; } = "\u2014";
// Chart data (serialized as JSON for Chart.js)
public string TimelineChartJson { get; set; } = "[]";
public string CategoryChartJson { get; set; } = "[]";
// Activity feed
public List<ContextEvent> ActivityItems { get; set; } = [];
public int TotalActivityCount { get; set; }
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 async Task<IActionResult> OnGetAsync(int minutes = 1440, int? taskId = null)
{
Minutes = minutes;
TaskId = taskId;
// Load all tasks
Tasks = await _taskRepo.GetAllAsync(includeSubTasks: false);
// Stat cards
OpenTaskCount = Tasks.Count(t =>
t.Status != WorkTaskStatus.Completed && t.Status != WorkTaskStatus.Abandoned);
// Active time: sum elapsed across all tasks with StartedAt
var totalMinutes = 0.0;
foreach (var task in Tasks.Where(t => t.StartedAt.HasValue))
{
var start = task.StartedAt!.Value;
var end = task.CompletedAt ?? (task.Status == WorkTaskStatus.Active ? DateTime.UtcNow : start);
totalMinutes += (end - start).TotalMinutes;
}
if (totalMinutes >= 60)
ActiveTimeDisplay = $"{(int)(totalMinutes / 60)}h {(int)(totalMinutes % 60)}m";
else if (totalMinutes >= 1)
ActiveTimeDisplay = $"{(int)totalMinutes}m";
// Top category
var topCat = Tasks
.Where(t => !string.IsNullOrEmpty(t.Category))
.GroupBy(t => t.Category)
.OrderByDescending(g => g.Count())
.FirstOrDefault();
if (topCat != null)
TopCategory = topCat.Key!;
// Context events for the time range
var events = await _contextRepo.GetRecentAsync(minutes);
if (taskId.HasValue)
events = events.Where(e => e.WorkTaskId == taskId.Value).ToList();
// Mappings
var mappings = await _mappingRepo.GetAllAsync();
// Timeline chart data: bucket by hour
var timelineData = events
.GroupBy(e => e.Timestamp.ToLocalTime().Hour)
.OrderBy(g => g.Key)
.Select(g =>
{
// Determine dominant category for this hour
var categoryCounts = g
.Select(e => ResolveCategory(e, mappings))
.GroupBy(c => c)
.OrderByDescending(cg => cg.Count())
.First();
var category = categoryCounts.Key;
var color = GetCategoryColor(category);
var hour = g.Key;
var label = hour == 0 ? "12 AM"
: hour < 12 ? $"{hour} AM"
: hour == 12 ? "12 PM"
: $"{hour - 12} PM";
return new { hour = label, count = g.Count(), category, color };
})
.ToList();
TimelineChartJson = JsonSerializer.Serialize(timelineData,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
// Category chart data
var categoryData = events
.Select(e => ResolveCategory(e, mappings))
.GroupBy(c => c)
.OrderByDescending(g => g.Count())
.Select(g => new
{
category = g.Key,
count = g.Count(),
color = GetCategoryColor(g.Key)
})
.ToList();
CategoryChartJson = JsonSerializer.Serialize(categoryData,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
// Activity feed: most recent 20
var orderedEvents = events.OrderByDescending(e => e.Timestamp).ToList();
TotalActivityCount = orderedEvents.Count;
ActivityItems = orderedEvents.Take(20).ToList();
return Page();
}
public async Task<IActionResult> OnGetActivityFeedAsync(int minutes = 1440, int? taskId = null, int offset = 0)
{
var events = await _contextRepo.GetRecentAsync(minutes);
if (taskId.HasValue)
events = events.Where(e => e.WorkTaskId == taskId.Value).ToList();
var items = events
.OrderByDescending(e => e.Timestamp)
.Skip(offset)
.Take(20)
.ToList();
TotalActivityCount = events.Count;
ActivityItems = items;
return Partial("Partials/_ActivityFeedItems", this);
}
private static string ResolveCategory(ContextEvent e, List<AppMapping> mappings)
{
foreach (var m in mappings)
{
bool match = m.MatchType switch
{
"ProcessName" => e.AppName.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"TitleContains" => e.WindowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"UrlContains" => e.Url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true,
_ => false
};
if (match) return m.Category;
}
return "Unknown";
}
private 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 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");
}
}

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,323 @@
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();
}
// 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();
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,2 @@
@page
@model TaskTracker.Api.Pages.IndexModel

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace TaskTracker.Api.Pages;
public class IndexModel : PageModel
{
public IActionResult OnGet() => RedirectToPage("/Board");
}

View File

@@ -0,0 +1,48 @@
@page
@using TaskTracker.Api.Pages
@model MappingsModel
<div class="mappings-page">
<div class="analytics-header">
<h1 class="page-title">App Mappings</h1>
<button class="btn btn--primary btn--sm"
hx-get="/mappings?handler=AddRow"
hx-target="#mapping-tbody"
hx-swap="afterbegin">
+ Add Rule
</button>
</div>
@if (Model.Mappings.Count == 0)
{
<div class="surface empty-state">
<p style="color: var(--color-text-secondary); font-size: 14px; margin-bottom: 12px;">No mappings configured</p>
<button class="btn btn--ghost"
hx-get="/mappings?handler=AddRow"
hx-target="#mapping-tbody"
hx-swap="afterbegin">
+ Add your first mapping rule
</button>
</div>
}
<div class="surface" style="padding: 0; overflow: hidden;">
<table class="mappings-table">
<thead>
<tr>
<th>Pattern</th>
<th>Match Type</th>
<th>Category</th>
<th>Friendly Name</th>
<th style="width: 96px;">Actions</th>
</tr>
</thead>
<tbody id="mapping-tbody">
@foreach (var m in Model.Mappings)
{
<partial name="Partials/_MappingRow" model="m" />
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Pages;
[IgnoreAntiforgeryToken]
public class MappingsModel : PageModel
{
private readonly IAppMappingRepository _mappingRepo;
public MappingsModel(IAppMappingRepository mappingRepo) => _mappingRepo = mappingRepo;
public List<AppMapping> Mappings { get; set; } = [];
// Category colors (same as Board)
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 readonly Dictionary<string, string> MatchTypeColors = new()
{
["ProcessName"] = "#6366f1",
["TitleContains"] = "#06b6d4",
["UrlContains"] = "#f97316",
};
public async Task OnGetAsync()
{
Mappings = await _mappingRepo.GetAllAsync();
}
// Return an empty edit row for adding new
public IActionResult OnGetAddRow()
{
return Partial("Partials/_MappingEditRow", new AppMapping());
}
// Return the edit form for an existing mapping
public async Task<IActionResult> OnGetEditRowAsync(int id)
{
var mapping = await _mappingRepo.GetByIdAsync(id);
if (mapping is null) return NotFound();
return Partial("Partials/_MappingEditRow", mapping);
}
// Return the display row for an existing mapping (cancel edit)
public async Task<IActionResult> OnGetRowAsync(int id)
{
var mapping = await _mappingRepo.GetByIdAsync(id);
if (mapping is null) return NotFound();
return Partial("Partials/_MappingRow", mapping);
}
// Create or update a mapping
public async Task<IActionResult> OnPostSaveAsync(int? id, string pattern, string matchType, string category, string? friendlyName)
{
if (id.HasValue && id.Value > 0)
{
// Update existing
var mapping = await _mappingRepo.GetByIdAsync(id.Value);
if (mapping is null) return NotFound();
mapping.Pattern = pattern;
mapping.MatchType = matchType;
mapping.Category = category;
mapping.FriendlyName = friendlyName;
await _mappingRepo.UpdateAsync(mapping);
return Partial("Partials/_MappingRow", mapping);
}
else
{
// Create new
var mapping = new AppMapping
{
Pattern = pattern,
MatchType = matchType,
Category = category,
FriendlyName = friendlyName,
};
var created = await _mappingRepo.CreateAsync(mapping);
return Partial("Partials/_MappingRow", created);
}
}
// Delete a mapping
public async Task<IActionResult> OnDeleteAsync(int id)
{
await _mappingRepo.DeleteAsync(id);
return Content(""); // htmx will remove the row
}
}

View File

@@ -0,0 +1,15 @@
@using TaskTracker.Api.Pages
@model AnalyticsModel
@foreach (var item in Model.ActivityItems)
{
<div class="activity-item">
<span class="activity-dot" style="background: var(--color-accent)"></span>
<div class="activity-line"></div>
<div class="activity-info">
<span class="activity-app">@item.AppName</span>
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
</div>
</div>
}

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,67 @@
@using TaskTracker.Api.Pages
@model TaskTracker.Core.Entities.AppMapping
@{
var isNew = Model.Id == 0;
var formId = isNew ? "mapping-form-new" : $"mapping-form-{Model.Id}";
var rowId = isNew ? "mapping-row-new" : $"mapping-row-{Model.Id}";
}
<tr id="@rowId" style="background: rgba(255,255,255,0.04);">
<td>
<input type="text" name="pattern" value="@Model.Pattern" placeholder="Pattern..."
class="input" autofocus form="@formId" />
</td>
<td>
<select name="matchType" class="select" form="@formId">
@{
var matchTypes = new[] { "ProcessName", "TitleContains", "UrlContains" };
var currentMatch = string.IsNullOrEmpty(Model.MatchType) ? "ProcessName" : Model.MatchType;
}
@foreach (var mt in matchTypes)
{
if (mt == currentMatch)
{
<option value="@mt" selected="selected">@mt</option>
}
else
{
<option value="@mt">@mt</option>
}
}
</select>
</td>
<td>
<input type="text" name="category" value="@Model.Category" placeholder="Category..."
class="input" form="@formId" />
</td>
<td>
<input type="text" name="friendlyName" value="@Model.FriendlyName" placeholder="Friendly name (optional)"
class="input" form="@formId" />
</td>
<td>
<form id="@formId"
hx-post="/mappings?handler=Save@(isNew ? "" : $"&id={Model.Id}")"
hx-target="#@rowId"
hx-swap="outerHTML"
style="display: flex; gap: 4px;">
<button type="submit" class="btn btn--ghost btn--sm" style="color: #22c55e;" title="Save">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
</button>
@if (isNew)
{
<button type="button" class="btn btn--ghost btn--sm" title="Cancel"
onclick="this.closest('tr').remove()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
}
else
{
<button type="button" class="btn btn--ghost btn--sm" title="Cancel"
hx-get="/mappings?handler=Row&id=@Model.Id"
hx-target="#mapping-row-@Model.Id"
hx-swap="outerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
}
</form>
</td>
</tr>

View File

@@ -0,0 +1,41 @@
@using TaskTracker.Api.Pages
@model TaskTracker.Core.Entities.AppMapping
@{
var matchColor = MappingsModel.MatchTypeColors.GetValueOrDefault(Model.MatchType, "#64748b");
var catColor = MappingsModel.CategoryColors.GetValueOrDefault(Model.Category, "#64748b");
}
<tr id="mapping-row-@Model.Id">
<td><span class="mapping-pattern">@Model.Pattern</span></td>
<td>
<span class="match-type-badge" style="background: @(matchColor)20; color: @matchColor;">
@Model.MatchType
</span>
</td>
<td>
<span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-primary);">
<span style="width: 8px; height: 8px; border-radius: 50%; background: @catColor; flex-shrink: 0;"></span>
@Model.Category
</span>
</td>
<td style="color: var(--color-text-secondary);">@(Model.FriendlyName ?? "\u2014")</td>
<td>
<div style="display: flex; gap: 4px;">
<button class="btn btn--ghost btn--sm"
hx-get="/mappings?handler=EditRow&id=@Model.Id"
hx-target="#mapping-row-@Model.Id"
hx-swap="outerHTML"
title="Edit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</button>
<button class="btn btn--ghost btn--sm"
hx-delete="/mappings?handler=Delete&id=@Model.Id"
hx-target="#mapping-row-@Model.Id"
hx-swap="outerHTML"
hx-confirm="Delete this mapping rule?"
title="Delete"
style="color: var(--color-text-secondary);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</td>
</tr>

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,37 @@
@using TaskTracker.Core.Enums
@model List<TaskTracker.Core.Entities.WorkTask>
@{
var statusColors = new Dictionary<WorkTaskStatus, string>
{
[WorkTaskStatus.Pending] = "#64748b",
[WorkTaskStatus.Active] = "#3b82f6",
[WorkTaskStatus.Paused] = "#eab308",
[WorkTaskStatus.Completed] = "#22c55e",
[WorkTaskStatus.Abandoned] = "#ef4444",
};
}
@if (Model.Count == 0)
{
<div class="search-empty">No results found</div>
}
else
{
@for (var i = 0; i < Model.Count; i++)
{
var task = Model[i];
var color = statusColors.GetValueOrDefault(task.Status, "#64748b");
<div class="search-result @(i == 0 ? "search-result--selected" : "")"
data-task-id="@task.Id"
onclick="selectSearchResult(@task.Id)"
onmouseenter="highlightResult(this)">
<span class="search-result-dot" style="background: @color"></span>
<span class="search-result-title">@task.Title</span>
@if (!string.IsNullOrEmpty(task.Category))
{
<span class="search-result-category">@task.Category</span>
}
<span class="search-result-arrow">→</span>
</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,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

@@ -0,0 +1,262 @@
@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>
<!-- Panel open/close, inline editing, and keyboard handling are in app.js -->

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskTracker</title>
<link rel="stylesheet" href="~/css/site.css" />
</head>
<body class="app">
<header class="app-header">
<div class="header-left">
<a href="/board" class="logo">
<span class="logo-icon">T</span>
<span class="logo-text">TaskTracker</span>
</a>
<nav class="nav">
<a href="/board"
class="nav-link @(ViewContext.HttpContext.Request.Path.StartsWithSegments("/board") ? "nav-link--active" : "")">
Board
</a>
<a href="/analytics"
class="nav-link @(ViewContext.HttpContext.Request.Path.StartsWithSegments("/analytics") ? "nav-link--active" : "")">
Analytics
</a>
<a href="/mappings"
class="nav-link @(ViewContext.HttpContext.Request.Path.StartsWithSegments("/mappings") ? "nav-link--active" : "")">
Mappings
</a>
</nav>
</div>
<div class="header-right">
<button type="button" class="btn btn-search" id="search-trigger" onclick="openSearch()">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span class="btn-search__hint">Ctrl+K</span>
</button>
<button type="button" class="btn btn-primary" id="new-task-trigger">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Task
</button>
</div>
</header>
<main class="app-main">
@RenderBody()
</main>
<div id="search-modal" class="search-backdrop" onclick="if(event.target===this)closeSearch()">
<div class="search-modal">
<div class="search-input-row">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="search-input" class="search-input" placeholder="Search tasks..."
autocomplete="off"
hx-get="/board?handler=Search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-trigger="input changed delay:200ms, search"
hx-include="this"
name="q" />
</div>
<div id="search-results" class="search-results">
<!-- Results loaded via htmx -->
</div>
<div class="search-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> open</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
<div id="detail-panel"></div>
<script src="~/lib/htmx.min.js"></script>
<script src="~/lib/Sortable.min.js"></script>
<script src="~/lib/chart.umd.min.js"></script>
<script src="~/lib/signalr.min.js"></script>
<script src="~/js/app.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,4 @@
@using TaskTracker.Core.Entities
@using TaskTracker.Core.Enums
@using TaskTracker.Core.DTOs
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Api.Hubs;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
using TaskTracker.Infrastructure.Repositories;
@@ -31,12 +32,19 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.SetIsOriginAllowed(_ => true)
.AllowAnyHeader()
.AllowAnyMethod();
.AllowAnyMethod()
.AllowCredentials();
});
});
// SignalR
builder.Services.AddSignalR();
// Razor Pages
builder.Services.AddRazorPages();
var app = builder.Build();
// Auto-migrate on startup in development
@@ -51,8 +59,9 @@ app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapRazorPages();
app.MapControllers();
app.MapHub<ActivityHub>("/hubs/activity");
app.Run();

View File

@@ -1,619 +0,0 @@
/* ── Variables ── */
:root {
--bg-primary: #1a1b23;
--bg-secondary: #22232e;
--bg-card: #2a2b38;
--bg-input: #32333f;
--bg-hover: #353647;
--border: #3a3b4a;
--text-primary: #e4e4e8;
--text-secondary: #9d9db0;
--text-muted: #6b6b80;
--accent: #6c8cff;
--accent-hover: #8ba3ff;
--success: #4caf7c;
--warning: #e0a040;
--danger: #e05555;
--info: #50b0d0;
--sidebar-width: 220px;
--radius: 8px;
--radius-sm: 4px;
}
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
/* ── Layout ── */
#app {
display: flex;
min-height: 100vh;
}
#sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-brand {
padding: 20px 16px;
font-size: 18px;
font-weight: 700;
color: var(--accent);
border-bottom: 1px solid var(--border);
letter-spacing: 0.5px;
}
.nav-links {
list-style: none;
padding: 8px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
color: var(--text-secondary);
text-decoration: none;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
font-size: 14px;
}
.nav-link:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-link.active {
background: var(--accent);
color: #fff;
}
.nav-icon {
font-size: 12px;
font-weight: 700;
width: 24px;
text-align: center;
opacity: 0.8;
}
#content {
flex: 1;
margin-left: var(--sidebar-width);
padding: 24px 32px;
max-width: 1200px;
}
.page { animation: fadeIn 0.15s ease; }
.hidden { display: none !important; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Typography ── */
.page-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ── Cards ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin-bottom: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title {
font-size: 16px;
font-weight: 600;
}
/* ── Stats Grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ── Status Badges ── */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.badge-pending { background: #3a3b4a; color: var(--text-secondary); }
.badge-active { background: rgba(76, 175, 124, 0.2); color: var(--success); }
.badge-paused { background: rgba(224, 160, 64, 0.2); color: var(--warning); }
.badge-completed { background: rgba(108, 140, 255, 0.2); color: var(--accent); }
.badge-abandoned { background: rgba(224, 85, 85, 0.2); color: var(--danger); }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
.btn-success:hover { opacity: 0.9; }
.btn-warning { background: var(--warning); border-color: var(--warning); color: #1a1b23; }
.btn-warning:hover { opacity: 0.9; }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── Forms ── */
.form-group {
margin-bottom: 14px;
}
.form-label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 8px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-textarea { resize: vertical; min-height: 60px; }
.form-inline {
display: flex;
gap: 8px;
align-items: flex-end;
}
.form-inline .form-group { margin-bottom: 0; flex: 1; }
/* ── Tables ── */
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
font-weight: 600;
background: var(--bg-secondary);
position: sticky;
top: 0;
}
tr:hover td { background: var(--bg-hover); }
/* ── Tabs / Filters ── */
.filter-bar {
display: flex;
gap: 4px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 16px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.filter-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ── Active Task Card ── */
.active-task-card {
border-left: 3px solid var(--success);
}
.active-task-card .card-title {
color: var(--success);
}
.no-active-task {
color: var(--text-muted);
font-style: italic;
padding: 12px 0;
}
/* ── Task List ── */
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
cursor: pointer;
transition: border-color 0.15s;
}
.task-item:hover { border-color: var(--accent); }
.task-item-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.task-item-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-item-meta {
font-size: 12px;
color: var(--text-muted);
}
/* ── Task Detail ── */
.task-detail { margin-top: 16px; }
.task-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.task-detail-title {
font-size: 20px;
font-weight: 600;
}
.task-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.meta-item {
font-size: 13px;
}
.meta-label {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.note-item {
padding: 10px 14px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 13px;
}
.note-item-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.note-type {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
}
.note-time {
font-size: 11px;
color: var(--text-muted);
}
/* ── Context Summary ── */
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.summary-item:last-child { border-bottom: none; }
.summary-app {
font-weight: 500;
}
.summary-category {
font-size: 12px;
color: var(--text-muted);
}
.summary-count {
font-size: 18px;
font-weight: 700;
color: var(--accent);
min-width: 50px;
text-align: right;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
min-width: 400px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
animation: modalIn 0.15s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* ── Utilities ── */
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.mb-8 { margin-bottom: 8px; }
.mb-16 { margin-bottom: 16px; }
.flex-between { display: flex; justify-content: space-between; align-items: center; }
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
/* ── Breadcrumbs ── */
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.breadcrumb-link {
color: var(--accent);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumb-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.breadcrumb-sep {
color: var(--text-muted);
margin: 0 2px;
}
.breadcrumb-current {
color: var(--text-secondary);
}
.breadcrumb-parent {
color: var(--text-muted);
}
/* ── Subtasks ── */
.subtask-list {
margin-bottom: 8px;
}
.subtask-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.subtask-item:hover {
border-color: var(--accent);
}
.subtask-item-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.subtask-item-title {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.subtask-item-title:hover {
color: var(--accent);
}
.subtask-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--bg-hover);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskTracker</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<div id="app">
<nav id="sidebar">
<div class="sidebar-brand">TaskTracker</div>
<ul class="nav-links">
<li><a href="#/" data-page="dashboard" class="nav-link active">
<span class="nav-icon">[D]</span> Dashboard
</a></li>
<li><a href="#/tasks" data-page="tasks" class="nav-link">
<span class="nav-icon">[T]</span> Tasks
</a></li>
<li><a href="#/context" data-page="context" class="nav-link">
<span class="nav-icon">[C]</span> Context
</a></li>
<li><a href="#/mappings" data-page="mappings" class="nav-link">
<span class="nav-icon">[M]</span> Mappings
</a></li>
</ul>
</nav>
<main id="content">
<div id="page-dashboard" class="page"></div>
<div id="page-tasks" class="page hidden"></div>
<div id="page-context" class="page hidden"></div>
<div id="page-mappings" class="page hidden"></div>
</main>
</div>
<div id="modal-overlay" class="modal-overlay hidden"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>

View File

@@ -1,48 +0,0 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Request failed');
return json.data;
}
export const tasks = {
list: (status, { parentId, includeSubTasks } = {}) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (parentId != null) params.set('parentId', parentId);
if (includeSubTasks) params.set('includeSubTasks', 'true');
const qs = params.toString();
return request(`/tasks${qs ? `?${qs}` : ''}`);
},
subtasks: (parentId) => request(`/tasks?parentId=${parentId}`),
active: () => request('/tasks/active'),
get: (id) => request(`/tasks/${id}`),
create: (body) => request('/tasks', { method: 'POST', body: JSON.stringify(body) }),
start: (id) => request(`/tasks/${id}/start`, { method: 'PUT' }),
pause: (id, note) => request(`/tasks/${id}/pause`, { method: 'PUT', body: JSON.stringify({ note }) }),
resume: (id, note) => request(`/tasks/${id}/resume`, { method: 'PUT', body: JSON.stringify({ note }) }),
complete: (id) => request(`/tasks/${id}/complete`, { method: 'PUT' }),
abandon: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
};
export const notes = {
list: (taskId) => request(`/tasks/${taskId}/notes`),
create: (taskId, body) => request(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify(body) }),
};
export const context = {
recent: (minutes = 30) => request(`/context/recent?minutes=${minutes}`),
summary: () => request('/context/summary'),
};
export const mappings = {
list: () => request('/mappings'),
create: (body) => request('/mappings', { method: 'POST', body: JSON.stringify(body) }),
update: (id, body) => request(`/mappings/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
remove: (id) => request(`/mappings/${id}`, { method: 'DELETE' }),
};

View File

@@ -1,53 +1,331 @@
import { initDashboard, refreshDashboard } from './pages/dashboard.js';
import { initTasks } from './pages/tasks.js';
import { initContext } from './pages/context.js';
import { initMappings } from './pages/mappings.js';
// TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts
const pages = ['dashboard', 'tasks', 'context', 'mappings'];
let currentPage = null;
let refreshTimer = null;
(function() {
'use strict';
function navigate(page) {
if (!pages.includes(page)) page = 'dashboard';
if (currentPage === page) return;
currentPage = page;
// ========================================
// Drag-and-Drop (SortableJS)
// ========================================
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === page);
});
function initKanban() {
document.querySelectorAll('.kanban-column-body').forEach(function(el) {
// Destroy existing Sortable instance if re-initializing
if (el._sortable) {
el._sortable.destroy();
}
// Show/hide pages
pages.forEach(p => {
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
});
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'task-card--ghost',
dragClass: 'task-card--dragging',
handle: '.task-card', // Only drag by the card itself
filter: '.create-task-form', // Don't drag the create form
onEnd: function(evt) {
var taskId = evt.item.dataset.taskId;
var fromStatus = evt.from.dataset.status;
var toStatus = evt.to.dataset.status;
// Load page content
const loaders = { dashboard: refreshDashboard, tasks: initTasks, context: initContext, mappings: initMappings };
loaders[page]?.();
// No-op if dropped in same column
if (fromStatus === toStatus) return;
// Auto-refresh for dashboard and context
clearInterval(refreshTimer);
if (page === 'dashboard' || page === 'context') {
refreshTimer = setInterval(() => loaders[page]?.(), 30000);
// Determine the handler based on transition
var handler = null;
if (toStatus === 'Active' && fromStatus === 'Paused') {
handler = 'Resume';
} else if (toStatus === 'Active') {
handler = 'Start';
} else if (toStatus === 'Paused' && fromStatus === 'Active') {
handler = 'Pause';
} else if (toStatus === 'Completed') {
handler = 'Complete';
} else {
// Invalid transition — revert by moving the item back
evt.from.appendChild(evt.item);
return;
}
// Fire htmx request to update the task status
htmx.ajax('PUT', '/board?handler=' + handler + '&id=' + taskId, {
target: '#kanban-board',
swap: 'innerHTML'
});
}
});
});
}
}
function onHashChange() {
const hash = location.hash.slice(2) || 'dashboard';
navigate(hash);
}
// ========================================
// Detail Panel helpers
// ========================================
// Init
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
location.hash = link.getAttribute('href').slice(1);
// Defined on window so inline onclick handlers and partial scripts can call them.
// The _TaskDetail partial also defines these inline; whichever loads last wins,
// but the behavior is identical.
window.closeDetailPanel = function() {
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 content after CSS transition completes
setTimeout(function() {
var container = document.getElementById('detail-panel');
if (container && !document.querySelector('.detail-panel--open')) {
container.innerHTML = '';
}
}, 300);
};
window.startEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.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);
}
}
}
};
window.cancelEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.querySelector('.inline-edit-form');
if (display) display.style.display = '';
if (form) form.style.display = 'none';
};
// ========================================
// Initialization
// ========================================
// Initialize Kanban drag-and-drop on page load
document.addEventListener('DOMContentLoaded', function() {
initKanban();
// "New Task" header button — scroll to and focus the create-task input in the Pending column
var newTaskBtn = document.getElementById('new-task-trigger');
if (newTaskBtn) {
newTaskBtn.addEventListener('click', function() {
var input = document.querySelector('.create-task-form input[name="title"]');
if (input) {
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
input.focus();
}
});
}
});
});
window.addEventListener('hashchange', onHashChange);
// Re-initialize after htmx swaps that affect the kanban board.
// This is critical because htmx replaces DOM nodes, destroying Sortable instances.
document.addEventListener('htmx:afterSwap', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
initKanban();
}
});
initDashboard();
initTasks();
onHashChange();
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
document.addEventListener('htmx:afterSettle', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content')) {
initKanban();
}
});
// Open detail panel when its content is loaded via htmx
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'detail-panel') {
// Panel content was just loaded — trigger open animation
requestAnimationFrame(function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.add('detail-overlay--open');
if (panel) panel.classList.add('detail-panel--open');
});
}
});
// Global Escape key handler for closing the detail panel
// (Search modal Escape is handled separately with stopPropagation)
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
// Don't close detail panel if search modal handled Escape
var searchModal = document.getElementById('search-modal');
if (searchModal && searchModal.classList.contains('search-backdrop--open')) return;
var panel = document.querySelector('.detail-panel--open');
if (panel) {
closeDetailPanel();
e.preventDefault();
}
}
});
// ========================================
// Search Modal (Ctrl+K)
// ========================================
var searchSelectedIndex = 0;
window.openSearch = function() {
var modal = document.getElementById('search-modal');
if (!modal) return;
modal.classList.add('search-backdrop--open');
var input = document.getElementById('search-input');
if (input) {
input.value = '';
input.focus();
// Trigger initial load (show recent tasks when empty)
htmx.trigger(input, 'search');
}
searchSelectedIndex = 0;
};
window.closeSearch = function() {
var modal = document.getElementById('search-modal');
if (modal) modal.classList.remove('search-backdrop--open');
};
window.selectSearchResult = function(taskId) {
closeSearch();
// Load the task detail panel
htmx.ajax('GET', '/board?handler=TaskDetail&id=' + taskId, {
target: '#detail-panel',
swap: 'innerHTML'
});
};
window.highlightResult = function(el) {
var results = document.querySelectorAll('.search-result');
results.forEach(function(r) { r.classList.remove('search-result--selected'); });
el.classList.add('search-result--selected');
// Update index
searchSelectedIndex = Array.from(results).indexOf(el);
};
// Keyboard navigation in search
document.addEventListener('keydown', function(e) {
var modal = document.getElementById('search-modal');
if (!modal || !modal.classList.contains('search-backdrop--open')) {
// Ctrl+K / Cmd+K to open
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
return;
}
// Modal is open
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSearch();
return;
}
var results = document.querySelectorAll('.search-result');
if (results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
searchSelectedIndex = Math.min(searchSelectedIndex + 1, results.length - 1);
updateSearchSelection(results);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
searchSelectedIndex = Math.max(searchSelectedIndex - 1, 0);
updateSearchSelection(results);
} else if (e.key === 'Enter') {
e.preventDefault();
var selected = results[searchSelectedIndex];
if (selected) {
var taskId = selected.dataset.taskId;
selectSearchResult(taskId);
}
}
});
function updateSearchSelection(results) {
results.forEach(function(r, i) {
if (i === searchSelectedIndex) {
r.classList.add('search-result--selected');
r.scrollIntoView({ block: 'nearest' });
} else {
r.classList.remove('search-result--selected');
}
});
}
// Reset selection when search results are updated
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'search-results') {
searchSelectedIndex = 0;
}
});
// ========================================
// SignalR — Real-time Activity Feed
// ========================================
function initActivityHub() {
var feed = document.getElementById('activity-feed');
if (!feed) return; // Not on the Analytics page
var connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/activity')
.withAutomaticReconnect()
.build();
connection.on('NewActivity', function(data) {
// Remove the empty-state placeholder if present
var empty = document.getElementById('activity-empty');
if (empty) empty.remove();
var item = document.createElement('div');
item.className = 'activity-item activity-item--new';
var displayText = data.url || data.windowTitle || '';
// Escape HTML to prevent XSS
var div = document.createElement('div');
div.textContent = data.appName || '';
var safeApp = div.innerHTML;
div.textContent = displayText;
var safeTitle = div.innerHTML;
item.innerHTML =
'<span class="activity-dot" style="background: var(--color-accent)"></span>' +
'<div class="activity-line"></div>' +
'<div class="activity-info">' +
'<span class="activity-app">' + safeApp + '</span>' +
'<span class="activity-title">' + safeTitle + '</span>' +
'<span class="activity-time">just now</span>' +
'</div>';
feed.insertBefore(item, feed.firstChild);
// Cap visible items at 100 to prevent memory bloat
var items = feed.querySelectorAll('.activity-item');
if (items.length > 100) {
items[items.length - 1].remove();
}
});
connection.start().catch(function(err) {
console.error('SignalR connection error:', err);
});
}
document.addEventListener('DOMContentLoaded', function() {
initActivityHub();
});
})();

View File

@@ -1,33 +0,0 @@
const overlay = document.getElementById('modal-overlay');
export function showModal(title, contentHtml, actions = []) {
overlay.innerHTML = `
<div class="modal">
<div class="modal-title">${title}</div>
<div class="modal-body">${contentHtml}</div>
<div class="modal-actions" id="modal-actions"></div>
</div>`;
const actionsEl = document.getElementById('modal-actions');
actions.forEach(({ label, cls, onClick }) => {
const btn = document.createElement('button');
btn.className = `btn ${cls || ''}`;
btn.textContent = label;
btn.addEventListener('click', async () => {
await onClick(overlay.querySelector('.modal'));
closeModal();
});
actionsEl.appendChild(btn);
});
overlay.classList.remove('hidden');
overlay.addEventListener('click', onOverlayClick);
}
export function closeModal() {
overlay.classList.add('hidden');
overlay.innerHTML = '';
overlay.removeEventListener('click', onOverlayClick);
}
function onOverlayClick(e) {
if (e.target === overlay) closeModal();
}

View File

@@ -1,91 +0,0 @@
import * as api from '../api.js';
const el = () => document.getElementById('page-context');
export async function initContext() {
el().innerHTML = `
<h1 class="page-title">Context</h1>
<div class="section-title">App Summary (8 hours)</div>
<div id="ctx-summary" class="card mb-16"></div>
<div class="flex-between mb-8">
<div class="section-title" style="margin-bottom:0">Recent Events</div>
<select class="form-select" id="ctx-minutes" style="width:auto">
<option value="15">Last 15 min</option>
<option value="30" selected>Last 30 min</option>
<option value="60">Last hour</option>
<option value="120">Last 2 hours</option>
<option value="480">Last 8 hours</option>
</select>
</div>
<div id="ctx-events" class="card table-wrap"></div>`;
document.getElementById('ctx-minutes').addEventListener('change', loadEvents);
await Promise.all([loadSummary(), loadEvents()]);
}
async function loadSummary() {
try {
const summary = await api.context.summary();
const container = document.getElementById('ctx-summary');
if (!summary || !summary.length) {
container.innerHTML = `<div class="empty-state">No activity recorded</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Application</th><th>Category</th><th>Events</th><th>First Seen</th><th>Last Seen</th></tr></thead>
<tbody>
${summary.map(s => `
<tr>
<td>${esc(s.appName)}</td>
<td>${esc(s.category)}</td>
<td>${s.eventCount}</td>
<td>${formatTime(s.firstSeen)}</td>
<td>${formatTime(s.lastSeen)}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (e) {
document.getElementById('ctx-summary').innerHTML = `<div class="empty-state">Failed to load summary</div>`;
}
}
async function loadEvents() {
const minutes = parseInt(document.getElementById('ctx-minutes').value);
try {
const events = await api.context.recent(minutes);
const container = document.getElementById('ctx-events');
if (!events || !events.length) {
container.innerHTML = `<div class="empty-state">No recent events</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Source</th><th>App</th><th>Window Title</th><th>URL</th><th>Time</th></tr></thead>
<tbody>
${events.map(e => `
<tr>
<td>${esc(e.source)}</td>
<td>${esc(e.appName)}</td>
<td class="truncate">${esc(e.windowTitle)}</td>
<td class="truncate">${e.url ? esc(e.url) : '-'}</td>
<td>${formatTime(e.timestamp)}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (e) {
document.getElementById('ctx-events').innerHTML = `<div class="empty-state">Failed to load events</div>`;
}
}
function formatTime(iso) {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,127 +0,0 @@
import * as api from '../api.js';
const el = () => document.getElementById('page-dashboard');
export function initDashboard() {
el().innerHTML = `
<h1 class="page-title">Dashboard</h1>
<div id="dash-active-task"></div>
<div class="section-title mt-16">Task Summary</div>
<div id="dash-stats" class="stats-grid"></div>
<div class="section-title mt-16">Recent Activity (8 hours)</div>
<div id="dash-context" class="card"></div>`;
}
export async function refreshDashboard() {
try {
const [active, allTasks, summary] = await Promise.all([
api.tasks.active(),
api.tasks.list(null, { includeSubTasks: true }),
api.context.summary(),
]);
await renderActiveTask(active);
renderStats(allTasks);
renderContextSummary(summary);
} catch (e) {
console.error('Dashboard refresh failed:', e);
}
}
async function buildParentTrail(task) {
const trail = [];
let current = task;
while (current.parentTaskId) {
try {
current = await api.tasks.get(current.parentTaskId);
trail.unshift({ id: current.id, title: current.title });
} catch {
break;
}
}
return trail;
}
async function renderActiveTask(task) {
const container = document.getElementById('dash-active-task');
if (!task) {
container.innerHTML = `<div class="card"><div class="no-active-task">No active task</div></div>`;
return;
}
const parentTrail = await buildParentTrail(task);
const breadcrumbHtml = parentTrail.length > 0
? `<div class="breadcrumb text-sm mt-8">${parentTrail.map(p => `<span class="breadcrumb-parent">${esc(p.title)}</span><span class="breadcrumb-sep">/</span>`).join('')}<span class="breadcrumb-current">${esc(task.title)}</span></div>`
: '';
const elapsed = task.startedAt ? formatElapsed(new Date(task.startedAt)) : '';
container.innerHTML = `
<div class="card active-task-card">
<div class="card-header">
<div>
<div class="card-title">${esc(task.title)}</div>
${breadcrumbHtml}
${task.description ? `<div class="text-sm text-muted mt-8">${esc(task.description)}</div>` : ''}
${task.category ? `<div class="text-sm text-muted">Category: ${esc(task.category)}</div>` : ''}
${elapsed ? `<div class="text-sm text-muted">Active for ${elapsed}</div>` : ''}
</div>
<div class="btn-group">
<button class="btn btn-warning btn-sm" data-action="pause" data-id="${task.id}">Pause</button>
<button class="btn btn-success btn-sm" data-action="complete" data-id="${task.id}">Complete</button>
</div>
</div>
</div>`;
container.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
const id = btn.dataset.id;
try {
if (action === 'pause') await api.tasks.pause(id);
else if (action === 'complete') await api.tasks.complete(id);
refreshDashboard();
} catch (e) { alert(e.message); }
});
});
}
function renderStats(allTasks) {
const counts = { Pending: 0, Active: 0, Paused: 0, Completed: 0, Abandoned: 0 };
allTasks.forEach(t => counts[t.status] = (counts[t.status] || 0) + 1);
const container = document.getElementById('dash-stats');
container.innerHTML = Object.entries(counts).map(([status, count]) => `
<div class="stat-card">
<div class="stat-value">${count}</div>
<div class="stat-label">${status}</div>
</div>`).join('');
}
function renderContextSummary(summary) {
const container = document.getElementById('dash-context');
if (!summary || summary.length === 0) {
container.innerHTML = `<div class="empty-state">No recent activity</div>`;
return;
}
container.innerHTML = summary.slice(0, 10).map(item => `
<div class="summary-item">
<div>
<div class="summary-app">${esc(item.appName)}</div>
<div class="summary-category">${esc(item.category)}</div>
</div>
<div class="summary-count">${item.eventCount}</div>
</div>`).join('');
}
function formatElapsed(since) {
const diff = Math.floor((Date.now() - since.getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
return `${h}h ${m}m`;
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,119 +0,0 @@
import * as api from '../api.js';
import { showModal } from '../components/modal.js';
const el = () => document.getElementById('page-mappings');
export async function initMappings() {
el().innerHTML = `
<h1 class="page-title">App Mappings</h1>
<div class="flex-between mb-16">
<div class="text-muted text-sm">Map process names, window titles, or URLs to categories</div>
<button class="btn btn-primary" id="btn-new-mapping">+ New Mapping</button>
</div>
<div id="mapping-list" class="card table-wrap"></div>`;
document.getElementById('btn-new-mapping').addEventListener('click', () => showMappingForm());
await loadMappings();
}
async function loadMappings() {
try {
const mappings = await api.mappings.list();
const container = document.getElementById('mapping-list');
if (!mappings || !mappings.length) {
container.innerHTML = `<div class="empty-state">No mappings configured</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Pattern</th><th>Match Type</th><th>Category</th><th>Friendly Name</th><th>Actions</th></tr></thead>
<tbody>
${mappings.map(m => `
<tr>
<td><code>${esc(m.pattern)}</code></td>
<td><span class="badge badge-pending">${m.matchType}</span></td>
<td>${esc(m.category)}</td>
<td>${esc(m.friendlyName) || '<span class="text-muted">-</span>'}</td>
<td>
<div class="btn-group">
<button class="btn btn-sm" data-edit="${m.id}">Edit</button>
<button class="btn btn-sm btn-danger" data-delete="${m.id}">Delete</button>
</div>
</td>
</tr>`).join('')}
</tbody>
</table>`;
container.querySelectorAll('[data-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const m = mappings.find(x => x.id === parseInt(btn.dataset.edit));
if (m) showMappingForm(m);
});
});
container.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', () => confirmDelete(parseInt(btn.dataset.delete)));
});
} catch (e) {
document.getElementById('mapping-list').innerHTML = `<div class="empty-state">Failed to load mappings</div>`;
}
}
function showMappingForm(existing = null) {
const title = existing ? 'Edit Mapping' : 'New Mapping';
showModal(title, `
<div class="form-group">
<label class="form-label">Pattern *</label>
<input type="text" class="form-input" id="map-pattern" value="${esc(existing?.pattern || '')}">
</div>
<div class="form-group">
<label class="form-label">Match Type *</label>
<select class="form-select" id="map-match-type">
<option value="ProcessName" ${existing?.matchType === 'ProcessName' ? 'selected' : ''}>Process Name</option>
<option value="TitleContains" ${existing?.matchType === 'TitleContains' ? 'selected' : ''}>Title Contains</option>
<option value="UrlContains" ${existing?.matchType === 'UrlContains' ? 'selected' : ''}>URL Contains</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category *</label>
<input type="text" class="form-input" id="map-category" value="${esc(existing?.category || '')}">
</div>
<div class="form-group">
<label class="form-label">Friendly Name</label>
<input type="text" class="form-input" id="map-friendly" value="${esc(existing?.friendlyName || '')}">
</div>`,
[
{ label: 'Cancel', onClick: () => {} },
{
label: existing ? 'Save' : 'Create', cls: 'btn-primary', onClick: async (modal) => {
const pattern = modal.querySelector('#map-pattern').value.trim();
const matchType = modal.querySelector('#map-match-type').value;
const category = modal.querySelector('#map-category').value.trim();
const friendlyName = modal.querySelector('#map-friendly').value.trim() || null;
if (!pattern || !category) { alert('Pattern and Category are required'); throw new Error('cancel'); }
const body = { pattern, matchType, category, friendlyName };
if (existing) await api.mappings.update(existing.id, body);
else await api.mappings.create(body);
loadMappings();
},
},
]);
setTimeout(() => document.getElementById('map-pattern')?.focus(), 100);
}
function confirmDelete(id) {
showModal('Delete Mapping', `<p>Are you sure you want to delete this mapping?</p>`, [
{ label: 'Cancel', onClick: () => {} },
{
label: 'Delete', cls: 'btn-danger', onClick: async () => {
await api.mappings.remove(id);
loadMappings();
},
},
]);
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,357 +0,0 @@
import * as api from '../api.js';
import { showModal, closeModal } from '../components/modal.js';
const el = () => document.getElementById('page-tasks');
let currentFilter = null;
let selectedTaskId = null;
export function initTasks() {
el().innerHTML = `
<h1 class="page-title">Tasks</h1>
<div class="flex-between mb-16">
<div id="task-filters" class="filter-bar"></div>
<button class="btn btn-primary" id="btn-new-task">+ New Task</button>
</div>
<div id="task-list"></div>
<div id="task-detail" class="hidden"></div>`;
renderFilters();
document.getElementById('btn-new-task').addEventListener('click', () => showNewTaskModal());
loadTasks();
}
function renderFilters() {
const statuses = [null, 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
const labels = ['All', 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
const container = document.getElementById('task-filters');
container.innerHTML = statuses.map((s, i) => `
<button class="filter-btn ${s === currentFilter ? 'active' : ''}" data-status="${s || ''}">${labels[i]}</button>`).join('');
container.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentFilter = btn.dataset.status || null;
renderFilters();
loadTasks();
});
});
}
async function loadTasks() {
try {
const tasks = await api.tasks.list(currentFilter);
renderTaskList(tasks);
} catch (e) {
document.getElementById('task-list').innerHTML = `<div class="empty-state">Failed to load tasks</div>`;
}
}
function renderTaskList(tasks) {
const container = document.getElementById('task-list');
document.getElementById('task-detail').classList.add('hidden');
container.classList.remove('hidden');
if (!tasks.length) {
container.innerHTML = `<div class="empty-state">No tasks found</div>`;
return;
}
container.innerHTML = tasks.map(t => {
const subCount = t.subTasks ? t.subTasks.length : 0;
return `
<div class="task-item" data-id="${t.id}">
<div class="task-item-left">
<span class="badge badge-${t.status.toLowerCase()}">${t.status}</span>
<span class="task-item-title">${esc(t.title)}</span>
${subCount > 0 ? `<span class="subtask-count">${subCount} subtask${subCount !== 1 ? 's' : ''}</span>` : ''}
</div>
<div class="task-item-meta">${t.category ? esc(t.category) + ' &middot; ' : ''}${formatDate(t.createdAt)}</div>
</div>`;
}).join('');
container.querySelectorAll('.task-item').forEach(item => {
item.addEventListener('click', () => showTaskDetail(parseInt(item.dataset.id)));
});
}
async function buildBreadcrumbs(task) {
const trail = [{ id: task.id, title: task.title }];
let current = task;
while (current.parentTaskId) {
try {
current = await api.tasks.get(current.parentTaskId);
trail.unshift({ id: current.id, title: current.title });
} catch {
break;
}
}
return trail;
}
async function showTaskDetail(id) {
selectedTaskId = id;
try {
const task = await api.tasks.get(id);
const container = document.getElementById('task-detail');
document.getElementById('task-list').classList.add('hidden');
container.classList.remove('hidden');
// Build breadcrumb trail
const breadcrumbs = await buildBreadcrumbs(task);
const breadcrumbHtml = breadcrumbs.length > 1
? `<div class="breadcrumb">${breadcrumbs.map((b, i) =>
i < breadcrumbs.length - 1
? `<a href="#" class="breadcrumb-link" data-id="${b.id}">${esc(b.title)}</a><span class="breadcrumb-sep">/</span>`
: `<span class="breadcrumb-current">${esc(b.title)}</span>`
).join('')}</div>`
: '';
container.innerHTML = `
<button class="btn btn-sm mb-16" id="btn-back-tasks">&larr; ${task.parentTaskId ? 'Back to parent' : 'Back to list'}</button>
${breadcrumbHtml}
<div class="task-detail">
<div class="task-detail-header">
<div>
<div class="task-detail-title">${esc(task.title)}</div>
<span class="badge badge-${task.status.toLowerCase()}">${task.status}</span>
</div>
<div class="btn-group" id="task-actions"></div>
</div>
${task.description ? `<p class="text-muted mb-16">${esc(task.description)}</p>` : ''}
<div class="task-meta-grid card">
<div class="meta-item"><div class="meta-label">Category</div>${esc(task.category) || 'None'}</div>
<div class="meta-item"><div class="meta-label">Created</div>${formatDateTime(task.createdAt)}</div>
<div class="meta-item"><div class="meta-label">Started</div>${task.startedAt ? formatDateTime(task.startedAt) : 'Not started'}</div>
<div class="meta-item"><div class="meta-label">Completed</div>${task.completedAt ? formatDateTime(task.completedAt) : '-'}</div>
</div>
<div class="section-title mt-16">Subtasks</div>
<div id="task-subtasks" class="subtask-list"></div>
${task.status !== 'Completed' && task.status !== 'Abandoned' ? `<button class="btn btn-sm btn-primary mt-8" id="btn-add-subtask">+ Add Subtask</button>` : ''}
<div class="section-title mt-16">Notes</div>
<div id="task-notes"></div>
<div class="form-inline mt-8">
<div class="form-group">
<input type="text" class="form-input" id="note-input" placeholder="Add a note...">
</div>
<button class="btn btn-primary btn-sm" id="btn-add-note">Add</button>
</div>
${task.contextEvents && task.contextEvents.length ? `
<div class="section-title mt-16">Linked Context Events</div>
<div class="table-wrap card">
<table>
<thead><tr><th>App</th><th>Title</th><th>Time</th></tr></thead>
<tbody>
${task.contextEvents.slice(0, 50).map(e => `
<tr>
<td>${esc(e.appName)}</td>
<td class="truncate">${esc(e.windowTitle)}</td>
<td>${formatTime(e.timestamp)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
</div>`;
// Render action buttons based on status
renderActions(task);
// Render subtasks
renderSubTasks(task.subTasks || []);
// Render notes
renderNotes(task.notes || []);
// Breadcrumb navigation
container.querySelectorAll('.breadcrumb-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTaskDetail(parseInt(link.dataset.id));
});
});
// Back button
document.getElementById('btn-back-tasks').addEventListener('click', () => {
if (task.parentTaskId) {
showTaskDetail(task.parentTaskId);
} else {
container.classList.add('hidden');
document.getElementById('task-list').classList.remove('hidden');
loadTasks();
}
});
// Add subtask button
const addSubBtn = document.getElementById('btn-add-subtask');
if (addSubBtn) {
addSubBtn.addEventListener('click', () => showNewTaskModal(task.id));
}
// Add note
const noteInput = document.getElementById('note-input');
document.getElementById('btn-add-note').addEventListener('click', () => addNote(task.id, noteInput));
noteInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addNote(task.id, noteInput);
});
} catch (e) {
alert('Failed to load task: ' + e.message);
}
}
function renderSubTasks(subTasks) {
const container = document.getElementById('task-subtasks');
if (!subTasks.length) {
container.innerHTML = `<div class="text-muted text-sm">No subtasks</div>`;
return;
}
container.innerHTML = subTasks.map(st => {
const subCount = st.subTasks ? st.subTasks.length : 0;
const canStart = st.status === 'Pending' || st.status === 'Paused';
const canComplete = st.status === 'Active' || st.status === 'Paused';
return `
<div class="subtask-item" data-id="${st.id}">
<div class="subtask-item-left">
<span class="badge badge-${st.status.toLowerCase()}">${st.status}</span>
<a href="#" class="subtask-item-title" data-id="${st.id}">${esc(st.title)}</a>
${subCount > 0 ? `<span class="subtask-count">${subCount}</span>` : ''}
</div>
<div class="btn-group">
${canStart ? `<button class="btn btn-sm btn-success subtask-action" data-action="start" data-id="${st.id}">${st.status === 'Paused' ? 'Resume' : 'Start'}</button>` : ''}
${canComplete ? `<button class="btn btn-sm btn-success subtask-action" data-action="complete" data-id="${st.id}">Complete</button>` : ''}
</div>
</div>`;
}).join('');
// Navigate to subtask detail
container.querySelectorAll('.subtask-item-title').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTaskDetail(parseInt(link.dataset.id));
});
});
// Inline subtask actions
container.querySelectorAll('.subtask-action').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const stId = parseInt(btn.dataset.id);
try {
if (action === 'start') await api.tasks.start(stId);
else if (action === 'complete') await api.tasks.complete(stId);
showTaskDetail(selectedTaskId);
} catch (err) { alert(err.message); }
});
});
}
function renderActions(task) {
const container = document.getElementById('task-actions');
const actions = [];
switch (task.status) {
case 'Pending':
actions.push({ label: 'Start', cls: 'btn-success', action: () => api.tasks.start(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
case 'Active':
actions.push({ label: 'Pause', cls: 'btn-warning', action: () => api.tasks.pause(task.id) });
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
case 'Paused':
actions.push({ label: 'Resume', cls: 'btn-success', action: () => api.tasks.resume(task.id) });
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
}
container.innerHTML = '';
actions.forEach(({ label, cls, action }) => {
const btn = document.createElement('button');
btn.className = `btn btn-sm ${cls}`;
btn.textContent = label;
btn.addEventListener('click', async () => {
try {
await action();
showTaskDetail(task.id);
} catch (e) { alert(e.message); }
});
container.appendChild(btn);
});
}
function renderNotes(notes) {
const container = document.getElementById('task-notes');
if (!notes.length) {
container.innerHTML = `<div class="text-muted text-sm">No notes yet</div>`;
return;
}
container.innerHTML = notes.map(n => `
<div class="note-item">
<div class="note-item-header">
<span class="note-type">${n.type}</span>
<span class="note-time">${formatDateTime(n.createdAt)}</span>
</div>
<div>${esc(n.content)}</div>
</div>`).join('');
}
async function addNote(taskId, input) {
const content = input.value.trim();
if (!content) return;
try {
await api.notes.create(taskId, { content, type: 'General' });
input.value = '';
showTaskDetail(taskId);
} catch (e) { alert(e.message); }
}
function showNewTaskModal(parentTaskId = null) {
const title = parentTaskId ? 'New Subtask' : 'New Task';
showModal(title, `
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" class="form-input" id="new-task-title">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="new-task-desc"></textarea>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="new-task-cat">
</div>`,
[
{ label: 'Cancel', onClick: () => {} },
{
label: 'Create', cls: 'btn-primary', onClick: async (modal) => {
const taskTitle = modal.querySelector('#new-task-title').value.trim();
if (!taskTitle) { alert('Title is required'); throw new Error('cancel'); }
const description = modal.querySelector('#new-task-desc').value.trim() || null;
const category = modal.querySelector('#new-task-cat').value.trim() || null;
const body = { title: taskTitle, description, category };
if (parentTaskId) body.parentTaskId = parentTaskId;
await api.tasks.create(body);
if (parentTaskId) {
showTaskDetail(parentTaskId);
} else {
loadTasks();
}
},
},
]);
setTimeout(() => document.getElementById('new-task-title')?.focus(), 100);
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString();
}
function formatDateTime(iso) {
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>TaskTracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "tasktracker-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,20 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Layout from './components/Layout'
import Board from './pages/Board'
import Analytics from './pages/Analytics'
import Mappings from './pages/Mappings'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/board" replace />} />
<Route path="/board" element={<Board />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/mappings" element={<Mappings />} />
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -1,12 +0,0 @@
import axios from 'axios'
import type { ApiResponse } from '../types'
const api = axios.create({ baseURL: '/api' })
export async function request<T>(config: Parameters<typeof api.request>[0]): Promise<T> {
const { data } = await api.request<ApiResponse<T>>(config)
if (!data.success) throw new Error(data.error ?? 'API error')
return data.data
}
export default api

View File

@@ -1,19 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { request } from './client'
import type { ContextEvent, ContextSummaryItem } from '../types'
export function useRecentContext(minutes = 30) {
return useQuery({
queryKey: ['context', 'recent', minutes],
queryFn: () => request<ContextEvent[]>({ url: '/context/recent', params: { minutes } }),
refetchInterval: 60_000,
})
}
export function useContextSummary() {
return useQuery({
queryKey: ['context', 'summary'],
queryFn: () => request<ContextSummaryItem[]>({ url: '/context/summary' }),
refetchInterval: 60_000,
})
}

View File

@@ -1,36 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from './client'
import type { AppMapping } from '../types'
export function useMappings() {
return useQuery({
queryKey: ['mappings'],
queryFn: () => request<AppMapping[]>({ url: '/mappings' }),
})
}
export function useCreateMapping() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { pattern: string; matchType: string; category: string; friendlyName?: string }) =>
request<AppMapping>({ method: 'POST', url: '/mappings', data: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
})
}
export function useUpdateMapping() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: number; pattern: string; matchType: string; category: string; friendlyName?: string }) =>
request<AppMapping>({ method: 'PUT', url: `/mappings/${id}`, data: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
})
}
export function useDeleteMapping() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/mappings/${id}` }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
})
}

View File

@@ -1,12 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from './client'
import type { TaskNote } from '../types'
export function useCreateNote() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ taskId, content, type }: { taskId: number; content: string; type: string }) =>
request<TaskNote>({ method: 'POST', url: `/tasks/${taskId}/notes`, data: { content, type } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}

View File

@@ -1,92 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from './client'
import type { WorkTask } from '../types'
export function useTasks(includeSubTasks = true) {
return useQuery({
queryKey: ['tasks', { includeSubTasks }],
queryFn: () => request<WorkTask[]>({ url: '/tasks', params: { includeSubTasks } }),
})
}
export function useActiveTask() {
return useQuery({
queryKey: ['tasks', 'active'],
queryFn: () => request<WorkTask | null>({ url: '/tasks/active' }),
refetchInterval: 30_000,
})
}
export function useTask(id: number) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => request<WorkTask>({ url: `/tasks/${id}` }),
})
}
function useInvalidateTasks() {
const qc = useQueryClient()
return () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
}
}
export function useCreateTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (body: { title: string; description?: string; category?: string; parentTaskId?: number; estimatedMinutes?: number }) =>
request<WorkTask>({ method: 'POST', url: '/tasks', data: body }),
onSuccess: invalidate,
})
}
export function useUpdateTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: ({ id, ...body }: { id: number; title?: string; description?: string; category?: string; estimatedMinutes?: number }) =>
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}`, data: body }),
onSuccess: invalidate,
})
}
export function useStartTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/start` }),
onSuccess: invalidate,
})
}
export function usePauseTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: ({ id, note }: { id: number; note?: string }) =>
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/pause`, data: { note } }),
onSuccess: invalidate,
})
}
export function useResumeTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: ({ id, note }: { id: number; note?: string }) =>
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/resume`, data: { note } }),
onSuccess: invalidate,
})
}
export function useCompleteTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/complete` }),
onSuccess: invalidate,
})
}
export function useAbandonTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/tasks/${id}` }),
onSuccess: invalidate,
})
}

View File

@@ -1,125 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useCreateTask } from '../api/tasks.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
interface CreateTaskFormProps {
onClose: () => void
}
const categories = Object.keys(CATEGORY_COLORS).filter((k) => k !== 'Unknown')
export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [estimatedMinutes, setEstimatedMinutes] = useState('')
const titleRef = useRef<HTMLInputElement>(null)
const createTask = useCreateTask()
useEffect(() => {
titleRef.current?.focus()
}, [])
function handleSubmit() {
const trimmed = title.trim()
if (!trimmed) return
createTask.mutate(
{
title: trimmed,
description: description.trim() || undefined,
category: category || undefined,
estimatedMinutes: estimatedMinutes ? Number(estimatedMinutes) : undefined,
},
{ onSuccess: () => onClose() }
)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
e.preventDefault()
handleSubmit()
}
}
const inputClass =
'w-full rounded-md bg-[var(--color-page)] text-white text-sm px-3 py-2 border border-[var(--color-border)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/60 focus:border-transparent transition-colors'
return (
<div
className="rounded-lg bg-[var(--color-surface)] p-3 space-y-3"
onKeyDown={handleKeyDown}
>
{/* Title */}
<input
ref={titleRef}
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputClass}
/>
{/* Description */}
<textarea
placeholder="Description (optional)"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
className={`${inputClass} resize-none`}
/>
{/* Category + Estimated Minutes row */}
<div className="flex gap-2">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className={`${inputClass} flex-1 appearance-none cursor-pointer`}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<input
type="number"
placeholder="Est. min"
min={1}
value={estimatedMinutes}
onChange={(e) => setEstimatedMinutes(e.target.value)}
className={`${inputClass} w-24`}
/>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="text-xs text-[var(--color-text-secondary)] hover:text-white transition-colors px-2 py-1"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!title.trim() || createTask.isPending}
className="flex items-center gap-1.5 text-xs font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110
disabled:opacity-40 disabled:cursor-not-allowed
px-3 py-1.5 rounded-md transition-colors"
>
{createTask.isPending && <Loader2 size={12} className="animate-spin" />}
Create
</button>
</div>
</div>
)
}

View File

@@ -1,137 +0,0 @@
import { useMemo } from 'react'
import { X, ListTree } from 'lucide-react'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
export interface Filters {
categories: string[]
hasSubtasks: boolean
}
export const EMPTY_FILTERS: Filters = { categories: [], hasSubtasks: false }
interface FilterBarProps {
tasks: WorkTask[]
filters: Filters
onFiltersChange: (filters: Filters) => void
}
export function applyFilters(tasks: WorkTask[], filters: Filters): WorkTask[] {
let result = tasks
if (filters.categories.length > 0) {
result = result.filter((t) => filters.categories.includes(t.category ?? 'Unknown'))
}
if (filters.hasSubtasks) {
result = result.filter((t) => t.subTasks && t.subTasks.length > 0)
}
return result
}
export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBarProps) {
// Derive unique categories from tasks + CATEGORY_COLORS keys
const allCategories = useMemo(() => {
const fromTasks = new Set(tasks.map((t) => t.category ?? 'Unknown'))
const fromConfig = new Set(Object.keys(CATEGORY_COLORS))
const merged = new Set([...fromConfig, ...fromTasks])
return Array.from(merged).sort()
}, [tasks])
const hasActiveFilters = filters.categories.length > 0 || filters.hasSubtasks
const toggleCategory = (category: string) => {
const active = filters.categories
const next = active.includes(category)
? active.filter((c) => c !== category)
: [...active, category]
onFiltersChange({ ...filters, categories: next })
}
const toggleHasSubtasks = () => {
onFiltersChange({ ...filters, hasSubtasks: !filters.hasSubtasks })
}
const clearAll = () => {
onFiltersChange(EMPTY_FILTERS)
}
return (
<div className="flex items-center gap-2 flex-wrap mb-4">
{/* "All" chip */}
<button
onClick={clearAll}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
!hasActiveFilters
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
All
</button>
{/* Divider */}
<div className="w-px h-4 bg-white/[0.06]" />
{/* Category chips */}
{allCategories.map((cat) => {
const isActive = filters.categories.includes(cat)
const color = CATEGORY_COLORS[cat] ?? CATEGORY_COLORS['Unknown']
return (
<button
key={cat}
onClick={() => toggleCategory(cat)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
isActive ? 'text-white' : 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
style={
isActive
? { backgroundColor: color, color: '#fff' }
: undefined
}
>
{cat}
{isActive && (
<X
size={10}
className="ml-0.5 opacity-70 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
toggleCategory(cat)
}}
/>
)}
</button>
)
})}
{/* Divider */}
<div className="w-px h-4 bg-white/[0.06]" />
{/* Has subtasks chip */}
<button
onClick={toggleHasSubtasks}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
filters.hasSubtasks
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
<ListTree size={12} />
Has subtasks
{filters.hasSubtasks && (
<X
size={10}
className="ml-0.5 opacity-70 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
toggleHasSubtasks()
}}
/>
)}
</button>
</div>
)
}

View File

@@ -1,153 +0,0 @@
import { useMemo, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
useStartTask,
usePauseTask,
useResumeTask,
useCompleteTask,
} from '../api/tasks.ts'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { COLUMN_CONFIG } from '../lib/constants.ts'
import KanbanColumn from './KanbanColumn.tsx'
import TaskCard from './TaskCard.tsx'
interface KanbanBoardProps {
tasks: WorkTask[]
isLoading: boolean
onTaskClick: (id: number) => void
}
export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) {
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
const completeTask = useCompleteTask()
const [activeTask, setActiveTask] = useState<WorkTask | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
)
// Filter to top-level tasks only and group by status
const columns = useMemo(() => {
const topLevel = tasks.filter((t) => t.parentTaskId === null)
return COLUMN_CONFIG.map((col) => ({
...col,
tasks: topLevel.filter((t) => t.status === col.status),
}))
}, [tasks])
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const draggedId = Number(event.active.id)
const task = tasks.find((t) => t.id === draggedId) ?? null
setActiveTask(task)
},
[tasks]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveTask(null)
const { active, over } = event
if (!over) return
const taskId = Number(active.id)
const task = tasks.find((t) => t.id === taskId)
if (!task) return
// Determine target status from the droppable column ID
let targetStatus: string | null = null
const overId = String(over.id)
if (overId.startsWith('column-')) {
targetStatus = overId.replace('column-', '')
} else {
// Dropped over another card - find which column it belongs to
const overTaskId = Number(over.id)
const overTask = tasks.find((t) => t.id === overTaskId)
if (overTask) {
targetStatus = overTask.status
}
}
if (targetStatus === null || targetStatus === task.status) return
// Map transitions to API calls
switch (targetStatus) {
case WorkTaskStatus.Active:
// Works for both Pending->Active and Paused->Active
if (task.status === WorkTaskStatus.Paused) {
resumeTask.mutate({ id: taskId })
} else {
startTask.mutate(taskId)
}
break
case WorkTaskStatus.Paused:
if (task.status === WorkTaskStatus.Active) {
pauseTask.mutate({ id: taskId })
}
break
case WorkTaskStatus.Completed:
completeTask.mutate(taskId)
break
case WorkTaskStatus.Pending:
// Transition back to Pending is not supported
break
}
},
[tasks, startTask, pauseTask, resumeTask, completeTask]
)
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-[#64748b]" size={32} />
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4 h-full">
{columns.map((col) => (
<KanbanColumn
key={col.status}
status={col.status}
label={col.label}
color={col.color}
tasks={col.tasks}
onTaskClick={onTaskClick}
onAddTask={col.status === WorkTaskStatus.Pending ? () => {} : undefined}
/>
))}
</div>
<DragOverlay>
{activeTask ? (
<div className="rotate-1 scale-[1.03] opacity-90">
<TaskCard task={activeTask} onClick={() => {}} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}

View File

@@ -1,87 +0,0 @@
import { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Plus } from 'lucide-react'
import { WorkTaskStatus, type WorkTask } from '../types/index.ts'
import TaskCard from './TaskCard.tsx'
import CreateTaskForm from './CreateTaskForm.tsx'
interface KanbanColumnProps {
status: string
label: string
color: string
tasks: WorkTask[]
onTaskClick: (id: number) => void
onAddTask?: () => void
}
export default function KanbanColumn({
status,
label,
color,
tasks,
onTaskClick,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: `column-${status}` })
const [showForm, setShowForm] = useState(false)
const taskIds = tasks.map((t) => t.id)
return (
<div className="flex flex-col min-h-[300px]">
{/* Column header */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
{label}
</h2>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{tasks.length}
</span>
</div>
<div className="h-[2px] rounded-full" style={{ backgroundColor: color }} />
</div>
{/* Cards area */}
<div
ref={setNodeRef}
className={`flex-1 flex flex-col gap-2 rounded-lg transition-colors duration-200 py-1 ${
isOver ? 'bg-white/[0.02]' : ''
}`}
>
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</SortableContext>
{/* Empty state */}
{tasks.length === 0 && !showForm && (
<div className="flex-1 flex items-center justify-center min-h-[80px] rounded-lg border border-dashed border-white/[0.06]">
<span className="text-[11px] text-[var(--color-text-tertiary)]">No tasks</span>
</div>
)}
</div>
{/* Add task (Pending column only) */}
{status === WorkTaskStatus.Pending && (
<div className="mt-2">
{showForm ? (
<CreateTaskForm onClose={() => setShowForm(false)} />
) : (
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-1.5 w-full py-2 rounded-lg
text-[11px] text-[var(--color-text-tertiary)]
hover:text-[var(--color-text-secondary)] hover:bg-white/[0.02]
transition-colors"
>
<Plus size={13} />
New task
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -1,108 +0,0 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutGrid, BarChart3, Link, Plus, Search } from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import SearchModal from './SearchModal.tsx'
const navItems = [
{ to: '/board', label: 'Board', icon: LayoutGrid },
{ to: '/analytics', label: 'Analytics', icon: BarChart3 },
{ to: '/mappings', label: 'Mappings', icon: Link },
]
export default function Layout() {
const navigate = useNavigate()
const [searchOpen, setSearchOpen] = useState(false)
const [showCreateHint, setShowCreateHint] = useState(false)
// Global Cmd+K handler
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setSearchOpen(true)
}
}, [])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [handleGlobalKeyDown])
return (
<div className="flex flex-col h-screen bg-[var(--color-page)] text-[var(--color-text-primary)] overflow-hidden">
{/* Top navigation bar */}
<header className="flex items-center h-12 px-4 border-b border-[var(--color-border)] shrink-0 bg-[var(--color-page)]">
{/* Logo */}
<div className="flex items-center gap-2 mr-8">
<div className="w-5 h-5 rounded bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent-end)] flex items-center justify-center">
<span className="text-[10px] font-bold text-white">T</span>
</div>
<span className="text-sm font-semibold tracking-tight">TaskTracker</span>
</div>
{/* Nav tabs */}
<nav className="flex items-center gap-1">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors ${
isActive
? 'text-white bg-white/[0.08]'
: 'text-[var(--color-text-secondary)] hover:text-white hover:bg-white/[0.04]'
}`
}
>
<Icon size={15} />
{label}
</NavLink>
))}
</nav>
{/* Spacer */}
<div className="flex-1" />
{/* Search trigger */}
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 h-7 px-2.5 rounded-md text-[12px] text-[var(--color-text-secondary)] bg-white/[0.04] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] transition-colors mr-2"
>
<Search size={13} />
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1 py-0.5 rounded">
Ctrl K
</kbd>
</button>
{/* New task button */}
<button
onClick={() => {
navigate('/board')
setShowCreateHint(true)
setTimeout(() => setShowCreateHint(false), 100)
}}
className="flex items-center gap-1 h-7 px-2.5 rounded-md text-[12px] font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 transition-all"
>
<Plus size={14} />
<span className="hidden sm:inline">New Task</span>
</button>
</header>
{/* Content */}
<main className="flex-1 overflow-auto p-5">
<Outlet context={{ showCreateHint }} />
</main>
{/* Search modal */}
{searchOpen && (
<SearchModal
onSelect={(taskId) => {
setSearchOpen(false)
navigate(`/board?task=${taskId}`)
}}
onClose={() => setSearchOpen(false)}
/>
)}
</div>
)
}

View File

@@ -1,126 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Plus } from 'lucide-react'
import { NoteType } from '../types/index.ts'
import type { TaskNote } from '../types/index.ts'
import { useCreateNote } from '../api/notes.ts'
interface NotesListProps {
taskId: number
notes: TaskNote[]
}
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = Date.now()
const diffMs = now - date.getTime()
const diffMins = Math.floor(diffMs / 60_000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
const diffWeeks = Math.floor(diffDays / 7)
if (diffWeeks < 5) return `${diffWeeks}w ago`
return date.toLocaleDateString()
}
export default function NotesList({ taskId, notes }: NotesListProps) {
const [showInput, setShowInput] = useState(false)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const createNote = useCreateNote()
useEffect(() => {
if (showInput) inputRef.current?.focus()
}, [showInput])
// Chronological order (oldest first)
const sortedNotes = [...notes].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && inputValue.trim()) {
createNote.mutate(
{ taskId, content: inputValue.trim(), type: NoteType.General },
{
onSuccess: () => {
setInputValue('')
},
}
)
}
if (e.key === 'Escape') {
setShowInput(false)
setInputValue('')
}
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Notes
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-3">
{sortedNotes.map((note) => {
const typeConfig = NOTE_TYPE_CONFIG[note.type] ?? NOTE_TYPE_CONFIG[NoteType.General]
return (
<div key={note.id} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
>
{typeConfig.label}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{formatRelativeTime(note.createdAt)}
</span>
</div>
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
</div>
)
})}
{sortedNotes.length === 0 && !showInput && (
<p className="text-sm text-[var(--color-text-secondary)] italic">No notes yet</p>
)}
{showInput && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (!inputValue.trim()) {
setShowInput(false)
setInputValue('')
}
}}
placeholder="Add a note..."
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
)}
</div>
</div>
)
}

View File

@@ -1,192 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Search, ArrowRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useTasks } from '../api/tasks.ts'
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
import type { WorkTask } from '../types/index.ts'
interface SearchModalProps {
onSelect: (taskId: number) => void
onClose: () => void
}
export default function SearchModal({ onSelect, onClose }: SearchModalProps) {
const { data: tasks } = useTasks()
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
// Filter tasks
const results: WorkTask[] = (() => {
if (!tasks) return []
if (!query.trim()) {
// Show recent/active tasks when no query
return tasks
.filter((t) => t.status === 'Active' || t.status === 'Paused' || t.status === 'Pending')
.slice(0, 8)
}
const q = query.toLowerCase()
return tasks
.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)) ||
(t.category && t.category.toLowerCase().includes(q))
)
.slice(0, 8)
})()
useEffect(() => {
setSelectedIndex(0)
}, [query])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (results[selectedIndex]) {
onSelect(results[selectedIndex].id)
}
break
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[results, selectedIndex, onSelect, onClose]
)
const getStatusColor = (status: string) => {
const col = COLUMN_CONFIG.find((c) => c.status === status)
return col ? col.color : '#64748b'
}
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<motion.div
className="relative w-full max-w-lg bg-[var(--color-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl shadow-black/50 overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.15 }}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
<Search size={16} className="text-[var(--color-text-secondary)] shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search tasks..."
className="flex-1 bg-transparent text-[var(--color-text-primary)] text-sm placeholder-[var(--color-text-tertiary)] outline-none"
/>
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1.5 py-0.5 rounded border border-[var(--color-border)]">
ESC
</kbd>
</div>
{/* Results */}
{results.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto py-1">
{!query.trim() && (
<div className="px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
Recent tasks
</div>
)}
{results.map((task, index) => {
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const statusColor = getStatusColor(task.status)
return (
<button
key={task.id}
onClick={() => onSelect(task.id)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === selectedIndex ? 'bg-white/[0.06]' : ''
}`}
>
{/* Status dot */}
<span
className="shrink-0 w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
{/* Title */}
<span className="flex-1 text-sm text-[var(--color-text-primary)] truncate">
{task.title}
</span>
{/* Category */}
{task.category && (
<span
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded"
style={{
backgroundColor: categoryColor + '15',
color: categoryColor,
}}
>
{task.category}
</span>
)}
{/* Arrow hint on selected */}
{index === selectedIndex && (
<ArrowRight size={12} className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
</button>
)
})}
</div>
) : query.trim() ? (
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-secondary)]">
No tasks found
</div>
) : null}
{/* Footer */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">&uarr;&darr;</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">&crarr;</kbd>
Open
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,108 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Plus, Square, CheckSquare } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { useCreateTask, useCompleteTask } from '../api/tasks.ts'
interface SubtaskListProps {
taskId: number
subtasks: WorkTask[]
}
export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
const [showInput, setShowInput] = useState(false)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const createTask = useCreateTask()
const completeTask = useCompleteTask()
useEffect(() => {
if (showInput) inputRef.current?.focus()
}, [showInput])
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && inputValue.trim()) {
createTask.mutate(
{ title: inputValue.trim(), parentTaskId: taskId },
{
onSuccess: () => {
setInputValue('')
},
}
)
}
if (e.key === 'Escape') {
setShowInput(false)
setInputValue('')
}
}
function handleToggle(subtask: WorkTask) {
if (subtask.status !== WorkTaskStatus.Completed) {
completeTask.mutate(subtask.id)
}
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Subtasks
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-1">
{subtasks.map((subtask) => {
const isCompleted = subtask.status === WorkTaskStatus.Completed
return (
<div
key={subtask.id}
className="flex items-center gap-2 py-1.5 px-1 rounded hover:bg-white/5 cursor-pointer group"
onClick={() => handleToggle(subtask)}
>
{isCompleted ? (
<CheckSquare size={16} className="text-[var(--color-status-completed)] flex-shrink-0" />
) : (
<Square size={16} className="text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] flex-shrink-0" />
)}
<span
className={`text-sm ${
isCompleted ? 'line-through text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)]'
}`}
>
{subtask.title}
</span>
</div>
)
})}
{showInput && (
<div className="flex items-center gap-2 py-1.5 px-1">
<Square size={16} className="text-[var(--color-text-secondary)] flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (!inputValue.trim()) {
setShowInput(false)
setInputValue('')
}
}}
placeholder="New subtask..."
className="flex-1 bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,118 +0,0 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Clock } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
function formatElapsed(task: WorkTask): string | null {
if (!task.startedAt) return null
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const mins = Math.floor((end - start) / 60_000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
const remainder = mins % 60
if (hours < 24) return `${hours}h ${remainder}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
interface TaskCardProps {
task: WorkTask
onClick: (id: number) => void
}
export default function TaskCard({ task, onClick }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const isActive = task.status === WorkTaskStatus.Active
const elapsed = formatElapsed(task)
const completedSubTasks = task.subTasks?.filter(
(s) => s.status === WorkTaskStatus.Completed
).length ?? 0
const totalSubTasks = task.subTasks?.length ?? 0
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={() => onClick(task.id)}
className={`
card-glow rounded-xl cursor-grab active:cursor-grabbing
bg-[var(--color-surface)] border transition-all duration-200
hover:-translate-y-0.5
${isActive
? 'border-[var(--color-status-active)]/30 animate-pulse-glow'
: 'border-[var(--color-border)] hover:border-[var(--color-border-hover)]'
}
${isDragging ? 'shadow-xl shadow-black/40' : ''}
`}
>
<div className="px-3.5 py-3">
{/* Title row */}
<div className="flex items-start gap-2 mb-1.5">
{isActive && (
<span className="shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full bg-[var(--color-status-active)] animate-live-dot" />
)}
<p className="text-[13px] font-medium text-[var(--color-text-primary)] leading-snug flex-1">
{task.title}
</p>
</div>
{/* Meta row */}
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-secondary)]">
{task.category && (
<span className="flex items-center gap-1">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: categoryColor }}
/>
{task.category}
</span>
)}
{elapsed && (
<span className="flex items-center gap-1">
<Clock size={10} />
{elapsed}
</span>
)}
{totalSubTasks > 0 && (
<span className="ml-auto flex items-center gap-1">
{Array.from({ length: totalSubTasks }, (_, i) => (
<span
key={i}
className={`w-1 h-1 rounded-full ${
i < completedSubTasks
? 'bg-[var(--color-status-completed)]'
: 'bg-white/10'
}`}
/>
))}
<span className="ml-0.5">{completedSubTasks}/{totalSubTasks}</span>
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,441 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion } from 'framer-motion'
import { X, Loader2 } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import {
useTask,
useUpdateTask,
useStartTask,
usePauseTask,
useResumeTask,
useCompleteTask,
useAbandonTask,
} from '../api/tasks.ts'
import { COLUMN_CONFIG } from '../lib/constants.ts'
import SubtaskList from './SubtaskList.tsx'
import NotesList from './NotesList.tsx'
interface TaskDetailPanelProps {
taskId: number
onClose: () => void
}
function formatElapsed(startedAt: string, completedAt: string | null): string {
const start = new Date(startedAt).getTime()
const end = completedAt ? new Date(completedAt).getTime() : Date.now()
const mins = Math.floor((end - start) / 60_000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
const remainder = mins % 60
if (hours < 24) return `${hours}h ${remainder}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) {
const { data: task, isLoading } = useTask(taskId)
const updateTask = useUpdateTask()
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
const completeTask = useCompleteTask()
const abandonTask = useAbandonTask()
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
const [editingDesc, setEditingDesc] = useState(false)
const [descValue, setDescValue] = useState('')
const [editingCategory, setEditingCategory] = useState(false)
const [categoryValue, setCategoryValue] = useState('')
const [editingEstimate, setEditingEstimate] = useState(false)
const [estimateValue, setEstimateValue] = useState('')
const titleInputRef = useRef<HTMLInputElement>(null)
const descInputRef = useRef<HTMLTextAreaElement>(null)
const categoryInputRef = useRef<HTMLInputElement>(null)
const estimateInputRef = useRef<HTMLInputElement>(null)
// Escape key handler
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
// If editing, cancel editing first
if (editingTitle || editingDesc || editingCategory || editingEstimate) {
setEditingTitle(false)
setEditingDesc(false)
setEditingCategory(false)
setEditingEstimate(false)
return
}
onClose()
}
},
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
)
useEffect(() => {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [handleEscape])
// Focus inputs when entering edit mode
useEffect(() => {
if (editingTitle) titleInputRef.current?.focus()
}, [editingTitle])
useEffect(() => {
if (editingDesc) descInputRef.current?.focus()
}, [editingDesc])
useEffect(() => {
if (editingCategory) categoryInputRef.current?.focus()
}, [editingCategory])
useEffect(() => {
if (editingEstimate) estimateInputRef.current?.focus()
}, [editingEstimate])
// --- Save handlers ---
function saveTitle() {
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
updateTask.mutate({ id: taskId, title: titleValue.trim() })
}
setEditingTitle(false)
}
function saveDescription() {
if (task && descValue !== (task.description ?? '')) {
updateTask.mutate({ id: taskId, description: descValue })
}
setEditingDesc(false)
}
function saveCategory() {
if (task && categoryValue.trim() !== (task.category ?? '')) {
updateTask.mutate({ id: taskId, category: categoryValue.trim() || undefined })
}
setEditingCategory(false)
}
function saveEstimate() {
const val = estimateValue.trim() === '' ? undefined : parseInt(estimateValue, 10)
if (task) {
const newVal = val && !isNaN(val) ? val : undefined
if (newVal !== (task.estimatedMinutes ?? undefined)) {
updateTask.mutate({ id: taskId, estimatedMinutes: newVal })
}
}
setEditingEstimate(false)
}
// --- Status helpers ---
const statusConfig = COLUMN_CONFIG.find((c) => c.status === task?.status)
// Progress percent
let progressPercent: number | null = null
if (task?.estimatedMinutes && task.startedAt) {
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const elapsedMins = (end - start) / 60_000
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
}
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
{isLoading || !task ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
</div>
) : (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="p-5 pb-4">
{/* Title row with close button inline */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<h2
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
>
<X size={18} />
</button>
</div>
{/* Status badge + Category */}
<div className="flex items-center gap-2 mt-3">
{statusConfig && (
<span
className="text-[10px] px-2.5 py-1 rounded-full"
style={{
backgroundColor: statusConfig.color + '20',
color: statusConfig.color,
}}
>
{statusConfig.label}
</span>
)}
{editingCategory ? (
<input
ref={categoryInputRef}
type="text"
value={categoryValue}
onChange={(e) => setCategoryValue(e.target.value)}
onBlur={saveCategory}
onKeyDown={(e) => {
if (e.key === 'Enter') saveCategory()
if (e.key === 'Escape') setEditingCategory(false)
}}
placeholder="Category..."
className="bg-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
/>
) : (
<span
className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] transition-colors px-2.5 py-1 rounded-full bg-white/5"
onClick={() => {
setCategoryValue(task.category ?? '')
setEditingCategory(true)
}}
>
{task.category || 'Add category'}
</span>
)}
</div>
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Description */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-2">
Description
</h3>
{editingDesc ? (
<textarea
ref={descInputRef}
value={descValue}
onChange={(e) => setDescValue(e.target.value)}
onBlur={saveDescription}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditingDesc(false)
e.stopPropagation()
}
}}
rows={4}
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none resize-none"
placeholder="Add a description..."
/>
) : (
<p
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
task.description ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
}`}
onClick={() => {
setDescValue(task.description ?? '')
setEditingDesc(true)
}}
>
{task.description || 'Add a description...'}
</p>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Time */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
Time
</h3>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium">
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
</span>
</div>
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Estimate</span>
{editingEstimate ? (
<input
ref={estimateInputRef}
type="number"
value={estimateValue}
onChange={(e) => setEstimateValue(e.target.value)}
onBlur={saveEstimate}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEstimate()
if (e.key === 'Escape') setEditingEstimate(false)
}}
placeholder="min"
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<span
className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
setEditingEstimate(true)
}}
>
{task.estimatedMinutes ? `${task.estimatedMinutes}m` : '--'}
</span>
)}
</div>
</div>
{/* Progress bar */}
{progressPercent !== null && (
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
progressPercent >= 100
? 'bg-rose-500'
: 'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
}`}
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Subtasks */}
<div className="p-5">
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Notes */}
<div className="p-5">
<NotesList taskId={taskId} notes={task.notes ?? []} />
</div>
</div>
{/* Action buttons - fixed at bottom */}
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
<div className="border-t border-[var(--color-border)] p-5 space-y-2">
{task.status === WorkTaskStatus.Pending && (
<>
<button
onClick={() => startTask.mutate(taskId)}
disabled={startTask.isPending}
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Start
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
{task.status === WorkTaskStatus.Active && (
<>
<button
onClick={() => pauseTask.mutate({ id: taskId })}
disabled={pauseTask.isPending}
className="w-full py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Pause
</button>
<button
onClick={() => completeTask.mutate(taskId)}
disabled={completeTask.isPending}
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Complete
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
{task.status === WorkTaskStatus.Paused && (
<>
<button
onClick={() => resumeTask.mutate({ id: taskId })}
disabled={resumeTask.isPending}
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Resume
</button>
<button
onClick={() => completeTask.mutate(taskId)}
disabled={completeTask.isPending}
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Complete
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
</div>
)}
</>
)}
</motion.div>
</>
)
}

View File

@@ -1,140 +0,0 @@
import { useMemo, useState } from 'react'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface ActivityFeedProps {
minutes: number
taskId?: number
}
const PAGE_SIZE = 20
function resolveCategory(
appName: string,
mappings: { pattern: string; matchType: string; category: string }[],
): string {
for (const m of mappings) {
if (m.matchType === 'Exact' && m.pattern.toLowerCase() === appName.toLowerCase()) {
return m.category
}
if (m.matchType === 'Contains' && appName.toLowerCase().includes(m.pattern.toLowerCase())) {
return m.category
}
if (m.matchType === 'Regex') {
try {
if (new RegExp(m.pattern, 'i').test(appName)) return m.category
} catch {
// skip invalid regex
}
}
}
return 'Unknown'
}
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
// Show time for older events
const h = date.getHours()
const m = date.getMinutes()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
const mins = m.toString().padStart(2, '0')
return `${hour12}:${mins}${ampm}`
}
export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const sortedEvents = useMemo(() => {
if (!events) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
// Reverse chronological
return [...filtered].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
)
}, [events, taskId])
const visibleEvents = sortedEvents.slice(0, visibleCount)
const hasMore = visibleCount < sortedEvents.length
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
Loading activity...
</div>
)
}
if (sortedEvents.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
No activity events for this time range.
</div>
)
}
return (
<div>
<div className="relative">
{visibleEvents.map((evt, idx) => {
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
const detail = evt.url || evt.windowTitle || ''
const isLast = idx === visibleEvents.length - 1
return (
<div key={evt.id} className="flex items-start gap-3 relative">
{/* Timeline connector + dot */}
<div className="flex flex-col items-center shrink-0">
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="w-px flex-1 bg-[var(--color-border)]" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
{formatTimestamp(evt.timestamp)}
</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
</div>
{detail && (
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
)}
</div>
</div>
)
})}
</div>
{hasMore && (
<button
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
className="mt-3 w-full py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Load more ({sortedEvents.length - visibleCount} remaining)
</button>
)}
</div>
)
}

View File

@@ -1,143 +0,0 @@
import { useMemo } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import { useContextSummary } from '../../api/context'
import { CATEGORY_COLORS } from '../../lib/constants'
interface CategoryBreakdownProps {
minutes: number
taskId?: number
}
interface CategoryData {
name: string
count: number
color: string
percentage: number
}
export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }: CategoryBreakdownProps) {
const { data: summary, isLoading } = useContextSummary()
const categories = useMemo(() => {
if (!summary) return []
// Aggregate by category
const catMap = new Map<string, number>()
for (const item of summary) {
const cat = item.category || 'Unknown'
catMap.set(cat, (catMap.get(cat) ?? 0) + item.eventCount)
}
const total = Array.from(catMap.values()).reduce((s, c) => s + c, 0)
const result: CategoryData[] = Array.from(catMap.entries())
.map(([name, count]) => ({
name,
count,
color: CATEGORY_COLORS[name] ?? CATEGORY_COLORS['Unknown'],
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
return result
}, [summary])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading category breakdown...
</div>
)
}
if (categories.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No category data available.
</div>
)
}
const totalEvents = categories.reduce((s, c) => s + c.count, 0)
return (
<div className="flex gap-8 items-start">
{/* Left: Donut chart */}
<div className="w-56 h-56 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categories}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="80%"
paddingAngle={2}
stroke="none"
>
{categories.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as CategoryData
return (
<div
style={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
{d.count} events ({d.percentage}%)
</div>
</div>
)
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Right: Legend list */}
<div className="flex-1 space-y-3 pt-2">
{categories.map((cat) => (
<div key={cat.name} className="flex items-center gap-3">
{/* Colored dot */}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
{/* Name + bar + stats */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
{cat.count} ({cat.percentage}%)
</span>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${totalEvents > 0 ? (cat.count / totalEvents) * 100 : 0}%`,
backgroundColor: cat.color,
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,208 +0,0 @@
import { useMemo } from 'react'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface TimelineProps {
minutes: number
taskId?: number
}
interface BucketData {
label: string
count: number
category: string
color: string
appName: string
timeRange: string
}
function resolveCategory(
appName: string,
mappings: { pattern: string; matchType: string; category: string }[],
): string {
for (const m of mappings) {
if (m.matchType === 'Exact' && m.pattern.toLowerCase() === appName.toLowerCase()) {
return m.category
}
if (m.matchType === 'Contains' && appName.toLowerCase().includes(m.pattern.toLowerCase())) {
return m.category
}
if (m.matchType === 'Regex') {
try {
if (new RegExp(m.pattern, 'i').test(appName)) return m.category
} catch {
// skip invalid regex
}
}
}
return 'Unknown'
}
function formatHour(date: Date): string {
const h = date.getHours()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
return `${hour12}${ampm}`
}
function formatTimeRange(date: Date): string {
const start = formatHour(date)
const next = new Date(date)
next.setHours(next.getHours() + 1)
const end = formatHour(next)
return `${start} - ${end}`
}
export default function Timeline({ minutes, taskId }: TimelineProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const buckets = useMemo(() => {
if (!events || !mappings) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
if (filtered.length === 0) return []
// Group by hour bucket
const hourMap = new Map<
string,
{ date: Date; apps: Map<string, { count: number; category: string }> }
>()
for (const evt of filtered) {
const ts = new Date(evt.timestamp)
const bucketDate = new Date(ts)
bucketDate.setMinutes(0, 0, 0)
const key = bucketDate.toISOString()
if (!hourMap.has(key)) {
hourMap.set(key, { date: bucketDate, apps: new Map() })
}
const bucket = hourMap.get(key)!
const category = resolveCategory(evt.appName, mappings)
const appKey = `${evt.appName}|${category}`
if (!bucket.apps.has(appKey)) {
bucket.apps.set(appKey, { count: 0, category })
}
bucket.apps.get(appKey)!.count++
}
// Sort by time and determine dominant app per bucket
const sorted = Array.from(hourMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, { date, apps }]): BucketData => {
let dominantApp = ''
let dominantCategory = 'Unknown'
let maxCount = 0
for (const [key, { count, category }] of apps) {
if (count > maxCount) {
maxCount = count
dominantCategory = category
dominantApp = key.split('|')[0]
}
}
const totalCount = Array.from(apps.values()).reduce((s, a) => s + a.count, 0)
return {
label: formatHour(date),
count: totalCount,
category: dominantCategory,
color: CATEGORY_COLORS[dominantCategory] ?? CATEGORY_COLORS['Unknown'],
appName: dominantApp,
timeRange: formatTimeRange(date),
}
})
return sorted
}, [events, mappings, taskId])
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading timeline...
</div>
)
}
if (buckets.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No activity data for this time range.
</div>
)
}
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<XAxis
dataKey="label"
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={{ stroke: 'var(--color-border)' }}
tickLine={false}
/>
<YAxis
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
labelStyle={{ display: 'none' }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as BucketData
return (
<div
style={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
<div className="text-xs mt-0.5" style={{ color: d.color }}>
{d.count} events
</div>
</div>
)
}}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
{buckets.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -1,100 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--color-page: #0a0a0f;
--color-surface: #12131a;
--color-elevated: #1a1b26;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-hover: rgba(255, 255, 255, 0.12);
--color-text-primary: #e2e8f0;
--color-text-secondary: #64748b;
--color-text-tertiary: #334155;
--color-accent: #8b5cf6;
--color-accent-end: #6366f1;
--color-status-active: #3b82f6;
--color-status-paused: #eab308;
--color-status-completed: #22c55e;
--color-status-pending: #64748b;
}
/* Noise grain texture */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* Active task pulse */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 16px rgba(59, 130, 246, 0.5);
}
}
.animate-pulse-glow {
animation: pulse-glow 2.5s ease-in-out infinite;
}
/* Live dot pulse */
@keyframes live-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.animate-live-dot {
animation: live-dot 1.5s ease-in-out infinite;
}
/* Card hover glow border */
.card-glow {
position: relative;
}
.card-glow::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.1), transparent);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
pointer-events: none;
}
.card-glow:hover::before {
opacity: 1;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #1a1b26;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #2a2d37;
}
/* Selection color */
::selection {
background: rgba(139, 92, 246, 0.3);
}

View File

@@ -1,21 +0,0 @@
export const COLUMN_CONFIG = [
{ status: 'Pending' as const, label: 'Pending', color: '#64748b' },
{ status: 'Active' as const, label: 'Active', color: '#3b82f6' },
{ status: 'Paused' as const, label: 'Paused', color: '#eab308' },
{ status: 'Completed' as const, label: 'Completed', color: '#22c55e' },
] as const
export const CATEGORY_COLORS: Record<string, string> = {
Development: '#6366f1',
Research: '#06b6d4',
Communication: '#8b5cf6',
DevOps: '#f97316',
Documentation: '#14b8a6',
Design: '#ec4899',
Testing: '#3b82f6',
General: '#64748b',
Email: '#f59e0b',
Engineering: '#6366f1',
LaserCutting: '#ef4444',
Unknown: '#475569',
}

View File

@@ -1,17 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -1,122 +0,0 @@
import { useState } from 'react'
import { useTasks } from '../api/tasks'
import Timeline from '../components/analytics/Timeline'
import CategoryBreakdown from '../components/analytics/CategoryBreakdown'
import ActivityFeed from '../components/analytics/ActivityFeed'
const TIME_RANGES = [
{ label: 'Today', minutes: 1440 },
{ label: '7 days', minutes: 10080 },
{ label: '30 days', minutes: 43200 },
] as const
export default function Analytics() {
const [minutes, setMinutes] = useState<number>(1440)
const [taskId, setTaskId] = useState<number | undefined>(undefined)
const { data: tasks } = useTasks()
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Header + Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
<div className="flex items-center gap-3">
{/* Time range dropdown */}
<select
value={minutes}
onChange={(e) => setMinutes(Number(e.target.value))}
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
>
{TIME_RANGES.map((r) => (
<option key={r.minutes} value={r.minutes}>
{r.label}
</option>
))}
</select>
{/* Task filter dropdown */}
<select
value={taskId ?? ''}
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
>
<option value="">All Tasks</option>
{tasks?.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const totalMins = tasks?.reduce((acc, t) => {
if (!t.startedAt) return acc
const start = new Date(t.startedAt).getTime()
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
return acc + (end - start) / 60000
}, 0) ?? 0
const hours = Math.floor(totalMins / 60)
const mins = Math.floor(totalMins % 60)
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
})()}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const counts: Record<string, number> = {}
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
return top ? top[0] : '\u2014'
})()}
</p>
</div>
</div>
{/* Timeline */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Activity Timeline
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<Timeline minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Category Breakdown */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Category Breakdown
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<CategoryBreakdown minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Recent Activity
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<ActivityFeed minutes={minutes} taskId={taskId} />
</div>
</section>
</div>
)
}

View File

@@ -1,49 +0,0 @@
import { useState, useEffect, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTasks } from '../api/tasks.ts'
import KanbanBoard from '../components/KanbanBoard.tsx'
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
import FilterBar, { applyFilters, EMPTY_FILTERS } from '../components/FilterBar.tsx'
import type { Filters } from '../components/FilterBar.tsx'
export default function Board() {
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
const { data: tasks, isLoading } = useTasks()
// Read ?task= search param on mount or when it changes
useEffect(() => {
const taskParam = searchParams.get('task')
if (taskParam) {
const id = Number(taskParam)
if (!isNaN(id)) {
setSelectedTaskId(id)
}
// Clean up the search param
setSearchParams({}, { replace: true })
}
}, [searchParams, setSearchParams])
// Apply filters to tasks
const filteredTasks = useMemo(() => {
if (!tasks) return []
return applyFilters(tasks, filters)
}, [tasks, filters])
return (
<div className="h-full flex flex-col">
<FilterBar tasks={tasks ?? []} filters={filters} onFiltersChange={setFilters} />
<div className="flex-1 min-h-0">
<KanbanBoard
tasks={filteredTasks}
isLoading={isLoading}
onTaskClick={(id) => setSelectedTaskId(id)}
/>
</div>
{selectedTaskId !== null && (
<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />
)}
</div>
)
}

View File

@@ -1,289 +0,0 @@
import { useState } from 'react'
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
import { CATEGORY_COLORS } from '../lib/constants'
import type { AppMapping } from '../types'
const MATCH_TYPES = ['ProcessName', 'TitleContains', 'UrlContains'] as const
const MATCH_TYPE_COLORS: Record<string, string> = {
ProcessName: '#6366f1',
TitleContains: '#06b6d4',
UrlContains: '#f97316',
}
interface FormData {
pattern: string
matchType: string
category: string
friendlyName: string
}
const emptyForm: FormData = { pattern: '', matchType: 'ProcessName', category: '', friendlyName: '' }
function formFromMapping(m: AppMapping): FormData {
return {
pattern: m.pattern,
matchType: m.matchType,
category: m.category,
friendlyName: m.friendlyName ?? '',
}
}
export default function Mappings() {
const { data: mappings, isLoading } = useMappings()
const createMapping = useCreateMapping()
const updateMapping = useUpdateMapping()
const deleteMapping = useDeleteMapping()
const [addingNew, setAddingNew] = useState(false)
const [newForm, setNewForm] = useState<FormData>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<FormData>(emptyForm)
function handleAddSave() {
if (!newForm.pattern.trim() || !newForm.category.trim()) return
createMapping.mutate(
{
pattern: newForm.pattern.trim(),
matchType: newForm.matchType,
category: newForm.category.trim(),
friendlyName: newForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setAddingNew(false)
setNewForm(emptyForm)
},
},
)
}
function handleAddCancel() {
setAddingNew(false)
setNewForm(emptyForm)
}
function handleEditStart(mapping: AppMapping) {
setEditingId(mapping.id)
setEditForm(formFromMapping(mapping))
// Cancel any add-new row when starting an edit
setAddingNew(false)
}
function handleEditSave() {
if (editingId === null) return
if (!editForm.pattern.trim() || !editForm.category.trim()) return
updateMapping.mutate(
{
id: editingId,
pattern: editForm.pattern.trim(),
matchType: editForm.matchType,
category: editForm.category.trim(),
friendlyName: editForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setEditingId(null)
setEditForm(emptyForm)
},
},
)
}
function handleEditCancel() {
setEditingId(null)
setEditForm(emptyForm)
}
function handleDelete(id: number) {
if (!window.confirm('Delete this mapping rule?')) return
deleteMapping.mutate(id)
}
const inputClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
const selectClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
return (
<tr className="bg-white/[0.04]">
<td className="px-4 py-3">
<input
type="text"
placeholder="Pattern..."
value={form.pattern}
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
className={inputClass}
autoFocus
/>
</td>
<td className="px-4 py-3">
<select
value={form.matchType}
onChange={(e) => setForm({ ...form, matchType: e.target.value })}
className={selectClass}
>
{MATCH_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Category..."
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Friendly name (optional)"
value={form.friendlyName}
onChange={(e) => setForm({ ...form, friendlyName: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={onSave}
disabled={isSaving}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-400/10 transition-colors disabled:opacity-50"
title="Save"
>
<Check size={16} />
</button>
<button
onClick={onCancel}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
title="Cancel"
>
<X size={16} />
</button>
</div>
</td>
</tr>
)
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
<button
onClick={() => {
setAddingNew(true)
setEditingId(null)
setNewForm(emptyForm)
}}
disabled={addingNew}
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
>
<Plus size={16} />
Add Rule
</button>
</div>
{/* Table */}
{isLoading ? (
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
) : !mappings?.length && !addingNew ? (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
<button
onClick={() => {
setAddingNew(true)
setNewForm(emptyForm)
}}
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
>
+ Add your first mapping rule
</button>
</div>
) : (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{/* Add-new row */}
{addingNew &&
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
{/* Data rows */}
{mappings?.map((m) =>
editingId === m.id ? (
renderFormRow(editForm, setEditForm, handleEditSave, handleEditCancel, updateMapping.isPending)
) : (
<tr
key={m.id}
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
<td className="px-4 py-3">
<span
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${MATCH_TYPE_COLORS[m.matchType] ?? '#64748b'}20`,
color: MATCH_TYPE_COLORS[m.matchType] ?? '#64748b',
}}
>
{m.matchType}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
/>
{m.category}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{m.friendlyName ?? '\u2014'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => handleEditStart(m)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -1,71 +0,0 @@
export const WorkTaskStatus = {
Pending: 'Pending',
Active: 'Active',
Paused: 'Paused',
Completed: 'Completed',
Abandoned: 'Abandoned',
} as const
export type WorkTaskStatus = (typeof WorkTaskStatus)[keyof typeof WorkTaskStatus]
export const NoteType = {
PauseNote: 'PauseNote',
ResumeNote: 'ResumeNote',
General: 'General',
} as const
export type NoteType = (typeof NoteType)[keyof typeof NoteType]
export interface WorkTask {
id: number
title: string
description: string | null
status: WorkTaskStatus
category: string | null
createdAt: string
startedAt: string | null
completedAt: string | null
estimatedMinutes: number | null
parentTaskId: number | null
subTasks: WorkTask[]
notes: TaskNote[]
contextEvents: ContextEvent[]
}
export interface TaskNote {
id: number
workTaskId: number
content: string
type: NoteType
createdAt: string
}
export interface ContextEvent {
id: number
workTaskId: number | null
source: string
appName: string
windowTitle: string
url: string | null
timestamp: string
}
export interface AppMapping {
id: number
pattern: string
matchType: string
category: string
friendlyName: string | null
}
export interface ContextSummaryItem {
appName: string
category: string
eventCount: number
firstSeen: string
lastSeen: string
}
export interface ApiResponse<T> {
success: boolean
data: T
error: string | null
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5200',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,160 @@
# TaskTracker Web UI: React to Razor Pages Migration
## Date: 2026-03-01
## Motivation
- **Single tech stack:** Eliminate the npm/Node toolchain; everything in C#/.NET
- **Single-binary deployment:** UI bundled into the API project — one process, one port, no separate build step
## Approach: Razor Pages + htmx
Server-rendered HTML with Razor syntax. htmx handles partial page updates via AJAX. Vanilla JS for drag-and-drop (SortableJS), charts (Chart.js), and keyboard shortcuts.
## Architecture
### Single Process
Razor Pages are added directly to the existing `TaskTracker.Api` project. No separate web project.
- One `Program.cs`, one binary, one port
- Razor Pages call repositories directly (same DI container) — no API round-trips for the UI
- API controllers remain at `/api/*` for external consumers (MCP, WindowWatcher, Chrome extension)
- `wwwroot/` hosts static JS/CSS files
### File Structure
```
TaskTracker.Api/
├── Pages/
│ ├── _ViewImports.cshtml
│ ├── _ViewStart.cshtml
│ ├── Shared/
│ │ └── _Layout.cshtml (shell: nav bar, search modal, script tags)
│ ├── Board.cshtml + Board.cshtml.cs
│ ├── Analytics.cshtml + Analytics.cshtml.cs
│ └── Mappings.cshtml + Mappings.cshtml.cs
├── Pages/Partials/
│ ├── _KanbanColumn.cshtml (single column, htmx swappable)
│ ├── _TaskCard.cshtml (single card)
│ ├── _TaskDetail.cshtml (slide-in detail panel)
│ ├── _FilterBar.cshtml (category chip bar)
│ ├── _SubtaskList.cshtml
│ ├── _NotesList.cshtml
│ ├── _CreateTaskForm.cshtml
│ ├── _MappingRow.cshtml (single table row, inline edit)
│ └── _SearchResults.cshtml (command palette results)
├── wwwroot/
│ ├── css/site.css (dark theme, design tokens, animations)
│ ├── lib/htmx.min.js (~14KB gzipped)
│ ├── lib/Sortable.min.js (~40KB)
│ ├── lib/chart.min.js (~65KB gzipped)
│ └── js/app.js (command palette, keyboard shortcuts, DnD wiring)
└── Program.cs (adds AddRazorPages + MapRazorPages)
```
### Program.cs Changes
```csharp
builder.Services.AddRazorPages();
// ... existing setup ...
app.MapRazorPages(); // Add alongside MapControllers()
app.MapControllers(); // Keep for API consumers
```
## Client-Side Libraries (all vendored, no npm)
| Library | Size (gzipped) | Purpose |
|---------|---------------|---------|
| htmx | ~14KB | Partial page updates via AJAX |
| SortableJS | ~40KB | Drag-and-drop Kanban columns |
| Chart.js | ~65KB | Timeline bar chart, category donut |
| app.js | ~100 lines | Command palette, keyboard shortcuts, DnD wiring |
## Styling
Plain CSS (no Tailwind, no build step). Same dark design system ported from the React app:
- CSS custom properties for all design tokens (`--color-page`, `--color-surface`, `--color-accent`, etc.)
- Same animations: `animate-pulse-glow`, `animate-live-dot`, `card-glow`
- Custom scrollbars, selection highlight, grain texture
## Page-by-Page Design
### Board Page
- 4 Kanban columns rendered server-side as partials
- **Drag-and-drop:** SortableJS on each column; `onEnd` callback reads task ID + target column status, fires htmx request:
- Pending → Active: `PUT /board/tasks/{id}/start`
- Active → Paused: `PUT /board/tasks/{id}/pause`
- Paused → Active: `PUT /board/tasks/{id}/resume`
- Any → Completed: `PUT /board/tasks/{id}/complete`
- Server returns updated column partials
- **Task detail panel:** `hx-get="/board/tasks/{id}/detail"` loads partial into right-side container; CSS transition handles slide-in
- **Inline editing:** Click-to-edit fields use `hx-put` on blur/Enter
- **Subtasks/notes:** Rendered as partials inside detail panel; htmx handles add/complete
- **Filter bar:** htmx re-requests board with query params (`?category=Development&hasSubtasks=true`)
- **Create task:** Inline form in Pending column, htmx POST
### Analytics Page
- Stat cards rendered server-side (open tasks count, active time, top category)
- **Charts:** Chart.js renders from JSON data serialized into `<script>` tags by the server
- Timeline: bar chart with events bucketed by hour, colored by category
- Category breakdown: donut chart with legend
- **Activity feed:** Initial batch server-rendered; "Load more" button uses htmx to append items
### Mappings Page
- Table rows rendered server-side
- **Inline edit:** Click "Edit" → htmx swaps row with editable form row partial
- **Add new:** htmx inserts empty form row at top
- **Delete:** `hx-delete` with `hx-confirm` for confirmation
### Search Modal (Ctrl+K)
- `app.js` handles keyboard shortcut and modal open/close
- On keystroke: htmx fetches `/board/search?q={query}`, swaps results list
- Arrow key navigation + Enter-to-select in JS (~80 lines)
- When empty: shows recent Active/Paused/Pending tasks
## htmx Endpoint Design
Razor Pages handler methods return HTML partials (not JSON). Examples:
| Endpoint | Method | Returns |
|----------|--------|---------|
| `/board` | GET | Full board page |
| `/board?handler=Column&status=Active` | GET | Single column partial |
| `/board?handler=TaskDetail&id=5` | GET | Detail panel partial |
| `/board?handler=Search&q=auth` | GET | Search results partial |
| `/board?handler=Start&id=5` | PUT | Updated board columns |
| `/board?handler=Pause&id=5` | PUT | Updated board columns |
| `/board?handler=Resume&id=5` | PUT | Updated board columns |
| `/board?handler=Complete&id=5` | PUT | Updated board columns |
| `/board?handler=CreateTask` | POST | Updated Pending column |
| `/board?handler=UpdateTask&id=5` | PUT | Updated task card |
| `/board?handler=AddSubtask&id=5` | POST | Updated subtask list |
| `/board?handler=AddNote&id=5` | POST | Updated notes list |
| `/analytics` | GET | Full analytics page |
| `/analytics?handler=ActivityFeed&offset=20` | GET | More activity items |
| `/mappings` | GET | Full mappings page |
| `/mappings?handler=EditRow&id=3` | GET | Editable row partial |
| `/mappings?handler=Save` | POST/PUT | Updated row partial |
| `/mappings?handler=Delete&id=3` | DELETE | Empty (row removed) |
## What Gets Removed
- `TaskTracker.Web/` directory (React app: node_modules, package.json, src/, dist/, etc.)
- `UseDefaultFiles()` in Program.cs (Razor Pages handle routing)
- CORS configuration becomes optional (UI is same-origin)
## What Stays Unchanged
- All API controllers (`/api/*`) — used by MCP, WindowWatcher, Chrome extension
- `TaskTracker.Core` — entities, DTOs, interfaces
- `TaskTracker.Infrastructure` — EF Core, repositories, migrations
- `TaskTracker.MCP` — MCP server
- `WindowWatcher` — context watcher service

View File

@@ -0,0 +1,720 @@
# Razor Pages Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the React/npm web UI with Razor Pages + htmx served from the existing TaskTracker.Api project, eliminating the Node toolchain entirely.
**Architecture:** Razor Pages added to the existing TaskTracker.Api project. Pages call repositories directly via DI (no API round-trip). htmx handles partial updates, SortableJS handles drag-and-drop, Chart.js handles analytics charts. All JS vendored as static files in wwwroot — zero npm.
**Tech Stack:** ASP.NET Razor Pages, htmx 2.0, SortableJS, Chart.js 4, vanilla JS
**Reference files:**
- Design doc: `docs/plans/2026-03-01-razor-pages-migration-design.md`
- Current React source: `TaskTracker.Web/src/` (reference for feature parity)
- Current CSS/tokens: `TaskTracker.Web/src/index.css`
- API controllers: `TaskTracker.Api/Controllers/` (keep unchanged)
- Entities: `TaskTracker.Core/Entities/` (WorkTask, TaskNote, ContextEvent, AppMapping)
- Repositories: `TaskTracker.Core/Interfaces/` (ITaskRepository, IContextEventRepository, IAppMappingRepository)
- Enums: `TaskTracker.Core/Enums/` (WorkTaskStatus, NoteType)
---
### Task 1: Project Setup — Add Razor Pages to TaskTracker.Api
**Files:**
- Modify: `TaskTracker.Api/Program.cs`
- Create: `TaskTracker.Api/Pages/_ViewImports.cshtml`
- Create: `TaskTracker.Api/Pages/_ViewStart.cshtml`
- Create: `TaskTracker.Api/Pages/Shared/_Layout.cshtml`
**Step 1: Update Program.cs to register Razor Pages**
Add `builder.Services.AddRazorPages()` after the existing service registrations. Add `app.MapRazorPages()` before `app.MapControllers()`. Remove `app.UseDefaultFiles()` (Razor Pages handle routing now). Keep `app.UseStaticFiles()` for wwwroot.
```csharp
// In Program.cs, after builder.Services.AddCors(...)
builder.Services.AddRazorPages();
// After app.UseCors()
app.UseStaticFiles();
app.MapRazorPages();
app.MapControllers();
// Remove: app.UseDefaultFiles();
```
**Step 2: Create _ViewImports.cshtml**
```html
@using TaskTracker.Core.Entities
@using TaskTracker.Core.Enums
@using TaskTracker.Core.DTOs
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
```
**Step 3: Create _ViewStart.cshtml**
```html
@{
Layout = "_Layout";
}
```
**Step 4: Create _Layout.cshtml — the app shell**
This is the shared layout with navigation bar, search modal placeholder, and script tags. Port the exact nav structure from `TaskTracker.Web/src/components/Layout.tsx`. Include the three vendored JS libraries and `app.js`.
The layout should have:
- `<header>` with logo, nav links (Board, Analytics, Mappings), search button (Ctrl+K hint), and "New Task" button
- `<main>` with `@RenderBody()`
- `<div id="search-modal">` empty container for the search modal
- `<div id="detail-panel">` empty container for the task detail slide-in
- Script tags for htmx, Sortable, Chart.js, and app.js
Nav links use `<a>` tags with `asp-page` tag helpers. Active state uses a CSS class toggled by checking `ViewContext.RouteData`.
**Step 5: Build and verify the app starts**
Run: `dotnet build TaskTracker.Api`
Expected: Build succeeds with no errors.
**Step 6: Commit**
```
feat(web): add Razor Pages scaffolding to API project
```
---
### Task 2: Static Assets — CSS and Vendored JS
**Files:**
- Create: `TaskTracker.Api/wwwroot/css/site.css`
- Create: `TaskTracker.Api/wwwroot/js/app.js` (empty placeholder)
- Download: `TaskTracker.Api/wwwroot/lib/htmx.min.js`
- Download: `TaskTracker.Api/wwwroot/lib/Sortable.min.js`
- Download: `TaskTracker.Api/wwwroot/lib/chart.umd.min.js`
**Step 1: Create site.css**
Port the design tokens and animations from `TaskTracker.Web/src/index.css`. Convert Tailwind utility patterns used across all React components into reusable CSS classes. Key sections:
- CSS custom properties (`:root` block with all `--color-*` tokens)
- Reset / base styles (dark background, font, box-sizing)
- Animations (`pulse-glow`, `live-dot`, `card-glow`)
- Scrollbar styles
- Selection color
- Noise grain texture overlay
- Layout utilities (`.flex`, `.grid`, `.flex-col`, `.items-center`, `.gap-*`, etc. — only the ones actually used)
- Component classes: `.nav-link`, `.nav-link--active`, `.btn`, `.btn--primary`, `.btn--danger`, `.btn--amber`, `.btn--emerald`, `.stat-card`, `.badge`, `.input`, `.select`, etc.
- Kanban-specific: `.kanban-grid`, `.kanban-column`, `.task-card`, `.task-card--active`
- Detail panel: `.detail-overlay`, `.detail-panel`, slide-in transition classes
- Table styles for Mappings page
- Responsive: the app is desktop-first, minimal responsive needed
Reference: Read every React component's className strings to ensure complete coverage. The CSS file will be ~400-600 lines.
**Step 2: Download vendored JS libraries**
Use curl to download from CDNs:
- htmx 2.0: `https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js`
- SortableJS: `https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js`
- Chart.js 4: `https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js`
**Step 3: Create empty app.js placeholder**
```javascript
// TaskTracker app.js — command palette, keyboard shortcuts, drag-and-drop wiring
// Will be populated in later tasks
```
**Step 4: Verify static files serve**
Run the app, navigate to `/css/site.css`, `/lib/htmx.min.js` — verify 200 responses.
**Step 5: Commit**
```
feat(web): add CSS design system and vendored JS libraries
```
---
### Task 3: Board Page — Kanban Columns (Server-Rendered)
**Files:**
- Create: `TaskTracker.Api/Pages/Board.cshtml`
- Create: `TaskTracker.Api/Pages/Board.cshtml.cs`
- Create: `TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_TaskCard.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_FilterBar.cshtml`
**Step 1: Create Board.cshtml.cs (PageModel)**
The PageModel should:
- Inject `ITaskRepository`
- `OnGetAsync()`: Load all tasks with subtasks (`GetAllAsync(includeSubTasks: true)`), filter to top-level only (`ParentTaskId == null`), group by status into 4 column view models. Accept optional `category` and `hasSubtasks` query params for filtering.
- `OnGetColumnAsync(WorkTaskStatus status)`: Return a single column partial (for htmx swap after drag-and-drop).
- `OnPostCreateTaskAsync(string title, string? category)`: Create a task, return updated Pending column partial.
- `OnPutStartAsync(int id)`: Start task (pause current active), return updated board columns.
- `OnPutPauseAsync(int id)`: Pause task, return updated board columns.
- `OnPutResumeAsync(int id)`: Resume task (pause current active), return updated board columns.
- `OnPutCompleteAsync(int id)`: Complete task, return updated board columns.
- `OnDeleteAbandonAsync(int id)`: Abandon (delete) task, return updated board columns.
For htmx handlers, detect `Request.Headers["HX-Request"]` and return `Partial("Partials/_KanbanColumn", columnModel)` instead of the full page.
Define a `ColumnViewModel` record: `record ColumnViewModel(WorkTaskStatus Status, string Label, string Color, List<WorkTask> Tasks)`.
Use the same column config as the React app:
```csharp
static readonly ColumnViewModel[] Columns = [
new(WorkTaskStatus.Pending, "Pending", "#64748b", []),
new(WorkTaskStatus.Active, "Active", "#3b82f6", []),
new(WorkTaskStatus.Paused, "Paused", "#eab308", []),
new(WorkTaskStatus.Completed, "Completed", "#22c55e", []),
];
```
Category colors dictionary — same as `CATEGORY_COLORS` in `constants.ts`:
```csharp
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",
};
```
**Step 2: Create Board.cshtml**
Renders the filter bar partial and a 4-column grid. Each column rendered via `_KanbanColumn` partial.
```html
@page
@model TaskTracker.Api.Pages.BoardModel
<div 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>
```
**Step 3: Create _KanbanColumn.cshtml**
Each column has:
- Column header with status label, colored dot, and task count
- `id="column-{Status}"` for htmx targeting and SortableJS group
- `data-status="{Status}"` for JS to read on drag-and-drop
- List of `_TaskCard` partials
- If Pending column: include `_CreateTaskForm` partial at the bottom
**Step 4: Create _TaskCard.cshtml**
Each card has:
- `id="task-{Id}"` and `data-task-id="{Id}"` for SortableJS
- Card glow class, active pulse class if status == Active
- Live dot indicator if Active
- Title, category dot, elapsed time
- Subtask progress dots (green = completed, dim = incomplete) + count
- `hx-get="/board?handler=TaskDetail&id={Id}"` `hx-target="#detail-panel"` on click
Reference: `TaskTracker.Web/src/components/TaskCard.tsx` for exact structure.
Elapsed time formatting — port `formatElapsed` to a C# helper method on the PageModel or a static helper:
```csharp
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";
}
```
**Step 5: Create _FilterBar.cshtml**
Category filter chips. Each chip is an `<a>` with htmx attributes:
- `hx-get="/board?category={cat}"` `hx-target="#kanban-board"` `hx-swap="innerHTML"`
- Active chip styled with category color background
- "All" chip to clear filter
**Step 6: Create _CreateTaskForm.cshtml**
Inline form at bottom of Pending column:
- Text input for title
- htmx POST: `hx-post="/board?handler=CreateTask"` `hx-target="#column-Pending"` `hx-swap="outerHTML"`
- Form submits on Enter
**Step 7: Build and manually test**
Run: `dotnet run --project TaskTracker.Api`
Navigate to `/board`. Verify 4 columns render with tasks from the database.
**Step 8: Commit**
```
feat(web): add Board page with Kanban columns and task cards
```
---
### Task 4: Board Page — Task Detail Panel
**Files:**
- Create: `TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_NotesList.cshtml`
- Modify: `TaskTracker.Api/Pages/Board.cshtml.cs` (add handler methods)
**Step 1: Add handler methods to Board.cshtml.cs**
- `OnGetTaskDetailAsync(int id)`: Load task by ID (with subtasks, notes), return `_TaskDetail` partial.
- `OnPutUpdateTaskAsync(int id, string? title, string? description, string? category, int? estimatedMinutes)`: Update task fields, return updated `_TaskDetail` partial.
- `OnPostAddSubtaskAsync(int id, string title)`: Create subtask with `parentTaskId = id`, return updated `_SubtaskList` partial.
- `OnPutCompleteSubtaskAsync(int id)`: Complete a subtask, return updated `_SubtaskList` partial.
- `OnPostAddNoteAsync(int id, string content)`: Add a General note, return updated `_NotesList` partial.
- `OnGetSearchAsync(string q)`: Search tasks by title/description/category (case-insensitive contains), return `_SearchResults` partial.
**Step 2: Create _TaskDetail.cshtml**
Port the structure from `TaskTracker.Web/src/components/TaskDetailPanel.tsx`:
- Close button (`onclick` calls JS to hide the panel)
- Title: displayed as text, with `hx-get` to swap in an edit form on click (or use JS `contenteditable` with htmx `hx-put` on blur)
- Status badge (colored pill)
- Category: click-to-edit (same pattern as title)
- Description section: click-to-edit textarea
- Time section: elapsed vs estimate, progress bar
- Estimate: click-to-edit number input
- Subtask list partial
- Notes list partial
- Action buttons at bottom (status-dependent: Start/Pause/Resume/Complete/Abandon)
Inline editing approach: Each editable field has two states (display and edit). Use htmx `hx-get` to swap the display element with an edit form, and `hx-put` on the form to save and swap back to display. Or use a small JS helper that toggles visibility and fires htmx on blur.
Action buttons use htmx:
```html
<button hx-put="/board?handler=Start&id=@task.Id"
hx-target="#kanban-board" hx-swap="innerHTML"
class="btn btn--primary">Start</button>
```
After a status-change action, the board columns should refresh AND the detail panel should update. Use `hx-swap-oob` (out-of-band swap) to update both targets in one response, or have the JS close the panel after the action completes.
**Step 3: Create _SubtaskList.cshtml**
- List of subtasks with checkbox icons
- Completed subtasks show line-through
- Click non-completed → htmx PUT to complete, swap subtask list
- Inline input to add new subtask → htmx POST
**Step 4: Create _NotesList.cshtml**
- Notes sorted chronologically
- Type badge (Pause=amber, Resume=blue, General=subtle)
- Relative timestamps (port the JS `formatRelativeTime` logic to C#)
- Inline input to add new note → htmx POST
**Step 5: Build and manually test**
Click a task card → detail panel should slide in. Test inline editing, subtask creation, note creation, and action buttons.
**Step 6: Commit**
```
feat(web): add task detail panel with inline editing, subtasks, and notes
```
---
### Task 5: Board Page — Drag-and-Drop with SortableJS
**Files:**
- Modify: `TaskTracker.Api/wwwroot/js/app.js`
**Step 1: Implement SortableJS wiring in app.js**
```javascript
function initKanban() {
document.querySelectorAll('.kanban-column-body').forEach(el => {
new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'task-card--ghost',
dragClass: 'task-card--dragging',
onEnd: function(evt) {
const taskId = evt.item.dataset.taskId;
const fromStatus = evt.from.dataset.status;
const toStatus = evt.to.dataset.status;
if (fromStatus === toStatus) return;
let handler = null;
if (toStatus === 'Active' && fromStatus === 'Paused') handler = 'Resume';
else if (toStatus === 'Active') handler = 'Start';
else if (toStatus === 'Paused') handler = 'Pause';
else if (toStatus === 'Completed') handler = 'Complete';
else { evt.from.appendChild(evt.item); return; } // Revert if invalid
htmx.ajax('PUT', `/board?handler=${handler}&id=${taskId}`, {
target: '#kanban-board',
swap: 'innerHTML'
});
}
});
});
}
```
Add ghost card CSS: `.task-card--ghost` gets rotation, scale, opacity matching the React DragOverlay.
Call `initKanban()` on DOMContentLoaded and after htmx swaps (listen for `htmx:afterSwap` event on `#kanban-board`).
**Step 2: Add htmx:afterSwap listener to re-init Sortable after board updates**
```javascript
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'kanban-board' ||
evt.detail.target.closest('#kanban-board')) {
initKanban();
}
});
```
**Step 3: Manually test drag-and-drop**
Drag a Pending task to Active → should fire Start API call and refresh board. Drag Active to Paused → Pause. Drag Paused to Active → Resume. Drag to Completed → Complete. Drag to Pending → should revert (snap back).
**Step 4: Commit**
```
feat(web): add drag-and-drop between Kanban columns via SortableJS
```
---
### Task 6: Board Page — Search Modal (Ctrl+K)
**Files:**
- Modify: `TaskTracker.Api/wwwroot/js/app.js`
- Create: `TaskTracker.Api/Pages/Partials/_SearchResults.cshtml`
- Modify: `TaskTracker.Api/Pages/Board.cshtml.cs` (add search handler)
**Step 1: Add search handler to Board.cshtml.cs**
`OnGetSearchAsync(string? q)`:
- If `q` is empty/null: return recent Active/Paused/Pending tasks (up to 8)
- If `q` has value: search tasks where title, description, or category contains `q` (case-insensitive), limit 10
- Return `_SearchResults` partial
**Step 2: Create _SearchResults.cshtml**
List of results, each with:
- Status color dot
- Title
- Category badge
- Each result is a link/button that navigates to `/board?task={id}` or fires JS to open the detail panel
**Step 3: Implement search modal in app.js**
~80 lines of vanilla JS:
- Ctrl+K / Cmd+K opens the modal (toggle `#search-modal` visibility)
- Escape closes
- Input field with debounced htmx fetch: `hx-get="/board?handler=Search&q={value}"` `hx-target="#search-results"` `hx-trigger="input changed delay:200ms"`
- Arrow key navigation: track selected index, move highlight, Enter to navigate
- Backdrop click closes
The search modal HTML structure can be in `_Layout.cshtml` (hidden by default) with the results container inside it.
**Step 4: Manually test**
Press Ctrl+K → modal opens. Type a search term → results appear. Arrow keys move selection. Enter opens task. Escape closes.
**Step 5: Commit**
```
feat(web): add Ctrl+K command palette search modal
```
---
### Task 7: Analytics Page
**Files:**
- Create: `TaskTracker.Api/Pages/Analytics.cshtml`
- Create: `TaskTracker.Api/Pages/Analytics.cshtml.cs`
**Step 1: Create Analytics.cshtml.cs**
Inject `ITaskRepository`, `IContextEventRepository`, `IAppMappingRepository`.
`OnGetAsync(int minutes = 1440, int? taskId = null)`:
- Load all tasks
- Load context events for the time range
- Load mappings
- Compute stat cards: open tasks count, total active time, top category
- Compute timeline data: bucket events by hour, resolve category via mappings, serialize as JSON for Chart.js
- Compute category breakdown: group events by resolved category, count, serialize as JSON
- Load activity feed (first 20 events, most recent first)
`OnGetActivityFeedAsync(int minutes, int? taskId, int offset)`:
- Return next batch of activity feed items as a partial (for htmx "Load more")
**Step 2: Create Analytics.cshtml**
Port structure from `TaskTracker.Web/src/pages/Analytics.tsx`:
- Header with time range and task filter dropdowns (use `<select>` with htmx `hx-get` on change to reload the page with new params)
- 3 stat cards (rendered server-side)
- Timeline section: `<canvas id="timeline-chart">` + inline `<script>` that reads JSON from a `<script type="application/json">` tag and renders a Chart.js bar chart
- Category breakdown: `<canvas id="category-chart">` + Chart.js donut
- Activity feed: server-rendered list with "Load more" button using htmx
Chart.js config should match the React Recharts appearance:
- Timeline: vertical bar chart, bars colored by category, custom tooltip
- Category: donut chart (cutout 60%), padding angle, legend on right with colored dots and percentages
The category color resolution logic (matching app names to categories via mappings) should be done server-side and the resolved data passed to Chart.js as JSON.
**Step 3: Build and manually test**
Navigate to `/analytics`. Verify stat cards, charts render, activity feed loads, "Load more" works, dropdowns filter data.
**Step 4: Commit**
```
feat(web): add Analytics page with Chart.js charts and activity feed
```
---
### Task 8: Mappings Page
**Files:**
- Create: `TaskTracker.Api/Pages/Mappings.cshtml`
- Create: `TaskTracker.Api/Pages/Mappings.cshtml.cs`
- Create: `TaskTracker.Api/Pages/Partials/_MappingRow.cshtml`
- Create: `TaskTracker.Api/Pages/Partials/_MappingEditRow.cshtml`
**Step 1: Create Mappings.cshtml.cs**
Inject `IAppMappingRepository`.
Handlers:
- `OnGetAsync()`: Load all mappings, render full page
- `OnGetEditRowAsync(int id)`: Load mapping by ID, return `_MappingEditRow` partial (for inline edit)
- `OnGetAddRowAsync()`: Return empty `_MappingEditRow` partial (for adding new)
- `OnPostSaveAsync(int? id, string pattern, string matchType, string category, string? friendlyName)`: Create or update mapping. If `id` is null, create; otherwise update. Return `_MappingRow` partial for the saved row.
- `OnDeleteAsync(int id)`: Delete mapping, return empty response (htmx removes the row)
**Step 2: Create Mappings.cshtml**
Port structure from `TaskTracker.Web/src/pages/Mappings.tsx`:
- Header with "App Mappings" title and "Add Rule" button
- Table with columns: Pattern, Match Type, Category, Friendly Name, Actions
- Each row rendered via `_MappingRow` partial
- "Add Rule" button uses htmx to insert `_MappingEditRow` at the top of the tbody
- Empty state when no mappings
**Step 3: Create _MappingRow.cshtml**
Display row with:
- Pattern (monospace)
- Match type badge (colored: ProcessName=indigo, TitleContains=cyan, UrlContains=orange)
- Category with colored dot
- Friendly name
- Edit button: `hx-get="/mappings?handler=EditRow&id={Id}"` `hx-target="closest tr"` `hx-swap="outerHTML"`
- Delete button: `hx-delete="/mappings?handler=Delete&id={Id}"` `hx-target="closest tr"` `hx-swap="outerHTML"` `hx-confirm="Delete this mapping rule?"`
**Step 4: Create _MappingEditRow.cshtml**
Inline edit row (replaces the display row) with:
- Pattern text input (autofocus)
- Match type select (ProcessName, TitleContains, UrlContains)
- Category text input
- Friendly name text input
- Save button (check icon): `hx-post="/mappings?handler=Save"` with form values
- Cancel button (x icon): `hx-get="/mappings?handler=Row&id={Id}"` to swap back to display (or for new rows, just remove the row)
**Step 5: Build and manually test**
Navigate to `/mappings`. Add a new mapping, edit it, delete it. Verify inline edit and table updates.
**Step 6: Commit**
```
feat(web): add Mappings page with inline CRUD table
```
---
### Task 9: Detail Panel Slide-In Animation and Polish
**Files:**
- Modify: `TaskTracker.Api/wwwroot/css/site.css`
- Modify: `TaskTracker.Api/wwwroot/js/app.js`
**Step 1: Implement slide-in animation in CSS**
The detail panel uses CSS transitions instead of Framer Motion:
```css
.detail-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.detail-overlay--open {
opacity: 1;
pointer-events: auto;
}
.detail-panel {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: 480px;
z-index: 50;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
/* ... background, border, shadow */
}
.detail-panel--open {
transform: translateX(0);
}
```
**Step 2: Implement panel open/close logic in app.js**
- Opening: When htmx loads the detail partial into `#detail-panel`, add `--open` classes to overlay and panel
- Closing: Remove `--open` classes, wait for transition end, then clear the panel content
- Backdrop click closes
- Escape key closes (unless editing inline)
**Step 3: Implement inline editing helpers in app.js**
Small helper functions for click-to-edit fields:
- `startEdit(fieldId)`: hide display element, show input, focus
- `cancelEdit(fieldId)`: hide input, show display element
- `saveEdit(fieldId)`: read input value, fire htmx request, on success update display
~30 lines of JS.
**Step 4: Manually test**
Click a task card → panel slides in smoothly. Click backdrop or press Escape → panel slides out. Inline edit title, description, category, estimate. Verify smooth transitions.
**Step 5: Commit**
```
feat(web): add detail panel slide-in animation and inline editing
```
---
### Task 10: Remove React App and Clean Up
**Files:**
- Delete: `TaskTracker.Web/` directory (entire React app)
- Modify: `.gitignore` (remove node_modules entry if no longer needed)
**Step 1: Verify all features work**
Before deleting, do a final pass:
- Board: 4 columns render, drag-and-drop works, task creation works, filters work
- Detail panel: slides in, inline edit works, subtasks/notes work, action buttons work
- Search: Ctrl+K opens, search works, keyboard navigation works
- Analytics: stat cards, charts, activity feed, filters all work
- Mappings: CRUD table works with inline editing
**Step 2: Delete the React app directory**
```bash
rm -rf TaskTracker.Web/
```
**Step 3: Update .gitignore**
Remove any React/Node-specific entries. Keep entries relevant to .NET.
**Step 4: Build the full solution**
Run: `dotnet build`
Expected: All projects build successfully. No references to TaskTracker.Web.
**Step 5: Commit**
```
feat(web): remove React app — migration to Razor Pages complete
```
---
### Task 11: Final Integration Test
**Step 1: Run the app and test end-to-end**
Start the API: `dotnet run --project TaskTracker.Api`
Test checklist:
- [ ] Navigate to `/board` — Kanban columns load with tasks
- [ ] Create a new task via inline form
- [ ] Drag task from Pending to Active
- [ ] Drag task from Active to Paused
- [ ] Drag task from Paused to Active (resume)
- [ ] Drag task to Completed
- [ ] Click task card — detail panel slides in
- [ ] Edit title, description, category, estimate inline
- [ ] Add a subtask, complete it
- [ ] Add a note
- [ ] Use action buttons (Start, Pause, Resume, Complete, Abandon)
- [ ] Ctrl+K search — type query, arrow navigate, enter to select
- [ ] Category filter chips on board
- [ ] Navigate to `/analytics` — stat cards, charts, activity feed
- [ ] Change time range and task filter dropdowns
- [ ] Click "Load more" on activity feed
- [ ] Navigate to `/mappings` — table renders
- [ ] Add a new mapping rule
- [ ] Edit an existing mapping
- [ ] Delete a mapping
- [ ] Verify API endpoints still work (`/api/tasks`, `/api/mappings`, `/swagger`)
**Step 2: Verify external consumers still work**
- MCP server: uses `/api/*` endpoints — unchanged
- WindowWatcher: uses `/api/context` — unchanged
- Chrome extension: uses `/api/*` — unchanged
- Swagger UI: `/swagger` — still accessible
**Step 3: Commit any final fixes**
```
fix(web): address integration test findings
```