chore: initial commit of TaskTracker project

Existing ASP.NET API with vanilla JS SPA, WindowWatcher, Chrome extension, and MCP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:08:45 -05:00
commit e12f78c479
66 changed files with 5170 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.vs/
bin/
obj/
node_modules/
*.user
*.suo
*.db
*.db-shm
*.db-wal
dist/

View File

@@ -0,0 +1,55 @@
const API_BASE = "http://localhost:5200";
let debounceTimer = null;
let lastReportedUrl = "";
let lastReportedTitle = "";
function reportTab(tab) {
if (!tab || !tab.url || tab.url.startsWith("chrome://") || tab.url.startsWith("chrome-extension://")) {
return;
}
// Skip if same as last reported
if (tab.url === lastReportedUrl && tab.title === lastReportedTitle) {
return;
}
// Debounce: wait 2 seconds before reporting
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
const payload = {
source: "ChromeExtension",
appName: "chrome.exe",
windowTitle: tab.title || "",
url: tab.url
};
fetch(`${API_BASE}/api/context`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(() => {
lastReportedUrl = tab.url;
lastReportedTitle = tab.title;
})
.catch(err => console.warn("Failed to report context:", err));
}, 2000);
}
// Tab switched
chrome.tabs.onActivated.addListener((activeInfo) => {
chrome.tabs.get(activeInfo.tabId, (tab) => {
if (chrome.runtime.lastError) return;
reportTab(tab);
});
});
// Navigation within tab
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === "complete" && tab.active) {
reportTab(tab);
}
});

View File

