Compare commits
14 Commits
864e5b712c
...
d784f9fea8
| Author | SHA1 | Date | |
|---|---|---|---|
| d784f9fea8 | |||
| 5a273ba667 | |||
| 6ea0e40d38 | |||
| cffd09941a | |||
| a6adaea2da | |||
| 91f2eec922 | |||
| fc674847f5 | |||
| 1fafae9705 | |||
| 1d1b2a153e | |||
| e34c5d561f | |||
| bef7916cf8 | |||
| c76956fb5b | |||
| 13b1f14344 | |||
| e04e9573ea |
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
5
TaskTracker.Api/Hubs/ActivityHub.cs
Normal file
5
TaskTracker.Api/Hubs/ActivityHub.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace TaskTracker.Api.Hubs;
|
||||
|
||||
public class ActivityHub : Hub { }
|
||||
216
TaskTracker.Api/Pages/Analytics.cshtml
Normal file
216
TaskTracker.Api/Pages/Analytics.cshtml
Normal 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>
|
||||
}
|
||||
206
TaskTracker.Api/Pages/Analytics.cshtml.cs
Normal file
206
TaskTracker.Api/Pages/Analytics.cshtml.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
12
TaskTracker.Api/Pages/Board.cshtml
Normal file
12
TaskTracker.Api/Pages/Board.cshtml
Normal 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>
|
||||
323
TaskTracker.Api/Pages/Board.cshtml.cs
Normal file
323
TaskTracker.Api/Pages/Board.cshtml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
TaskTracker.Api/Pages/Index.cshtml
Normal file
2
TaskTracker.Api/Pages/Index.cshtml
Normal file
@@ -0,0 +1,2 @@
|
||||
@page
|
||||
@model TaskTracker.Api.Pages.IndexModel
|
||||
9
TaskTracker.Api/Pages/Index.cshtml.cs
Normal file
9
TaskTracker.Api/Pages/Index.cshtml.cs
Normal 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");
|
||||
}
|
||||
48
TaskTracker.Api/Pages/Mappings.cshtml
Normal file
48
TaskTracker.Api/Pages/Mappings.cshtml
Normal 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>
|
||||
96
TaskTracker.Api/Pages/Mappings.cshtml.cs
Normal file
96
TaskTracker.Api/Pages/Mappings.cshtml.cs
Normal 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
|
||||
}
|
||||
}
|
||||
15
TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml
Normal file
15
TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml
Normal 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>
|
||||
}
|
||||
11
TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml
Normal file
11
TaskTracker.Api/Pages/Partials/_CreateTaskForm.cshtml
Normal 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>
|
||||
55
TaskTracker.Api/Pages/Partials/_FilterBar.cshtml
Normal file
55
TaskTracker.Api/Pages/Partials/_FilterBar.cshtml
Normal 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>
|
||||
12
TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml
Normal file
12
TaskTracker.Api/Pages/Partials/_KanbanBoard.cshtml
Normal 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>
|
||||
33
TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml
Normal file
33
TaskTracker.Api/Pages/Partials/_KanbanColumn.cshtml
Normal 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>
|
||||
67
TaskTracker.Api/Pages/Partials/_MappingEditRow.cshtml
Normal file
67
TaskTracker.Api/Pages/Partials/_MappingEditRow.cshtml
Normal 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>
|
||||
41
TaskTracker.Api/Pages/Partials/_MappingRow.cshtml
Normal file
41
TaskTracker.Api/Pages/Partials/_MappingRow.cshtml
Normal 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>
|
||||
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal file
64
TaskTracker.Api/Pages/Partials/_NotesList.cshtml
Normal 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>
|
||||
37
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal file
37
TaskTracker.Api/Pages/Partials/_SearchResults.cshtml
Normal 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>
|
||||
}
|
||||
}
|
||||
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal file
46
TaskTracker.Api/Pages/Partials/_SubtaskList.cshtml
Normal 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>
|
||||
55
TaskTracker.Api/Pages/Partials/_TaskCard.cshtml
Normal file
55
TaskTracker.Api/Pages/Partials/_TaskCard.cshtml
Normal 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>
|
||||
262
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal file
262
TaskTracker.Api/Pages/Partials/_TaskDetail.cshtml
Normal 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 -->
|
||||
85
TaskTracker.Api/Pages/Shared/_Layout.cshtml
Normal file
85
TaskTracker.Api/Pages/Shared/_Layout.cshtml
Normal 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>
|
||||
4
TaskTracker.Api/Pages/_ViewImports.cshtml
Normal file
4
TaskTracker.Api/Pages/_ViewImports.cshtml
Normal file
@@ -0,0 +1,4 @@
|
||||
@using TaskTracker.Core.Entities
|
||||
@using TaskTracker.Core.Enums
|
||||
@using TaskTracker.Core.DTOs
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
TaskTracker.Api/Pages/_ViewStart.cshtml
Normal file
3
TaskTracker.Api/Pages/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
1717
TaskTracker.Api/wwwroot/css/site.css
Normal file
1717
TaskTracker.Api/wwwroot/css/site.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) + ' · ' : ''}${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">← ${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;
|
||||
}
|
||||
2
TaskTracker.Api/wwwroot/lib/Sortable.min.js
vendored
Normal file
2
TaskTracker.Api/wwwroot/lib/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
20
TaskTracker.Api/wwwroot/lib/chart.umd.min.js
vendored
Normal file
20
TaskTracker.Api/wwwroot/lib/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
TaskTracker.Api/wwwroot/lib/htmx.min.js
vendored
Normal file
1
TaskTracker.Api/wwwroot/lib/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
TaskTracker.Api/wwwroot/lib/signalr.min.js
vendored
Normal file
2
TaskTracker.Api/wwwroot/lib/signalr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
TaskTracker.Web/.gitignore
vendored
24
TaskTracker.Web/.gitignore
vendored
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
4796
TaskTracker.Web/package-lock.json
generated
4796
TaskTracker.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
160
docs/plans/2026-03-01-razor-pages-migration-design.md
Normal file
160
docs/plans/2026-03-01-razor-pages-migration-design.md
Normal 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
|
||||
720
docs/plans/2026-03-01-razor-pages-migration-plan.md
Normal file
720
docs/plans/2026-03-01-razor-pages-migration-plan.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user