@@ -0,0 +1,16 @@
{
"manifest_version": 3,
"name": "Work Context Tracker",
"version": "1.0",
"description": "Reports active tab URL and title to the Work Context Tracker API",
"permissions": ["tabs", "activeTab", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
},
"host_permissions": [
"http://localhost:5200/*"
]
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
width: 300px;
padding: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
color: #333;
}
h3 {
margin: 0 0 8px 0;
font-size: 14px;
color: #1a73e8;
}
.task-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.task-status {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
margin-bottom: 8px;
}
.status-Active { background: #e8f5e9; color: #2e7d32; }
.status-Paused { background: #fff3e0; color: #e65100; }
.status-Pending { background: #e3f2fd; color: #1565c0; }
.no-task {
color: #888;
font-style: italic;
}
button {
display: block;
width: 100%;
padding: 8px;
margin-top: 8px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
button:hover { background: #f5f5f5; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#error { color: #d32f2f; font-size: 11px; margin-top: 8px; }
</style>
</head>
<body>
<h3>Work Context Tracker</h3>
<div id="content">Loading...</div>
<div id="error"></div>
<script src="popup.js"></script>
</body>
</html>

62
ChromeExtension/popup.js Normal file
View File

@@ -0,0 +1,62 @@
const API_BASE = "http://localhost:5200";
const content = document.getElementById("content");
const errorDiv = document.getElementById("error");
let currentTask = null;
async function loadActiveTask() {
try {
const res = await fetch(`${API_BASE}/api/tasks/active`);
const json = await res.json();
if (!json.success || !json.data) {
content.innerHTML = '<p class="no-task">No active task</p>';
return;
}
currentTask = json.data;
renderTask(currentTask);
} catch (err) {
errorDiv.textContent = "Cannot connect to API";
}
}
function renderTask(task) {
const isActive = task.status === "Active";
const isPaused = task.status === "Paused";
content.innerHTML = `
<div class="task-title">${escapeHtml(task.title)}</div>
<span class="task-status status-${task.status}">${task.status}</span>
${task.category ? `<div style="font-size:11px;color:#666;">Category: ${escapeHtml(task.category)}</div>` : ""}
<button id="toggleBtn">${isActive ? "Pause Task" : "Resume Task"}</button>
`;
document.getElementById("toggleBtn").addEventListener("click", () => toggleTask(task.id, isActive));
}
async function toggleTask(taskId, isCurrentlyActive) {
try {
const action = isCurrentlyActive ? "pause" : "resume";
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/${action}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
const json = await res.json();
if (json.success && json.data) {
currentTask = json.data;
renderTask(currentTask);
}
} catch (err) {
errorDiv.textContent = "Failed to update task";
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
loadActiveTask();

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ContextController(
IContextEventRepository contextRepo,
IAppMappingRepository mappingRepo,
ITaskRepository taskRepo,
ILogger<ContextController> logger) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Ingest([FromBody] ContextEventRequest request)
{
// Try to auto-link to the active task
var activeTask = await taskRepo.GetActiveTaskAsync();
var contextEvent = new ContextEvent
{
Source = request.Source,
AppName = request.AppName,
WindowTitle = request.WindowTitle,
Url = request.Url,
WorkTaskId = activeTask?.Id
};
// Try to find a matching app mapping for category enrichment
var mapping = await mappingRepo.FindMatchAsync(request.AppName, request.WindowTitle, request.Url);
if (mapping is not null)
{
logger.LogDebug("Matched context event to category {Category} via {MatchType}",
mapping.Category, mapping.MatchType);
}
var created = await contextRepo.CreateAsync(contextEvent);
return Ok(ApiResponse<ContextEvent>.Ok(created));
}
[HttpGet("recent")]
public async Task<IActionResult> GetRecent([FromQuery] int minutes = 30)
{
var events = await contextRepo.GetRecentAsync(minutes);
return Ok(ApiResponse<List<ContextEvent>>.Ok(events));
}
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
// Get today's events (last 8 hours)
var events = await contextRepo.GetRecentAsync(480);
var mappings = await mappingRepo.GetAllAsync();
var summary = events
.GroupBy(e => e.AppName)
.Select(g =>
{
var match = mappings.FirstOrDefault(m => m.MatchType switch
{
"ProcessName" => g.Key.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"TitleContains" => g.Any(e => e.WindowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase)),
"UrlContains" => g.Any(e => e.Url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true),
_ => false
});
return new ContextSummaryItem
{
AppName = match?.FriendlyName ?? g.Key,
Category = match?.Category ?? "Unknown",
EventCount = g.Count(),
FirstSeen = g.Min(e => e.Timestamp),
LastSeen = g.Max(e => e.Timestamp)
};
})
.OrderByDescending(s => s.EventCount)
.ToList();
return Ok(ApiResponse<List<ContextSummaryItem>>.Ok(summary));
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Mvc;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MappingsController(IAppMappingRepository mappingRepo) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var mappings = await mappingRepo.GetAllAsync();
return Ok(ApiResponse<List<AppMapping>>.Ok(mappings));
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateAppMappingRequest request)
{
var mapping = new AppMapping
{
Pattern = request.Pattern,
MatchType = request.MatchType,
Category = request.Category,
FriendlyName = request.FriendlyName
};
var created = await mappingRepo.CreateAsync(mapping);
return Ok(ApiResponse<AppMapping>.Ok(created));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] CreateAppMappingRequest request)
{
var mapping = await mappingRepo.GetByIdAsync(id);
if (mapping is null)
return NotFound(ApiResponse.Fail("Mapping not found"));
mapping.Pattern = request.Pattern;
mapping.MatchType = request.MatchType;
mapping.Category = request.Category;
mapping.FriendlyName = request.FriendlyName;
await mappingRepo.UpdateAsync(mapping);
return Ok(ApiResponse<AppMapping>.Ok(mapping));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
await mappingRepo.DeleteAsync(id);
return Ok(ApiResponse.Ok());
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Controllers;
[ApiController]
[Route("api/tasks/{taskId:int}/notes")]
public class NotesController(ITaskRepository taskRepo) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetNotes(int taskId)
{
var task = await taskRepo.GetByIdAsync(taskId);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
return Ok(ApiResponse<List<TaskNote>>.Ok(task.Notes));
}
[HttpPost]
public async Task<IActionResult> AddNote(int taskId, [FromBody] CreateNoteRequest request)
{
var task = await taskRepo.GetByIdAsync(taskId);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
var note = new TaskNote
{
Content = request.Content,
Type = request.Type,
CreatedAt = DateTime.UtcNow
};
task.Notes.Add(note);
await taskRepo.UpdateAsync(task);
return Ok(ApiResponse<TaskNote>.Ok(note));
}
}

View File

@@ -0,0 +1,171 @@
using Microsoft.AspNetCore.Mvc;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Enums;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TasksController(ITaskRepository taskRepo, ILogger<TasksController> logger) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] WorkTaskStatus? status, [FromQuery] int? parentId, [FromQuery] bool includeSubTasks = false)
{
var tasks = await taskRepo.GetAllAsync(status, parentId, includeSubTasks);
return Ok(ApiResponse<List<WorkTask>>.Ok(tasks));
}
[HttpGet("active")]
public async Task<IActionResult> GetActive()
{
var task = await taskRepo.GetActiveTaskAsync();
if (task is null)
return Ok(ApiResponse<WorkTask?>.Ok(null));
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTaskRequest request)
{
if (request.ParentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(request.ParentTaskId.Value);
if (parent is null)
return BadRequest(ApiResponse.Fail("Parent task not found"));
}
var task = new WorkTask
{
Title = request.Title,
Description = request.Description,
Category = request.Category,
ParentTaskId = request.ParentTaskId,
Status = WorkTaskStatus.Pending
};
var created = await taskRepo.CreateAsync(task);
logger.LogInformation("Created task {TaskId}: {Title}", created.Id, created.Title);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ApiResponse<WorkTask>.Ok(created));
}
[HttpPut("{id:int}/start")]
public async Task<IActionResult> Start(int id)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
// Pause any currently active task
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);
logger.LogInformation("Started task {TaskId}", id);
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpPut("{id:int}/pause")]
public async Task<IActionResult> Pause(int id, [FromBody] TaskActionRequest? request)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
task.Status = WorkTaskStatus.Paused;
if (!string.IsNullOrWhiteSpace(request?.Note))
{
task.Notes.Add(new TaskNote
{
Content = request.Note,
Type = NoteType.PauseNote,
CreatedAt = DateTime.UtcNow
});
}
await taskRepo.UpdateAsync(task);
logger.LogInformation("Paused task {TaskId}", id);
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpPut("{id:int}/resume")]
public async Task<IActionResult> Resume(int id, [FromBody] TaskActionRequest? request)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
// Pause any currently active task
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;
if (!string.IsNullOrWhiteSpace(request?.Note))
{
task.Notes.Add(new TaskNote
{
Content = request.Note,
Type = NoteType.ResumeNote,
CreatedAt = DateTime.UtcNow
});
}
await taskRepo.UpdateAsync(task);
logger.LogInformation("Resumed task {TaskId}", id);
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpPut("{id:int}/complete")]
public async Task<IActionResult> Complete(int id)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
// Block completion if any subtasks are not completed/abandoned
var incompleteSubTasks = task.SubTasks
.Where(st => st.Status != WorkTaskStatus.Completed && st.Status != WorkTaskStatus.Abandoned)
.ToList();
if (incompleteSubTasks.Count > 0)
return BadRequest(ApiResponse.Fail($"Cannot complete task: {incompleteSubTasks.Count} subtask(s) are still incomplete"));
task.Status = WorkTaskStatus.Completed;
task.CompletedAt = DateTime.UtcNow;
await taskRepo.UpdateAsync(task);
logger.LogInformation("Completed task {TaskId}", id);
return Ok(ApiResponse<WorkTask>.Ok(task));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
await taskRepo.DeleteAsync(id);
logger.LogInformation("Abandoned task {TaskId}", id);
return Ok(ApiResponse.Ok());
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
using TaskTracker.Infrastructure.Repositories;
var builder = WebApplication.CreateBuilder(args);
// EF Core
builder.Services.AddDbContext<TaskTrackerDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Repositories
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IContextEventRepository, ContextEventRepository>();
builder.Services.AddScoped<IAppMappingRepository, AppMappingRepository>();
// Controllers + JSON
builder.Services.AddControllers()
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
o.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CORS — allow Chrome extension and local MCP server
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
// Auto-migrate on startup in development
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TaskTrackerDbContext>();
db.Database.Migrate();
}
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5200",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:5201;http://localhost:5200",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TaskTracker.Core\TaskTracker.Core.csproj" />
<ProjectReference Include="..\TaskTracker.Infrastructure\TaskTracker.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@TaskTracker.Api_HostAddress = http://localhost:5125
GET {{TaskTracker.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost\\SQLEXPRESS;Database=WorkContextTracker;Trusted_Connection=True;TrustServerCertificate=True;"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,619 @@
/* ── 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); }

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
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';
const pages = ['dashboard', 'tasks', 'context', 'mappings'];
let currentPage = null;
let refreshTimer = null;
function navigate(page) {
if (!pages.includes(page)) page = 'dashboard';
if (currentPage === page) return;
currentPage = page;
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === page);
});
// Show/hide pages
pages.forEach(p => {
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
});
// Load page content
const loaders = { dashboard: refreshDashboard, tasks: initTasks, context: initContext, mappings: initMappings };
loaders[page]?.();
// Auto-refresh for dashboard and context
clearInterval(refreshTimer);
if (page === 'dashboard' || page === 'context') {
refreshTimer = setInterval(() => loaders[page]?.(), 30000);
}
}
function onHashChange() {
const hash = location.hash.slice(2) || 'dashboard';
navigate(hash);
}
// Init
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
location.hash = link.getAttribute('href').slice(1);
});
});
window.addEventListener('hashchange', onHashChange);
initDashboard();
initTasks();
onHashChange();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
namespace TaskTracker.Core.DTOs;
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Error { get; set; }
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
public class ApiResponse
{
public bool Success { get; set; }
public string? Error { get; set; }
public static ApiResponse Ok() => new() { Success = true };
public static ApiResponse Fail(string error) => new() { Success = false, Error = error };
}

View File

@@ -0,0 +1,9 @@
namespace TaskTracker.Core.DTOs;
public class ContextEventRequest
{
public string Source { get; set; } = string.Empty;
public string AppName { get; set; } = string.Empty;
public string WindowTitle { get; set; } = string.Empty;
public string? Url { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace TaskTracker.Core.DTOs;
public class ContextSummaryItem
{
public string AppName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public int EventCount { get; set; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace TaskTracker.Core.DTOs;
public class CreateAppMappingRequest
{
public string Pattern { get; set; } = string.Empty;
public string MatchType { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string? FriendlyName { get; set; }
}

View File

@@ -0,0 +1,9 @@
using TaskTracker.Core.Enums;
namespace TaskTracker.Core.DTOs;
public class CreateNoteRequest
{
public string Content { get; set; } = string.Empty;
public NoteType Type { get; set; } = NoteType.General;
}

View File

@@ -0,0 +1,9 @@
namespace TaskTracker.Core.DTOs;
public class CreateTaskRequest
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Category { get; set; }
public int? ParentTaskId { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace TaskTracker.Core.DTOs;
public class TaskActionRequest
{
public string? Note { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace TaskTracker.Core.Entities;
public class AppMapping
{
public int Id { get; set; }
public string Pattern { get; set; } = string.Empty;
public string MatchType { get; set; } = string.Empty; // ProcessName, UrlContains, TitleContains
public string Category { get; set; } = string.Empty;
public string? FriendlyName { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace TaskTracker.Core.Entities;
public class ContextEvent
{
public int Id { get; set; }
public int? WorkTaskId { get; set; }
public string Source { get; set; } = string.Empty;
public string AppName { get; set; } = string.Empty;
public string WindowTitle { get; set; } = string.Empty;
public string? Url { get; set; }
public DateTime Timestamp { get; set; }
public WorkTask? WorkTask { get; set; }
}

View File

@@ -0,0 +1,14 @@
using TaskTracker.Core.Enums;
namespace TaskTracker.Core.Entities;
public class TaskNote
{
public int Id { get; set; }
public int WorkTaskId { get; set; }
public string Content { get; set; } = string.Empty;
public NoteType Type { get; set; }
public DateTime CreatedAt { get; set; }
public WorkTask WorkTask { get; set; } = null!;
}

View File

@@ -0,0 +1,22 @@
using TaskTracker.Core.Enums;
namespace TaskTracker.Core.Entities;
public class WorkTask
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public WorkTaskStatus Status { get; set; }
public string? Category { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int? ParentTaskId { get; set; }
public WorkTask? ParentTask { get; set; }
public List<WorkTask> SubTasks { get; set; } = new();
public List<TaskNote> Notes { get; set; } = [];
public List<ContextEvent> ContextEvents { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace TaskTracker.Core.Enums;
public enum NoteType
{
PauseNote,
ResumeNote,
General
}

View File

@@ -0,0 +1,10 @@
namespace TaskTracker.Core.Enums;
public enum WorkTaskStatus
{
Pending,
Active,
Paused,
Completed,
Abandoned
}

View File

@@ -0,0 +1,13 @@
using TaskTracker.Core.Entities;
namespace TaskTracker.Core.Interfaces;
public interface IAppMappingRepository
{
Task<List<AppMapping>> GetAllAsync();
Task<AppMapping?> GetByIdAsync(int id);
Task<AppMapping> CreateAsync(AppMapping mapping);
Task UpdateAsync(AppMapping mapping);
Task DeleteAsync(int id);
Task<AppMapping?> FindMatchAsync(string appName, string windowTitle, string? url);
}

View File

@@ -0,0 +1,10 @@
using TaskTracker.Core.Entities;
namespace TaskTracker.Core.Interfaces;
public interface IContextEventRepository
{
Task<ContextEvent> CreateAsync(ContextEvent contextEvent);
Task<List<ContextEvent>> GetRecentAsync(int minutes = 30);
Task<List<ContextEvent>> GetByTaskIdAsync(int taskId);
}

View File

@@ -0,0 +1,15 @@
using TaskTracker.Core.Entities;
using TaskTracker.Core.Enums;
namespace TaskTracker.Core.Interfaces;
public interface ITaskRepository
{
Task<List<WorkTask>> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false);
Task<WorkTask?> GetByIdAsync(int id);
Task<WorkTask?> GetActiveTaskAsync();
Task<List<WorkTask>> GetSubTasksAsync(int parentId);
Task<WorkTask> CreateAsync(WorkTask task);
Task UpdateAsync(WorkTask task);
Task DeleteAsync(int id);
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Core.Entities;
namespace TaskTracker.Infrastructure.Data;
public class TaskTrackerDbContext(DbContextOptions<TaskTrackerDbContext> options) : DbContext(options)
{
public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<TaskNote> Notes => Set<TaskNote>();
public DbSet<ContextEvent> ContextEvents => Set<ContextEvent>();
public DbSet<AppMapping> AppMappings => Set<AppMapping>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<WorkTask>(e =>
{
e.HasKey(t => t.Id);
e.Property(t => t.Title).HasMaxLength(500).IsRequired();
e.Property(t => t.Status).HasConversion<string>().HasMaxLength(50);
e.Property(t => t.Category).HasMaxLength(100);
e.HasMany(t => t.SubTasks).WithOne(t => t.ParentTask).HasForeignKey(t => t.ParentTaskId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(t => t.ParentTaskId);
e.HasMany(t => t.Notes).WithOne(n => n.WorkTask).HasForeignKey(n => n.WorkTaskId);
e.HasMany(t => t.ContextEvents).WithOne(c => c.WorkTask).HasForeignKey(c => c.WorkTaskId);
});
modelBuilder.Entity<TaskNote>(e =>
{
e.HasKey(n => n.Id);
e.Property(n => n.Content).IsRequired();
e.Property(n => n.Type).HasConversion<string>().HasMaxLength(50);
});
modelBuilder.Entity<ContextEvent>(e =>
{
e.HasKey(c => c.Id);
e.Property(c => c.Source).HasMaxLength(100).IsRequired();
e.Property(c => c.AppName).HasMaxLength(200).IsRequired();
e.Property(c => c.WindowTitle).HasMaxLength(1000).IsRequired();
e.Property(c => c.Url).HasMaxLength(2000);
e.HasIndex(c => c.Timestamp);
});
modelBuilder.Entity<AppMapping>(e =>
{
e.HasKey(m => m.Id);
e.Property(m => m.Pattern).HasMaxLength(500).IsRequired();
e.Property(m => m.MatchType).HasMaxLength(50).IsRequired();
e.Property(m => m.Category).HasMaxLength(100).IsRequired();
e.Property(m => m.FriendlyName).HasMaxLength(200);
});
// Seed default app mappings
modelBuilder.Entity<AppMapping>().HasData(
new AppMapping { Id = 1, Pattern = "SLDWORKS", MatchType = "ProcessName", Category = "Engineering", FriendlyName = "SolidWorks" },
new AppMapping { Id = 2, Pattern = "OUTLOOK", MatchType = "ProcessName", Category = "Email", FriendlyName = "Outlook" },
new AppMapping { Id = 3, Pattern = "notepad", MatchType = "ProcessName", Category = "General", FriendlyName = "Notepad" },
new AppMapping { Id = 4, Pattern = "pep", MatchType = "UrlContains", Category = "LaserCutting", FriendlyName = "PEP System" },
new AppMapping { Id = 5, Pattern = "solidworks", MatchType = "TitleContains", Category = "Engineering", FriendlyName = "SolidWorks" }
);
}
}

View File

@@ -0,0 +1,242 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskTracker.Infrastructure.Data;
#nullable disable
namespace TaskTracker.Infrastructure.Migrations
{
[DbContext(typeof(TaskTrackerDbContext))]
[Migration("20260226032729_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("FriendlyName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("MatchType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.ToTable("AppMappings");
b.HasData(
new
{
Id = 1,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "ProcessName",
Pattern = "SLDWORKS"
},
new
{
Id = 2,
Category = "Email",
FriendlyName = "Outlook",
MatchType = "ProcessName",
Pattern = "OUTLOOK"
},
new
{
Id = 3,
Category = "General",
FriendlyName = "Notepad",
MatchType = "ProcessName",
Pattern = "notepad"
},
new
{
Id = 4,
Category = "LaserCutting",
FriendlyName = "PEP System",
MatchType = "UrlContains",
Pattern = "pep"
},
new
{
Id = 5,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "TitleContains",
Pattern = "solidworks"
});
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AppName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime2");
b.Property<string>("Url")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("WindowTitle")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.HasIndex("WorkTaskId");
b.ToTable("ContextEvents");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkTaskId");
b.ToTable("Notes");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.ToTable("Tasks");
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("ContextEvents")
.HasForeignKey("WorkTaskId");
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("Notes")
.HasForeignKey("WorkTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Navigation("ContextEvents");
b.Navigation("Notes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace TaskTracker.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppMappings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Pattern = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
MatchType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
FriendlyName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AppMappings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Title = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContextEvents",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkTaskId = table.Column<int>(type: "int", nullable: true),
Source = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
AppName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
WindowTitle = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
Url = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
Timestamp = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ContextEvents", x => x.Id);
table.ForeignKey(
name: "FK_ContextEvents_Tasks_WorkTaskId",
column: x => x.WorkTaskId,
principalTable: "Tasks",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Notes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkTaskId = table.Column<int>(type: "int", nullable: false),
Content = table.Column<string>(type: "nvarchar(max)", nullable: false),
Type = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notes", x => x.Id);
table.ForeignKey(
name: "FK_Notes_Tasks_WorkTaskId",
column: x => x.WorkTaskId,
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "AppMappings",
columns: new[] { "Id", "Category", "FriendlyName", "MatchType", "Pattern" },
values: new object[,]
{
{ 1, "Engineering", "SolidWorks", "ProcessName", "SLDWORKS" },
{ 2, "Email", "Outlook", "ProcessName", "OUTLOOK" },
{ 3, "General", "Notepad", "ProcessName", "notepad" },
{ 4, "LaserCutting", "PEP System", "UrlContains", "pep" },
{ 5, "Engineering", "SolidWorks", "TitleContains", "solidworks" }
});
migrationBuilder.CreateIndex(
name: "IX_ContextEvents_Timestamp",
table: "ContextEvents",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_ContextEvents_WorkTaskId",
table: "ContextEvents",
column: "WorkTaskId");
migrationBuilder.CreateIndex(
name: "IX_Notes_WorkTaskId",
table: "Notes",
column: "WorkTaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppMappings");
migrationBuilder.DropTable(
name: "ContextEvents");
migrationBuilder.DropTable(
name: "Notes");
migrationBuilder.DropTable(
name: "Tasks");
}
}
}

View File

@@ -0,0 +1,259 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskTracker.Infrastructure.Data;
#nullable disable
namespace TaskTracker.Infrastructure.Migrations
{
[DbContext(typeof(TaskTrackerDbContext))]
[Migration("20260227013459_AddSubTasks")]
partial class AddSubTasks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("FriendlyName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("MatchType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.ToTable("AppMappings");
b.HasData(
new
{
Id = 1,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "ProcessName",
Pattern = "SLDWORKS"
},
new
{
Id = 2,
Category = "Email",
FriendlyName = "Outlook",
MatchType = "ProcessName",
Pattern = "OUTLOOK"
},
new
{
Id = 3,
Category = "General",
FriendlyName = "Notepad",
MatchType = "ProcessName",
Pattern = "notepad"
},
new
{
Id = 4,
Category = "LaserCutting",
FriendlyName = "PEP System",
MatchType = "UrlContains",
Pattern = "pep"
},
new
{
Id = 5,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "TitleContains",
Pattern = "solidworks"
});
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AppName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime2");
b.Property<string>("Url")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("WindowTitle")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.HasIndex("WorkTaskId");
b.ToTable("ContextEvents");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkTaskId");
b.ToTable("Notes");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ParentTaskId")
.HasColumnType("int");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("ContextEvents")
.HasForeignKey("WorkTaskId");
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("Notes")
.HasForeignKey("WorkTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ParentTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Navigation("ContextEvents");
b.Navigation("Notes");
b.Navigation("SubTasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TaskTracker.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSubTasks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParentTaskId",
table: "Tasks",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}

View File

@@ -0,0 +1,256 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskTracker.Infrastructure.Data;
#nullable disable
namespace TaskTracker.Infrastructure.Migrations
{
[DbContext(typeof(TaskTrackerDbContext))]
partial class TaskTrackerDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("FriendlyName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("MatchType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.ToTable("AppMappings");
b.HasData(
new
{
Id = 1,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "ProcessName",
Pattern = "SLDWORKS"
},
new
{
Id = 2,
Category = "Email",
FriendlyName = "Outlook",
MatchType = "ProcessName",
Pattern = "OUTLOOK"
},
new
{
Id = 3,
Category = "General",
FriendlyName = "Notepad",
MatchType = "ProcessName",
Pattern = "notepad"
},
new
{
Id = 4,
Category = "LaserCutting",
FriendlyName = "PEP System",
MatchType = "UrlContains",
Pattern = "pep"
},
new
{
Id = 5,
Category = "Engineering",
FriendlyName = "SolidWorks",
MatchType = "TitleContains",
Pattern = "solidworks"
});
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AppName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime2");
b.Property<string>("Url")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("WindowTitle")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Timestamp");
b.HasIndex("WorkTaskId");
b.ToTable("ContextEvents");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("WorkTaskId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkTaskId");
b.ToTable("Notes");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ParentTaskId")
.HasColumnType("int");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
});
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("ContextEvents")
.HasForeignKey("WorkTaskId");
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
.WithMany("Notes")
.HasForeignKey("WorkTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.HasOne("TaskTracker.Core.Entities.WorkTask", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ParentTask");
});
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
{
b.Navigation("ContextEvents");
b.Navigation("Notes");
b.Navigation("SubTasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
namespace TaskTracker.Infrastructure.Repositories;
public class AppMappingRepository(TaskTrackerDbContext db) : IAppMappingRepository
{
public async Task<List<AppMapping>> GetAllAsync()
{
return await db.AppMappings.OrderBy(m => m.Category).ToListAsync();
}
public async Task<AppMapping?> GetByIdAsync(int id)
{
return await db.AppMappings.FindAsync(id);
}
public async Task<AppMapping> CreateAsync(AppMapping mapping)
{
db.AppMappings.Add(mapping);
await db.SaveChangesAsync();
return mapping;
}
public async Task UpdateAsync(AppMapping mapping)
{
db.AppMappings.Update(mapping);
await db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var mapping = await db.AppMappings.FindAsync(id);
if (mapping is not null)
{
db.AppMappings.Remove(mapping);
await db.SaveChangesAsync();
}
}
public async Task<AppMapping?> FindMatchAsync(string appName, string windowTitle, string? url)
{
var mappings = await db.AppMappings.ToListAsync();
return mappings.FirstOrDefault(m => m.MatchType switch
{
"ProcessName" => appName.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"TitleContains" => windowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"UrlContains" => url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true,
_ => false
});
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
namespace TaskTracker.Infrastructure.Repositories;
public class ContextEventRepository(TaskTrackerDbContext db) : IContextEventRepository
{
public async Task<ContextEvent> CreateAsync(ContextEvent contextEvent)
{
contextEvent.Timestamp = DateTime.UtcNow;
db.ContextEvents.Add(contextEvent);
await db.SaveChangesAsync();
return contextEvent;
}
public async Task<List<ContextEvent>> GetRecentAsync(int minutes = 30)
{
var since = DateTime.UtcNow.AddMinutes(-minutes);
return await db.ContextEvents
.Where(c => c.Timestamp >= since)
.OrderByDescending(c => c.Timestamp)
.ToListAsync();
}
public async Task<List<ContextEvent>> GetByTaskIdAsync(int taskId)
{
return await db.ContextEvents
.Where(c => c.WorkTaskId == taskId)
.OrderByDescending(c => c.Timestamp)
.ToListAsync();
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Enums;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
namespace TaskTracker.Infrastructure.Repositories;
public class TaskRepository(TaskTrackerDbContext db) : ITaskRepository
{
public async Task<List<WorkTask>> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false)
{
var query = db.Tasks.Include(t => t.Notes).Include(t => t.SubTasks).AsQueryable();
if (status.HasValue)
query = query.Where(t => t.Status == status.Value);
if (parentId.HasValue)
query = query.Where(t => t.ParentTaskId == parentId.Value);
else if (!includeSubTasks)
query = query.Where(t => t.ParentTaskId == null);
return await query.OrderByDescending(t => t.CreatedAt).ToListAsync();
}
public async Task<WorkTask?> GetByIdAsync(int id)
{
return await db.Tasks
.Include(t => t.Notes.OrderByDescending(n => n.CreatedAt))
.Include(t => t.ContextEvents.OrderByDescending(c => c.Timestamp).Take(20))
.Include(t => t.SubTasks)
.Include(t => t.ParentTask)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<WorkTask?> GetActiveTaskAsync()
{
return await db.Tasks
.Include(t => t.Notes.OrderByDescending(n => n.CreatedAt))
.Include(t => t.ContextEvents.OrderByDescending(c => c.Timestamp).Take(20))
.Include(t => t.SubTasks)
.Include(t => t.ParentTask)
.FirstOrDefaultAsync(t => t.Status == WorkTaskStatus.Active);
}
public async Task<List<WorkTask>> GetSubTasksAsync(int parentId)
{
return await db.Tasks
.Include(t => t.SubTasks)
.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
public async Task<WorkTask> CreateAsync(WorkTask task)
{
task.CreatedAt = DateTime.UtcNow;
db.Tasks.Add(task);
await db.SaveChangesAsync();
return task;
}
public async Task UpdateAsync(WorkTask task)
{
db.Tasks.Update(task);
await db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var task = await db.Tasks.Include(t => t.SubTasks).FirstOrDefaultAsync(t => t.Id == id);
if (task is not null)
{
await AbandonDescendantsAsync(task);
task.Status = WorkTaskStatus.Abandoned;
await db.SaveChangesAsync();
}
}
private async System.Threading.Tasks.Task AbandonDescendantsAsync(WorkTask task)
{
var children = await db.Tasks.Include(t => t.SubTasks).Where(t => t.ParentTaskId == task.Id).ToListAsync();
foreach (var child in children)
{
await AbandonDescendantsAsync(child);
child.Status = WorkTaskStatus.Abandoned;
}
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TaskTracker.Core\TaskTracker.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.3" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
var apiBaseUrl = Environment.GetEnvironmentVariable("API_BASE_URL") ?? "http://localhost:5200";
builder.Services.AddSingleton(_ => new HttpClient
{
BaseAddress = new Uri(apiBaseUrl)
});
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,116 @@
using System.ComponentModel;
using System.Net.Http.Json;
using System.Text.Json;
using ModelContextProtocol.Server;
namespace TaskTracker.MCP.Tools;
[McpServerToolType]
public sealed class TaskTools
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
[McpServerTool, Description("Get the currently active work task with its latest notes and recent context events")]
public static async Task<string> GetActiveTask(HttpClient client)
{
var response = await client.GetAsync("/api/tasks/active");
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("List work tasks, optionally filtered by status (Pending, Active, Paused, Completed, Abandoned)")]
public static async Task<string> ListTasks(
HttpClient client,
[Description("Filter by status: Pending, Active, Paused, Completed, Abandoned")] string? status = null)
{
var url = "/api/tasks";
if (!string.IsNullOrEmpty(status))
url += $"?status={status}";
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Create a new work task")]
public static async Task<string> CreateTask(
HttpClient client,
[Description("Title of the task")] string title,
[Description("Optional description")] string? description = null,
[Description("Optional category (e.g. Engineering, Email, LaserCutting)")] string? category = null)
{
var payload = new { title, description, category };
var response = await client.PostAsJsonAsync("/api/tasks", payload, JsonOptions);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Start/activate a task by ID. Automatically pauses any currently active task.")]
public static async Task<string> StartTask(
HttpClient client,
[Description("The task ID to start")] int taskId)
{
var response = await client.PutAsync($"/api/tasks/{taskId}/start", null);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Pause a task with an optional note about why it was paused")]
public static async Task<string> PauseTask(
HttpClient client,
[Description("The task ID to pause")] int taskId,
[Description("Optional note about why the task is being paused")] string? note = null)
{
var payload = new { note };
var response = await client.PutAsJsonAsync($"/api/tasks/{taskId}/pause", payload, JsonOptions);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Resume a paused task with an optional note about context for resuming")]
public static async Task<string> ResumeTask(
HttpClient client,
[Description("The task ID to resume")] int taskId,
[Description("Optional note about context for resuming")] string? note = null)
{
var payload = new { note };
var response = await client.PutAsJsonAsync($"/api/tasks/{taskId}/resume", payload, JsonOptions);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Mark a task as complete")]
public static async Task<string> CompleteTask(
HttpClient client,
[Description("The task ID to complete")] int taskId)
{
var response = await client.PutAsync($"/api/tasks/{taskId}/complete", null);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Add a note to a task")]
public static async Task<string> AddNote(
HttpClient client,
[Description("The task ID")] int taskId,
[Description("The note content")] string content,
[Description("Note type: General, PauseNote, or ResumeNote")] string type = "General")
{
var payload = new { content, type };
var response = await client.PostAsJsonAsync($"/api/tasks/{taskId}/notes", payload, JsonOptions);
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Get a summary of recent window/browser activity grouped by application, useful for understanding what the user has been working on")]
public static async Task<string> GetContextSummary(HttpClient client)
{
var response = await client.GetAsync("/api/context/summary");
return await response.Content.ReadAsStringAsync();
}
[McpServerTool, Description("Get raw context events (window switches, browser navigations) from the last N minutes")]
public static async Task<string> GetRecentContext(
HttpClient client,
[Description("Number of minutes to look back (default 30)")] int minutes = 30)
{
var response = await client.GetAsync($"/api/context/recent?minutes={minutes}");
return await response.Content.ReadAsStringAsync();
}
}

View File

@@ -0,0 +1,16 @@
using System.Runtime.InteropServices;
using System.Text;
namespace WindowWatcher;
internal static partial class NativeMethods
{
[LibraryImport("user32.dll")]
internal static partial IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[LibraryImport("user32.dll", SetLastError = true)]
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}

32
WindowWatcher/Program.cs Normal file
View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WindowWatcher;
var hostCts = new CancellationTokenSource();
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<WindowWatcherOptions>(
builder.Configuration.GetSection(WindowWatcherOptions.SectionName));
builder.Services.AddHttpClient("TaskTrackerApi", (sp, client) =>
{
var config = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WindowWatcherOptions>>().Value;
client.BaseAddress = new Uri(config.ApiBaseUrl);
});
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
// Run the host in a background thread
var hostTask = Task.Run(() => host.RunAsync(hostCts.Token));
// Run WinForms tray icon on the main thread
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext(hostCts));
// Wait for host to finish
await hostTask;

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"WindowWatcher": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,58 @@
namespace WindowWatcher;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly CancellationTokenSource _hostCts;
private bool _trackingPaused;
public TrayApplicationContext(CancellationTokenSource hostCts)
{
_hostCts = hostCts;
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "Work Context Tracker",
Visible = true,
ContextMenuStrip = CreateMenu()
};
}
private ContextMenuStrip CreateMenu()
{
var menu = new ContextMenuStrip();
var pauseItem = new ToolStripMenuItem("Pause Tracking");
pauseItem.Click += (_, _) =>
{
_trackingPaused = !_trackingPaused;
pauseItem.Text = _trackingPaused ? "Resume Tracking" : "Pause Tracking";
_trayIcon.Text = _trackingPaused ? "Work Context Tracker (Paused)" : "Work Context Tracker";
};
var exitItem = new ToolStripMenuItem("Exit");
exitItem.Click += (_, _) =>
{
_trayIcon.Visible = false;
_hostCts.Cancel();
Application.Exit();
};
menu.Items.Add(pauseItem);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(exitItem);
return menu;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_trayIcon.Visible = false;
_trayIcon.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace WindowWatcher;
public class WindowWatcherOptions
{
public const string SectionName = "WindowWatcher";
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
public int PollIntervalMs { get; set; } = 2000;
public int DebounceMs { get; set; } = 3000;
}

111
WindowWatcher/Worker.cs Normal file
View File

@@ -0,0 +1,111 @@
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace WindowWatcher;
public class Worker(
IHttpClientFactory httpClientFactory,
IOptions<WindowWatcherOptions> options,
ILogger<Worker> logger) : BackgroundService
{
private string _lastAppName = string.Empty;
private string _lastWindowTitle = string.Empty;
private DateTime _lastChangeTime = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = options.Value;
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
config.PollIntervalMs, config.DebounceMs);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var hwnd = NativeMethods.GetForegroundWindow();
if (hwnd == IntPtr.Zero)
{
await Task.Delay(config.PollIntervalMs, stoppingToken);
continue;
}
// Get window title
var sb = new StringBuilder(512);
NativeMethods.GetWindowText(hwnd, sb, sb.Capacity);
var windowTitle = sb.ToString();
// Get process name
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
string appName;
try
{
var process = Process.GetProcessById((int)pid);
appName = process.ProcessName;
}
catch
{
appName = "Unknown";
}
// Check if changed
if (appName != _lastAppName || windowTitle != _lastWindowTitle)
{
var now = DateTime.UtcNow;
// Debounce: only report if last change was more than DebounceMs ago
if ((now - _lastChangeTime).TotalMilliseconds >= config.DebounceMs
&& !string.IsNullOrWhiteSpace(windowTitle))
{
await ReportContextEvent(appName, windowTitle, stoppingToken);
}
_lastAppName = appName;
_lastWindowTitle = windowTitle;
_lastChangeTime = now;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error polling foreground window");
}
await Task.Delay(config.PollIntervalMs, stoppingToken);
}
}
private async Task ReportContextEvent(string appName, string windowTitle, CancellationToken ct)
{
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
var payload = new
{
source = "WindowWatcher",
appName,
windowTitle
};
var response = await client.PostAsJsonAsync("/api/context", payload, ct);
if (response.IsSuccessStatusCode)
{
logger.LogDebug("Reported: {App} - {Title}", appName, windowTitle);
}
else
{
logger.LogWarning("API returned {StatusCode} for context event", response.StatusCode);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to report context event to API");
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WindowWatcher": {
"ApiBaseUrl": "http://localhost:5200",
"PollIntervalMs": 2000,
"DebounceMs": 3000
}
}

7
WorkContextTracker.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="TaskTracker.Api/TaskTracker.Api.csproj" />
<Project Path="TaskTracker.Core/TaskTracker.Core.csproj" />
<Project Path="TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj" />
<Project Path="TaskTracker.MCP/TaskTracker.MCP.csproj" />
<Project Path="WindowWatcher/WindowWatcher.csproj" />
</Solution>

View File

@@ -0,0 +1,200 @@
# TaskTracker Web UI Redesign
**Date**: 2026-02-26
**Status**: Approved
## Overview
Ground-up redesign of the TaskTracker web UI. Replace the current vanilla JS SPA in `wwwroot/` with a React-based Kanban board application in a separate `TaskTracker.Web` project.
## Tech Stack
- **Framework**: React 19, TypeScript
- **Build**: Vite
- **Styling**: Tailwind CSS
- **Drag-and-drop**: @dnd-kit/core + @dnd-kit/sortable
- **Data fetching**: @tanstack/react-query
- **Routing**: react-router-dom
- **Charts**: Recharts
- **HTTP**: Axios
- **Icons**: lucide-react
- **Font**: Inter
Dev server on port 5173, proxies `/api` to `localhost:5200`.
## Project Structure
```
TaskTracker.Web/
├── index.html
├── package.json
├── vite.config.ts
├── tailwind.config.ts
├── tsconfig.json
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/
│ │ ├── client.ts # Axios wrapper → localhost:5200
│ │ ├── tasks.ts # useTasksQuery, useStartTask, etc.
│ │ ├── context.ts # useContextSummary, useRecentEvents
│ │ └── mappings.ts # useMappings, useCreateMapping
│ ├── components/
│ │ ├── Layout.tsx # Sidebar + top bar + content area
│ │ ├── KanbanBoard.tsx
│ │ ├── KanbanColumn.tsx
│ │ ├── TaskCard.tsx
│ │ ├── TaskModal.tsx
│ │ ├── SearchBar.tsx
│ │ └── ...
│ ├── pages/
│ │ ├── Board.tsx # Kanban board (main view)
│ │ ├── Analytics.tsx # Context tracking visualizations
│ │ ├── Mappings.tsx # App-to-category mapping management
│ │ └── Settings.tsx # (future)
│ ├── types/ # TypeScript types matching API DTOs
│ └── lib/ # Utilities, constants, theme tokens
└── public/
```
## Pages & Features
### Kanban Board (main page)
Four columns: Pending, Active, Paused, Completed.
**Drag-and-drop behavior**:
- Pending → Active: calls `PUT /tasks/{id}/start` (auto-pauses any active task)
- Active → Paused: calls `PUT /tasks/{id}/pause`
- Paused → Active: calls `PUT /tasks/{id}/resume`
- Any → Completed: calls `PUT /tasks/{id}/complete`
- Dragging to Pending from other columns is blocked
- Optimistic updates via TanStack Query; rolls back on API error
**Task card displays**:
- Title
- Category badge (color-coded)
- Elapsed time (live-updating for active task)
- Progress bar (actual vs estimated time, if estimate set)
- Subtask count indicator (e.g., "2/4 subtasks done")
- Active task gets a pulsing cyan accent border
**"+ Add Task"** button in Pending column opens quick-create form.
**Card click** opens the Task Detail Panel.
**Filter bar** below board header:
- Category filter chips
- Date range picker
- "Has subtasks" toggle
- Filters are additive (AND), shown as dismissible chips
### Task Detail Panel
Slide-over panel from the right (~400px), board visible behind (dimmed).
**Sections**:
- **Header**: Title (inline editable), status, category dropdown
- **Description**: Inline editable text area
- **Time**: Elapsed display, estimate input field, progress bar (actual/estimated)
- **Subtasks**: Checklist of child tasks. Check to complete, "+" to add new subtask (creates child task via API)
- **Notes**: Chronological list with type badges (Pause, Resume, General). "+" for inline add
- **Actions**: Context-aware buttons (Start/Pause/Resume/Complete/Abandon)
Close with Escape or click outside.
### Analytics Page
Three visualization sections, filterable by time range and task.
**Filters** (top bar):
- Time range: Today, Last 7 days, Last 30 days, Custom
- Task filter: All Tasks or specific task
**Timeline**:
- Horizontal swim lane showing app usage blocks, color-coded by category
- Task lifecycle markers (started/paused/resumed/completed) as annotations
- Hover shows app name, window title, duration
**Category Breakdown**:
- Donut chart + horizontal bar list with time and percentage per category
- Colors match category badges
**Activity Feed**:
- Reverse-chronological log of context events
- Each row: colored dot, timestamp, app name, window title/URL
- Task lifecycle events interleaved
- Paginated with "Load more"
### Mappings Page
CRUD table for app-to-category rules.
- Columns: Pattern, Match Type, Category (color badge), Friendly Name, Edit/Delete actions
- "+ Add Rule" opens inline form row at top
- Inline editing on existing rows
### Search
Global search bar in top bar, always visible.
- Client-side search over task titles and descriptions
- Results as dropdown below search bar
- Keyboard navigable (arrows, Enter, Escape)
## New Features (require API changes)
- **Time estimates**: New field on WorkTask entity for estimated duration. Progress bar = elapsed / estimated.
- **Subtask progress rollup**: Parent task cards show child completion count (may be client-side calculation from existing data).
## Visual Design
**Palette**:
- Background: `#0f1117` (deep charcoal)
- Surface/cards: `#1a1d27`
- Accent primary: `#6366f1` (electric indigo)
- Accent secondary: `#06b6d4` (vivid cyan)
- Success: `#10b981` (emerald)
- Warning: `#f59e0b` (amber)
- Danger: `#f43f5e` (rose)
- Per-category saturated colors: dev=indigo, research=cyan, comms=violet, devops=orange
**Typography**:
- Font: Inter
- Body: 13px / Medium (500)
- Labels: 11px
- Headings: 18px / Semibold (600)
**Visual details**:
- Subtle gradient on sidebar
- Colored left border on task cards (category color)
- Active task: soft glowing cyan border animation
- Column headers: subtle colored underline per status
- Smooth transitions on drag, hover, panel open/close
- Colored-tint shadows for depth
**Layout**:
- Sidebar: ~60px collapsed, ~200px expanded, icon + label nav
- Top bar: search, minimal
- Board: full remaining width, columns flex evenly
- Detail panel: ~400px, slides from right, overlays board
## API Endpoints Used
| Endpoint | Used By |
|----------|---------|
| `GET /api/tasks` | Board, Search |
| `GET /api/tasks/active` | Board (active indicator) |
| `GET /api/tasks/{id}` | Detail panel |
| `POST /api/tasks` | New task, new subtask |
| `PUT /api/tasks/{id}/start` | Drag to Active |
| `PUT /api/tasks/{id}/pause` | Drag to Paused |
| `PUT /api/tasks/{id}/resume` | Drag to Active from Paused |
| `PUT /api/tasks/{id}/complete` | Drag to Completed, subtask checkbox |
| `DELETE /api/tasks/{id}` | Abandon button |
| `GET /api/tasks/{taskId}/notes` | Detail panel notes |
| `POST /api/tasks/{taskId}/notes` | Add note |
| `GET /api/context/summary` | Analytics category breakdown |
| `GET /api/context/recent` | Analytics timeline + feed |
| `GET /api/mappings` | Mappings page, category colors |
| `POST /api/mappings` | Add mapping |
| `PUT /api/mappings/{id}` | Edit mapping |
| `DELETE /api/mappings/{id}` | Delete mapping |

View File

@@ -0,0 +1,965 @@
# TaskTracker Web UI Redesign — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the vanilla JS SPA with a React Kanban board app featuring rich analytics, subtask management, time estimates, and search/filters.
**Architecture:** Separate Vite+React project (`TaskTracker.Web`) calling the existing ASP.NET API at `localhost:5200`. Two small API changes needed first (time estimate field, task update endpoint). Frontend uses TanStack Query for server state, dnd-kit for drag-and-drop, Recharts for analytics.
**Tech Stack:** React 19, TypeScript, Vite, Tailwind CSS, @dnd-kit, @tanstack/react-query, react-router-dom, Recharts, Axios, lucide-react
**Design doc:** `docs/plans/2026-02-26-web-ui-redesign-design.md`
---
## Task 1: API — Add EstimatedMinutes field and Update endpoint
The React UI needs two things the API doesn't have yet: a time estimate field on tasks, and a general-purpose update endpoint for inline editing.
**Files:**
- Modify: `TaskTracker.Core/Entities/WorkTask.cs`
- Modify: `TaskTracker.Core/DTOs/CreateTaskRequest.cs`
- Create: `TaskTracker.Core/DTOs/UpdateTaskRequest.cs`
- Modify: `TaskTracker.Api/Controllers/TasksController.cs`
- Modify: `TaskTracker.Infrastructure/Data/AppDbContext.cs` (if migration needed)
**Step 1: Add EstimatedMinutes to WorkTask entity**
In `TaskTracker.Core/Entities/WorkTask.cs`, add:
```csharp
public int? EstimatedMinutes { get; set; }
```
**Step 2: Add EstimatedMinutes to CreateTaskRequest**
In `TaskTracker.Core/DTOs/CreateTaskRequest.cs`, add:
```csharp
public int? EstimatedMinutes { get; set; }
```
**Step 3: Create UpdateTaskRequest DTO**
Create `TaskTracker.Core/DTOs/UpdateTaskRequest.cs`:
```csharp
namespace TaskTracker.Core.DTOs;
public class UpdateTaskRequest
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
public int? EstimatedMinutes { get; set; }
}
```
**Step 4: Wire EstimatedMinutes in TasksController.Create**
In `TasksController.cs`, update the `Create` method's `new WorkTask` block to include:
```csharp
EstimatedMinutes = request.EstimatedMinutes,
```
**Step 5: Add PUT update endpoint to TasksController**
Add to `TasksController.cs`:
```csharp
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaskRequest request)
{
var task = await taskRepo.GetByIdAsync(id);
if (task is null)
return NotFound(ApiResponse.Fail("Task not found"));
if (request.Title is not null) task.Title = request.Title;
if (request.Description is not null) task.Description = request.Description;
if (request.Category is not null) task.Category = request.Category;
if (request.EstimatedMinutes.HasValue) task.EstimatedMinutes = request.EstimatedMinutes;
await taskRepo.UpdateAsync(task);
return Ok(ApiResponse<WorkTask>.Ok(task));
}
```
**Step 6: Create and apply EF migration**
Run:
```bash
cd TaskTracker.Infrastructure
dotnet ef migrations add AddEstimatedMinutes --startup-project ../TaskTracker.Api
dotnet ef database update --startup-project ../TaskTracker.Api
```
**Step 7: Verify with Swagger**
Run the API (`dotnet run --project TaskTracker.Api`) and test the new PUT endpoint at `/swagger`.
**Step 8: Commit**
```bash
git add TaskTracker.Core/Entities/WorkTask.cs TaskTracker.Core/DTOs/CreateTaskRequest.cs TaskTracker.Core/DTOs/UpdateTaskRequest.cs TaskTracker.Api/Controllers/TasksController.cs TaskTracker.Infrastructure/
git commit -m "feat: add EstimatedMinutes field and general PUT update endpoint for tasks"
```
---
## Task 2: Scaffold React project with Vite + Tailwind
**Files:**
- Create: `TaskTracker.Web/` (entire project scaffold)
**Step 1: Create Vite React TypeScript project**
```bash
cd C:/Users/AJ/Desktop/Projects/TaskTracker
npm create vite@latest TaskTracker.Web -- --template react-ts
```
**Step 2: Install dependencies**
```bash
cd TaskTracker.Web
npm install axios @tanstack/react-query react-router-dom @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities recharts lucide-react
npm install -D tailwindcss @tailwindcss/vite
```
**Step 3: Configure Tailwind**
Replace `TaskTracker.Web/src/index.css` with:
```css
@import "tailwindcss";
```
Add Tailwind plugin to `TaskTracker.Web/vite.config.ts`:
```typescript
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,
},
},
},
})
```
**Step 4: Clean up scaffold**
- Delete `src/App.css`, `src/assets/`
- Replace `src/App.tsx` with a minimal placeholder:
```tsx
function App() {
return <div className="bg-[#0f1117] min-h-screen text-white p-8">
<h1 className="text-2xl font-semibold">TaskTracker</h1>
</div>
}
export default App
```
- Replace `src/main.tsx`:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
```
**Step 5: Verify it runs**
```bash
npm run dev
```
Open `http://localhost:5173` — should see "TaskTracker" on a dark background.
**Step 6: Commit**
```bash
git add TaskTracker.Web/
git commit -m "feat: scaffold TaskTracker.Web with Vite, React, TypeScript, Tailwind"
```
---
## Task 3: TypeScript types + API client + TanStack Query provider
**Files:**
- Create: `TaskTracker.Web/src/types/index.ts`
- Create: `TaskTracker.Web/src/api/client.ts`
- Create: `TaskTracker.Web/src/api/tasks.ts`
- Create: `TaskTracker.Web/src/api/context.ts`
- Create: `TaskTracker.Web/src/api/mappings.ts`
- Modify: `TaskTracker.Web/src/main.tsx`
**Step 1: Create TypeScript types matching the API**
Create `TaskTracker.Web/src/types/index.ts`:
```typescript
export enum WorkTaskStatus {
Pending = 0,
Active = 1,
Paused = 2,
Completed = 3,
Abandoned = 4,
}
export enum NoteType {
PauseNote = 0,
ResumeNote = 1,
General = 2,
}
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
}
```
**Step 2: Create Axios client**
Create `TaskTracker.Web/src/api/client.ts`:
```typescript
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
```
**Step 3: Create task API hooks**
Create `TaskTracker.Web/src/api/tasks.ts`:
```typescript
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,
})
}
```
**Step 4: Create context API hooks**
Create `TaskTracker.Web/src/api/context.ts`:
```typescript
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,
})
}
```
**Step 5: Create mappings API hooks**
Create `TaskTracker.Web/src/api/mappings.ts`:
```typescript
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'] }),
})
}
```
**Step 6: Add QueryClientProvider to main.tsx**
```tsx
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>,
)
```
**Step 7: Verify compilation**
```bash
cd TaskTracker.Web && npm run build
```
Expected: clean build with no errors.
**Step 8: Commit**
```bash
git add TaskTracker.Web/src/types/ TaskTracker.Web/src/api/ TaskTracker.Web/src/main.tsx
git commit -m "feat: add TypeScript types, API client, and TanStack Query hooks"
```
---
## Task 4: Layout shell — sidebar, top bar, routing
**Files:**
- Create: `TaskTracker.Web/src/components/Layout.tsx`
- Create: `TaskTracker.Web/src/pages/Board.tsx` (placeholder)
- Create: `TaskTracker.Web/src/pages/Analytics.tsx` (placeholder)
- Create: `TaskTracker.Web/src/pages/Mappings.tsx` (placeholder)
- Modify: `TaskTracker.Web/src/App.tsx`
**Step 1: Create Layout component**
Create `TaskTracker.Web/src/components/Layout.tsx` with:
- Collapsible sidebar (60px collapsed, 200px expanded) with gradient background
- Nav items: Board (LayoutGrid icon), Analytics (BarChart3 icon), Mappings (Link icon)
- Top bar with app title and SearchBar placeholder
- `<Outlet />` for page content
- Use `lucide-react` for icons
- Use `react-router-dom` `NavLink` with active styling (indigo highlight)
**Step 2: Create placeholder pages**
Each page as a simple component with just a heading, e.g.:
```tsx
export default function Board() {
return <div><h1 className="text-xl font-semibold text-white">Board</h1></div>
}
```
**Step 3: Wire up routing in App.tsx**
```tsx
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>
)
}
```
**Step 4: Verify navigation works**
Run `npm run dev`. Click sidebar links — URL changes and placeholder pages render.
**Step 5: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add layout shell with sidebar navigation and routing"
```
---
## Task 5: Kanban board — columns, cards, drag-and-drop
This is the core feature. Build it incrementally.
**Files:**
- Create: `TaskTracker.Web/src/lib/constants.ts` (colors, status labels)
- Create: `TaskTracker.Web/src/components/KanbanColumn.tsx`
- Create: `TaskTracker.Web/src/components/TaskCard.tsx`
- Create: `TaskTracker.Web/src/components/KanbanBoard.tsx`
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
**Step 1: Create constants for status/category config**
Create `TaskTracker.Web/src/lib/constants.ts`:
```typescript
import { WorkTaskStatus } from '../types'
export const COLUMN_CONFIG = [
{ status: WorkTaskStatus.Pending, label: 'Pending', color: '#94a3b8' },
{ status: WorkTaskStatus.Active, label: 'Active', color: '#06b6d4' },
{ status: WorkTaskStatus.Paused, label: 'Paused', color: '#f59e0b' },
{ status: WorkTaskStatus.Completed, label: 'Completed', color: '#10b981' },
] as const
export const CATEGORY_COLORS: Record<string, string> = {
Development: '#6366f1',
Research: '#06b6d4',
Communication: '#8b5cf6',
DevOps: '#f97316',
Documentation: '#14b8a6',
Design: '#ec4899',
Unknown: '#64748b',
}
```
**Step 2: Build TaskCard component**
Create `TaskTracker.Web/src/components/TaskCard.tsx`:
- Renders task title, category badge (color-coded from `CATEGORY_COLORS`), elapsed time
- If `estimatedMinutes` set, show progress bar (elapsed / estimated)
- If task has subtasks, show "N/M done" indicator
- Active task gets a pulsing cyan border (`animate-pulse` or custom keyframe)
- Colored left border matching category
- Uses `useSortable` from dnd-kit for drag handle
- `onClick` prop to open detail panel
**Step 3: Build KanbanColumn component**
Create `TaskTracker.Web/src/components/KanbanColumn.tsx`:
- Column header with status label + count + colored underline
- Uses `useDroppable` from dnd-kit
- Renders list of `TaskCard` components
- "Pending" column has "+ Add Task" button at bottom
- Visual drop indicator when dragging over
**Step 4: Build KanbanBoard component**
Create `TaskTracker.Web/src/components/KanbanBoard.tsx`:
- `DndContext` + `SortableContext` from dnd-kit wrapping the columns
- `useTasks()` hook to fetch all tasks
- Groups tasks by status into columns
- `onDragEnd` handler that:
- Determines source/target column
- Maps column transitions to API calls (start/pause/resume/complete)
- Blocks invalid transitions (e.g., moving to Pending)
- Shows error toast on failure
- Filters out subtasks from board (only show top-level tasks, `parentTaskId === null`)
**Step 5: Wire Board page**
Update `TaskTracker.Web/src/pages/Board.tsx` to render `<KanbanBoard />` and pass an `onTaskClick` callback that opens the detail panel (wired in Task 6).
**Step 6: Verify board renders with real data**
Start the API (`dotnet run --project TaskTracker.Api`), then `npm run dev`. Create a few tasks via Swagger, verify they show in the correct columns. Test drag-and-drop transitions.
**Step 7: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: implement Kanban board with drag-and-drop task management"
```
---
## Task 6: Task detail slide-over panel
**Files:**
- Create: `TaskTracker.Web/src/components/TaskDetailPanel.tsx`
- Create: `TaskTracker.Web/src/components/SubtaskList.tsx`
- Create: `TaskTracker.Web/src/components/NotesList.tsx`
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
**Step 1: Build TaskDetailPanel**
Create `TaskTracker.Web/src/components/TaskDetailPanel.tsx`:
- Slide-over panel from right (~400px), board dimmed behind with semi-transparent overlay
- Fetches task detail via `useTask(id)`
- Sections:
- **Header**: Title (click to edit inline, blur to save via `useUpdateTask`), status badge, category dropdown
- **Description**: Click-to-edit text area, saves on blur
- **Time**: Show elapsed time. Input for estimated minutes, saves on blur. Progress bar if estimate set.
- **Subtasks**: `<SubtaskList />` component
- **Notes**: `<NotesList />` component
- **Actions**: Context-aware buttons — Start/Pause/Resume/Complete/Abandon based on current status
- Close on Escape keypress or overlay click
- Animate slide-in/out with CSS transition
**Step 2: Build SubtaskList**
Create `TaskTracker.Web/src/components/SubtaskList.tsx`:
- Renders `task.subTasks` as checkboxes
- Checking a subtask calls `useCompleteTask()` on it
- "+" button at top shows inline text input → `useCreateTask()` with `parentTaskId`
- Shows subtask status (completed = strikethrough + checkmark)
**Step 3: Build NotesList**
Create `TaskTracker.Web/src/components/NotesList.tsx`:
- Renders `task.notes` chronologically
- Each note shows: type badge (PauseNote/ResumeNote/General), content, relative timestamp
- "+" button shows inline text input → `POST /api/tasks/{id}/notes` with type General
**Step 4: Wire panel into Board page**
In `Board.tsx`:
- Add `selectedTaskId` state
- Pass `onTaskClick={(id) => setSelectedTaskId(id)}` to `KanbanBoard`
- Render `<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />` when set
**Step 5: Verify full workflow**
Test: click card → panel opens → edit title → add subtask → add note → change status → close panel. All changes persist.
**Step 6: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add task detail slide-over panel with inline editing, subtasks, and notes"
```
---
## Task 7: Create task form
**Files:**
- Create: `TaskTracker.Web/src/components/CreateTaskForm.tsx`
- Modify: `TaskTracker.Web/src/components/KanbanColumn.tsx`
**Step 1: Build CreateTaskForm**
Create `TaskTracker.Web/src/components/CreateTaskForm.tsx`:
- Inline form that expands in the Pending column when "+ Add Task" is clicked
- Fields: Title (required), Description (optional textarea), Category (dropdown from known categories), Estimated Minutes (optional number)
- Submit calls `useCreateTask()`
- Escape or click away cancels
- Auto-focus title input on open
**Step 2: Wire into Pending column**
Modify `KanbanColumn.tsx`:
- When column is Pending, show "+ Add Task" button
- On click, toggle showing `<CreateTaskForm />` at bottom of column
- On submit/cancel, hide the form
**Step 3: Verify**
Create a task via the inline form. It should appear in the Pending column immediately.
**Step 4: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add inline create task form in Pending column"
```
---
## Task 8: Search bar + board filters
**Files:**
- Create: `TaskTracker.Web/src/components/SearchBar.tsx`
- Create: `TaskTracker.Web/src/components/FilterBar.tsx`
- Modify: `TaskTracker.Web/src/components/Layout.tsx`
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
**Step 1: Build SearchBar**
Create `TaskTracker.Web/src/components/SearchBar.tsx`:
- Input with search icon (lucide `Search`)
- On typing, filters tasks client-side (title + description match)
- Results shown as dropdown list of matching tasks
- Click result → opens TaskDetailPanel (via callback prop)
- Keyboard navigation: arrow keys to move, Enter to select, Escape to close
- Debounce input by 200ms
**Step 2: Build FilterBar**
Create `TaskTracker.Web/src/components/FilterBar.tsx`:
- Row of filter chips rendered below the board header
- Category chips: derived from unique categories across all tasks + mappings
- "Has subtasks" toggle chip
- Active filters shown as colored chips with "x" to dismiss
- Exposes `filters` state and `filteredTasks` computation to parent
**Step 3: Wire SearchBar into Layout**
Add `<SearchBar />` to the top bar in `Layout.tsx`. It needs access to task data — use `useTasks()` inside the component.
**Step 4: Wire FilterBar into Board page**
In `Board.tsx`, add `<FilterBar />` above the `<KanbanBoard />`. Pass filtered tasks down to the board instead of all tasks. Lift task fetching up to `Board.tsx` and pass tasks as props to both `FilterBar` and `KanbanBoard`.
**Step 5: Verify**
- Type in search bar → dropdown shows matching tasks
- Click category chip → board filters to that category
- Combine filters → board shows intersection
**Step 6: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add global search bar and board filter chips"
```
---
## Task 9: Analytics page — timeline, charts, activity feed
**Files:**
- Create: `TaskTracker.Web/src/components/analytics/Timeline.tsx`
- Create: `TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx`
- Create: `TaskTracker.Web/src/components/analytics/ActivityFeed.tsx`
- Modify: `TaskTracker.Web/src/pages/Analytics.tsx`
**Step 1: Build Analytics page shell**
Update `TaskTracker.Web/src/pages/Analytics.tsx`:
- Filter bar at top: time range dropdown (Today/7d/30d), task filter dropdown
- Time range maps to `minutes` param for `/context/recent` (1440 for today, 10080 for 7d, 43200 for 30d)
- Three sections stacked vertically: Timeline, Category Breakdown, Activity Feed
**Step 2: Build Timeline component**
Create `TaskTracker.Web/src/components/analytics/Timeline.tsx`:
- Recharts `BarChart` with custom rendering
- X-axis: time of day (hourly buckets)
- Bars: colored by category (from mappings)
- Tooltip on hover: app name, window title, duration
- Uses `useRecentContext()` data, groups events into time buckets
**Step 3: Build CategoryBreakdown component**
Create `TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx`:
- Left side: Recharts `PieChart` (donut style with inner radius)
- Right side: list of categories with horizontal bar, time, percentage
- Uses `useContextSummary()` data
- Colors from `CATEGORY_COLORS` constant
**Step 4: Build ActivityFeed component**
Create `TaskTracker.Web/src/components/analytics/ActivityFeed.tsx`:
- Reverse-chronological list of context events
- Each row: colored category dot, relative timestamp, app name (bold), window title/URL
- "Load more" button at bottom (increase `minutes` param or paginate client-side)
- Uses `useRecentContext()` with the selected time range
**Step 5: Verify analytics page**
Need some context data — either run WindowWatcher/Chrome extension to generate real data, or POST a few test events via Swagger. Verify all three visualizations render.
**Step 6: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add analytics page with timeline, category breakdown, and activity feed"
```
---
## Task 10: Mappings page
**Files:**
- Modify: `TaskTracker.Web/src/pages/Mappings.tsx`
**Step 1: Build Mappings page**
Update `TaskTracker.Web/src/pages/Mappings.tsx`:
- Table with columns: Pattern, Match Type, Category (color badge), Friendly Name, Actions (edit/delete)
- Uses `useMappings()` to fetch data
- "+ Add Rule" button at top → inserts inline form row at top of table
- Form fields: Pattern (text), Match Type (dropdown: ProcessName/TitleContains/UrlContains), Category (text), Friendly Name (text)
- Submit → `useCreateMapping()`
- Edit button → row becomes editable inline → save → `useUpdateMapping()`
- Delete button → confirm dialog → `useDeleteMapping()`
**Step 2: Verify CRUD**
Create a mapping, edit it, delete it. Verify all operations work.
**Step 3: Commit**
```bash
git add TaskTracker.Web/src/
git commit -m "feat: add mappings page with inline CRUD table"
```
---
## Task 11: Visual polish and animations
**Files:**
- Modify: Various component files for animation/transition polish
- Modify: `TaskTracker.Web/index.html` (add Inter font)
**Step 1: Add Inter font**
In `TaskTracker.Web/index.html`, add to `<head>`:
```html
<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">
```
Add to `index.css` (inside `@theme`):
```css
@theme {
--font-sans: 'Inter', sans-serif;
}
```
**Step 2: Add animations**
- Active task card: glowing cyan border pulse (CSS `@keyframes` animation)
- Detail panel: slide-in from right with opacity transition
- Drag-and-drop: smooth card movement via dnd-kit's built-in transitions
- Hover effects: subtle card lift with colored shadow
- Column drop target: border highlight on drag-over
**Step 3: Polish visual details**
- Sidebar gradient background
- Colored left border on task cards
- Category badge colors consistent everywhere
- Column header colored underlines
- Colored-tint shadows on cards (not plain gray)
- Consistent spacing and typography
**Step 4: Verify visual quality**
Walk through every page and interaction. Check dark background rendering, color contrast, animation smoothness.
**Step 5: Commit**
```bash
git add TaskTracker.Web/
git commit -m "feat: add visual polish — Inter font, animations, colored shadows, hover effects"
```
---
## Task 12: Final integration testing and cleanup
**Step 1: Full workflow test**
Run both the API and the React dev server. Walk through the complete workflow:
1. Create a task from the board
2. Start it (drag or button)
3. Add a subtask, complete it
4. Add a note
5. Set time estimate, verify progress bar
6. Pause with note, resume
7. Complete the task
8. Check analytics page
9. Add/edit/delete a mapping
10. Test search and filters
11. Test sidebar collapse/expand
**Step 2: Fix any issues found**
Address bugs or visual issues found during testing.
**Step 3: Clean up**
- Remove any unused imports or dead code
- Ensure no console warnings in browser
- Verify `npm run build` produces a clean production build
**Step 4: Commit**
```bash
git add -A
git commit -m "chore: final cleanup and integration testing"
```