commit e12f78c4794690412898354fdb5b2d5527677769 Author: AJ Isaacs Date: Thu Feb 26 22:08:45 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6edf483 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.vs/ +bin/ +obj/ +node_modules/ +*.user +*.suo +*.db +*.db-shm +*.db-wal +dist/ diff --git a/ChromeExtension/background.js b/ChromeExtension/background.js new file mode 100644 index 0000000..24cb3d4 --- /dev/null +++ b/ChromeExtension/background.js @@ -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); + } +}); diff --git a/ChromeExtension/manifest.json b/ChromeExtension/manifest.json new file mode 100644 index 0000000..7d607be --- /dev/null +++ b/ChromeExtension/manifest.json @@ -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/*" + ] +} diff --git a/ChromeExtension/popup.html b/ChromeExtension/popup.html new file mode 100644 index 0000000..acc3c2b --- /dev/null +++ b/ChromeExtension/popup.html @@ -0,0 +1,60 @@ + + + + + + + +

Work Context Tracker

+
Loading...
+
+ + + diff --git a/ChromeExtension/popup.js b/ChromeExtension/popup.js new file mode 100644 index 0000000..5f56ec3 --- /dev/null +++ b/ChromeExtension/popup.js @@ -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 = '

No active task

'; + 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 = ` +
${escapeHtml(task.title)}
+ ${task.status} + ${task.category ? `
Category: ${escapeHtml(task.category)}
` : ""} + + `; + + 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(); diff --git a/TaskTracker.Api/Controllers/ContextController.cs b/TaskTracker.Api/Controllers/ContextController.cs new file mode 100644 index 0000000..0ee5c2a --- /dev/null +++ b/TaskTracker.Api/Controllers/ContextController.cs @@ -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 logger) : ControllerBase +{ + [HttpPost] + public async Task 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.Ok(created)); + } + + [HttpGet("recent")] + public async Task GetRecent([FromQuery] int minutes = 30) + { + var events = await contextRepo.GetRecentAsync(minutes); + return Ok(ApiResponse>.Ok(events)); + } + + [HttpGet("summary")] + public async Task 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>.Ok(summary)); + } +} diff --git a/TaskTracker.Api/Controllers/MappingsController.cs b/TaskTracker.Api/Controllers/MappingsController.cs new file mode 100644 index 0000000..4b3a04b --- /dev/null +++ b/TaskTracker.Api/Controllers/MappingsController.cs @@ -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 GetAll() + { + var mappings = await mappingRepo.GetAllAsync(); + return Ok(ApiResponse>.Ok(mappings)); + } + + [HttpPost] + public async Task 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.Ok(created)); + } + + [HttpPut("{id:int}")] + public async Task 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.Ok(mapping)); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await mappingRepo.DeleteAsync(id); + return Ok(ApiResponse.Ok()); + } +} diff --git a/TaskTracker.Api/Controllers/NotesController.cs b/TaskTracker.Api/Controllers/NotesController.cs new file mode 100644 index 0000000..fddee89 --- /dev/null +++ b/TaskTracker.Api/Controllers/NotesController.cs @@ -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 GetNotes(int taskId) + { + var task = await taskRepo.GetByIdAsync(taskId); + if (task is null) + return NotFound(ApiResponse.Fail("Task not found")); + + return Ok(ApiResponse>.Ok(task.Notes)); + } + + [HttpPost] + public async Task 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.Ok(note)); + } +} diff --git a/TaskTracker.Api/Controllers/TasksController.cs b/TaskTracker.Api/Controllers/TasksController.cs new file mode 100644 index 0000000..f68ff9e --- /dev/null +++ b/TaskTracker.Api/Controllers/TasksController.cs @@ -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 logger) : ControllerBase +{ + [HttpGet] + public async Task GetAll([FromQuery] WorkTaskStatus? status, [FromQuery] int? parentId, [FromQuery] bool includeSubTasks = false) + { + var tasks = await taskRepo.GetAllAsync(status, parentId, includeSubTasks); + return Ok(ApiResponse>.Ok(tasks)); + } + + [HttpGet("active")] + public async Task GetActive() + { + var task = await taskRepo.GetActiveTaskAsync(); + if (task is null) + return Ok(ApiResponse.Ok(null)); + return Ok(ApiResponse.Ok(task)); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var task = await taskRepo.GetByIdAsync(id); + if (task is null) + return NotFound(ApiResponse.Fail("Task not found")); + return Ok(ApiResponse.Ok(task)); + } + + [HttpPost] + public async Task 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.Ok(created)); + } + + [HttpPut("{id:int}/start")] + public async Task 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.Ok(task)); + } + + [HttpPut("{id:int}/pause")] + public async Task 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.Ok(task)); + } + + [HttpPut("{id:int}/resume")] + public async Task 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.Ok(task)); + } + + [HttpPut("{id:int}/complete")] + public async Task 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.Ok(task)); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await taskRepo.DeleteAsync(id); + logger.LogInformation("Abandoned task {TaskId}", id); + return Ok(ApiResponse.Ok()); + } +} diff --git a/TaskTracker.Api/Program.cs b/TaskTracker.Api/Program.cs new file mode 100644 index 0000000..9b215ac --- /dev/null +++ b/TaskTracker.Api/Program.cs @@ -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(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Repositories +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// 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(); + db.Database.Migrate(); +} + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseCors(); +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapControllers(); + +app.Run(); diff --git a/TaskTracker.Api/Properties/launchSettings.json b/TaskTracker.Api/Properties/launchSettings.json new file mode 100644 index 0000000..d1028d4 --- /dev/null +++ b/TaskTracker.Api/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/TaskTracker.Api/TaskTracker.Api.csproj b/TaskTracker.Api/TaskTracker.Api.csproj new file mode 100644 index 0000000..605cb23 --- /dev/null +++ b/TaskTracker.Api/TaskTracker.Api.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net10.0 + enable + enable + + + diff --git a/TaskTracker.Api/TaskTracker.Api.http b/TaskTracker.Api/TaskTracker.Api.http new file mode 100644 index 0000000..d6eab06 --- /dev/null +++ b/TaskTracker.Api/TaskTracker.Api.http @@ -0,0 +1,6 @@ +@TaskTracker.Api_HostAddress = http://localhost:5125 + +GET {{TaskTracker.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/TaskTracker.Api/appsettings.Development.json b/TaskTracker.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/TaskTracker.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/TaskTracker.Api/appsettings.json b/TaskTracker.Api/appsettings.json new file mode 100644 index 0000000..996f643 --- /dev/null +++ b/TaskTracker.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost\\SQLEXPRESS;Database=WorkContextTracker;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/TaskTracker.Api/wwwroot/css/app.css b/TaskTracker.Api/wwwroot/css/app.css new file mode 100644 index 0000000..305de4c --- /dev/null +++ b/TaskTracker.Api/wwwroot/css/app.css @@ -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); } diff --git a/TaskTracker.Api/wwwroot/index.html b/TaskTracker.Api/wwwroot/index.html new file mode 100644 index 0000000..1921029 --- /dev/null +++ b/TaskTracker.Api/wwwroot/index.html @@ -0,0 +1,38 @@ + + + + + + TaskTracker + + + + + + + + diff --git a/TaskTracker.Api/wwwroot/js/api.js b/TaskTracker.Api/wwwroot/js/api.js new file mode 100644 index 0000000..bc8a5e0 --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/api.js @@ -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' }), +}; diff --git a/TaskTracker.Api/wwwroot/js/app.js b/TaskTracker.Api/wwwroot/js/app.js new file mode 100644 index 0000000..cecf942 --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/app.js @@ -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(); diff --git a/TaskTracker.Api/wwwroot/js/components/modal.js b/TaskTracker.Api/wwwroot/js/components/modal.js new file mode 100644 index 0000000..880b3d3 --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/components/modal.js @@ -0,0 +1,33 @@ +const overlay = document.getElementById('modal-overlay'); + +export function showModal(title, contentHtml, actions = []) { + overlay.innerHTML = ` + `; + 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(); +} diff --git a/TaskTracker.Api/wwwroot/js/pages/context.js b/TaskTracker.Api/wwwroot/js/pages/context.js new file mode 100644 index 0000000..71579ea --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/pages/context.js @@ -0,0 +1,91 @@ +import * as api from '../api.js'; + +const el = () => document.getElementById('page-context'); + +export async function initContext() { + el().innerHTML = ` +

Context

+
App Summary (8 hours)
+
+
+
Recent Events
+ +
+
`; + + 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 = `
No activity recorded
`; + return; + } + container.innerHTML = ` + + + + ${summary.map(s => ` + + + + + + + `).join('')} + +
ApplicationCategoryEventsFirst SeenLast Seen
${esc(s.appName)}${esc(s.category)}${s.eventCount}${formatTime(s.firstSeen)}${formatTime(s.lastSeen)}
`; + } catch (e) { + document.getElementById('ctx-summary').innerHTML = `
Failed to load summary
`; + } +} + +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 = `
No recent events
`; + return; + } + container.innerHTML = ` + + + + ${events.map(e => ` + + + + + + + `).join('')} + +
SourceAppWindow TitleURLTime
${esc(e.source)}${esc(e.appName)}${esc(e.windowTitle)}${e.url ? esc(e.url) : '-'}${formatTime(e.timestamp)}
`; + } catch (e) { + document.getElementById('ctx-events').innerHTML = `
Failed to load events
`; + } +} + +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; +} diff --git a/TaskTracker.Api/wwwroot/js/pages/dashboard.js b/TaskTracker.Api/wwwroot/js/pages/dashboard.js new file mode 100644 index 0000000..ade244b --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/pages/dashboard.js @@ -0,0 +1,127 @@ +import * as api from '../api.js'; + +const el = () => document.getElementById('page-dashboard'); + +export function initDashboard() { + el().innerHTML = ` +

Dashboard

+
+
Task Summary
+
+
Recent Activity (8 hours)
+
`; +} + +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 = `
No active task
`; + return; + } + + const parentTrail = await buildParentTrail(task); + const breadcrumbHtml = parentTrail.length > 0 + ? `` + : ''; + + const elapsed = task.startedAt ? formatElapsed(new Date(task.startedAt)) : ''; + container.innerHTML = ` +
+
+
+
${esc(task.title)}
+ ${breadcrumbHtml} + ${task.description ? `
${esc(task.description)}
` : ''} + ${task.category ? `
Category: ${esc(task.category)}
` : ''} + ${elapsed ? `
Active for ${elapsed}
` : ''} +
+
+ + +
+
+
`; + 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]) => ` +
+
${count}
+
${status}
+
`).join(''); +} + +function renderContextSummary(summary) { + const container = document.getElementById('dash-context'); + if (!summary || summary.length === 0) { + container.innerHTML = `
No recent activity
`; + return; + } + container.innerHTML = summary.slice(0, 10).map(item => ` +
+
+
${esc(item.appName)}
+
${esc(item.category)}
+
+
${item.eventCount}
+
`).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; +} diff --git a/TaskTracker.Api/wwwroot/js/pages/mappings.js b/TaskTracker.Api/wwwroot/js/pages/mappings.js new file mode 100644 index 0000000..86e15eb --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/pages/mappings.js @@ -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 = ` +

App Mappings

+
+
Map process names, window titles, or URLs to categories
+ +
+
`; + + 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 = `
No mappings configured
`; + return; + } + container.innerHTML = ` + + + + ${mappings.map(m => ` + + + + + + + `).join('')} + +
PatternMatch TypeCategoryFriendly NameActions
${esc(m.pattern)}${m.matchType}${esc(m.category)}${esc(m.friendlyName) || '-'} +
+ + +
+
`; + 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 = `
Failed to load mappings
`; + } +} + +function showMappingForm(existing = null) { + const title = existing ? 'Edit Mapping' : 'New Mapping'; + showModal(title, ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
`, + [ + { 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', `

Are you sure you want to delete this mapping?

`, [ + { 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; +} diff --git a/TaskTracker.Api/wwwroot/js/pages/tasks.js b/TaskTracker.Api/wwwroot/js/pages/tasks.js new file mode 100644 index 0000000..c015247 --- /dev/null +++ b/TaskTracker.Api/wwwroot/js/pages/tasks.js @@ -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 = ` +

Tasks

+
+
+ +
+
+ `; + + 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) => ` + `).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 = `
Failed to load tasks
`; + } +} + +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 = `
No tasks found
`; + return; + } + container.innerHTML = tasks.map(t => { + const subCount = t.subTasks ? t.subTasks.length : 0; + return ` +
+
+ ${t.status} + ${esc(t.title)} + ${subCount > 0 ? `${subCount} subtask${subCount !== 1 ? 's' : ''}` : ''} +
+
${t.category ? esc(t.category) + ' · ' : ''}${formatDate(t.createdAt)}
+
`; + }).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 + ? `` + : ''; + + container.innerHTML = ` + + ${breadcrumbHtml} +
+
+
+
${esc(task.title)}
+ ${task.status} +
+
+
+ ${task.description ? `

${esc(task.description)}

` : ''} +
+
Category
${esc(task.category) || 'None'}
+
Created
${formatDateTime(task.createdAt)}
+
Started
${task.startedAt ? formatDateTime(task.startedAt) : 'Not started'}
+
Completed
${task.completedAt ? formatDateTime(task.completedAt) : '-'}
+
+
Subtasks
+
+ ${task.status !== 'Completed' && task.status !== 'Abandoned' ? `` : ''} +
Notes
+
+
+
+ +
+ +
+ ${task.contextEvents && task.contextEvents.length ? ` +
Linked Context Events
+
+ + + + ${task.contextEvents.slice(0, 50).map(e => ` + + + + + `).join('')} + +
AppTitleTime
${esc(e.appName)}${esc(e.windowTitle)}${formatTime(e.timestamp)}
+
` : ''} +
`; + + // 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 = `
No subtasks
`; + 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 ` +
+
+ ${st.status} + ${esc(st.title)} + ${subCount > 0 ? `${subCount}` : ''} +
+
+ ${canStart ? `` : ''} + ${canComplete ? `` : ''} +
+
`; + }).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 = `
No notes yet
`; + return; + } + container.innerHTML = notes.map(n => ` +
+
+ ${n.type} + ${formatDateTime(n.createdAt)} +
+
${esc(n.content)}
+
`).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, ` +
+ + +
+
+ + +
+
+ + +
`, + [ + { 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; +} diff --git a/TaskTracker.Core/DTOs/ApiResponse.cs b/TaskTracker.Core/DTOs/ApiResponse.cs new file mode 100644 index 0000000..7c84aa2 --- /dev/null +++ b/TaskTracker.Core/DTOs/ApiResponse.cs @@ -0,0 +1,20 @@ +namespace TaskTracker.Core.DTOs; + +public class ApiResponse +{ + public bool Success { get; set; } + public T? Data { get; set; } + public string? Error { get; set; } + + public static ApiResponse Ok(T data) => new() { Success = true, Data = data }; + public static ApiResponse 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 }; +} diff --git a/TaskTracker.Core/DTOs/ContextEventRequest.cs b/TaskTracker.Core/DTOs/ContextEventRequest.cs new file mode 100644 index 0000000..44e9242 --- /dev/null +++ b/TaskTracker.Core/DTOs/ContextEventRequest.cs @@ -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; } +} diff --git a/TaskTracker.Core/DTOs/ContextSummaryItem.cs b/TaskTracker.Core/DTOs/ContextSummaryItem.cs new file mode 100644 index 0000000..25ae8e6 --- /dev/null +++ b/TaskTracker.Core/DTOs/ContextSummaryItem.cs @@ -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; } +} diff --git a/TaskTracker.Core/DTOs/CreateAppMappingRequest.cs b/TaskTracker.Core/DTOs/CreateAppMappingRequest.cs new file mode 100644 index 0000000..9b83c1d --- /dev/null +++ b/TaskTracker.Core/DTOs/CreateAppMappingRequest.cs @@ -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; } +} diff --git a/TaskTracker.Core/DTOs/CreateNoteRequest.cs b/TaskTracker.Core/DTOs/CreateNoteRequest.cs new file mode 100644 index 0000000..82fdade --- /dev/null +++ b/TaskTracker.Core/DTOs/CreateNoteRequest.cs @@ -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; +} diff --git a/TaskTracker.Core/DTOs/CreateTaskRequest.cs b/TaskTracker.Core/DTOs/CreateTaskRequest.cs new file mode 100644 index 0000000..aaf6cb8 --- /dev/null +++ b/TaskTracker.Core/DTOs/CreateTaskRequest.cs @@ -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; } +} diff --git a/TaskTracker.Core/DTOs/TaskActionRequest.cs b/TaskTracker.Core/DTOs/TaskActionRequest.cs new file mode 100644 index 0000000..0d030fc --- /dev/null +++ b/TaskTracker.Core/DTOs/TaskActionRequest.cs @@ -0,0 +1,6 @@ +namespace TaskTracker.Core.DTOs; + +public class TaskActionRequest +{ + public string? Note { get; set; } +} diff --git a/TaskTracker.Core/Entities/AppMapping.cs b/TaskTracker.Core/Entities/AppMapping.cs new file mode 100644 index 0000000..398dbb0 --- /dev/null +++ b/TaskTracker.Core/Entities/AppMapping.cs @@ -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; } +} diff --git a/TaskTracker.Core/Entities/ContextEvent.cs b/TaskTracker.Core/Entities/ContextEvent.cs new file mode 100644 index 0000000..bc4a1a5 --- /dev/null +++ b/TaskTracker.Core/Entities/ContextEvent.cs @@ -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; } +} diff --git a/TaskTracker.Core/Entities/TaskNote.cs b/TaskTracker.Core/Entities/TaskNote.cs new file mode 100644 index 0000000..6a94b8e --- /dev/null +++ b/TaskTracker.Core/Entities/TaskNote.cs @@ -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!; +} diff --git a/TaskTracker.Core/Entities/WorkTask.cs b/TaskTracker.Core/Entities/WorkTask.cs new file mode 100644 index 0000000..9038c5c --- /dev/null +++ b/TaskTracker.Core/Entities/WorkTask.cs @@ -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 SubTasks { get; set; } = new(); + + public List Notes { get; set; } = []; + public List ContextEvents { get; set; } = []; +} diff --git a/TaskTracker.Core/Enums/NoteType.cs b/TaskTracker.Core/Enums/NoteType.cs new file mode 100644 index 0000000..e5e4ec8 --- /dev/null +++ b/TaskTracker.Core/Enums/NoteType.cs @@ -0,0 +1,8 @@ +namespace TaskTracker.Core.Enums; + +public enum NoteType +{ + PauseNote, + ResumeNote, + General +} diff --git a/TaskTracker.Core/Enums/TaskStatus.cs b/TaskTracker.Core/Enums/TaskStatus.cs new file mode 100644 index 0000000..383abd2 --- /dev/null +++ b/TaskTracker.Core/Enums/TaskStatus.cs @@ -0,0 +1,10 @@ +namespace TaskTracker.Core.Enums; + +public enum WorkTaskStatus +{ + Pending, + Active, + Paused, + Completed, + Abandoned +} diff --git a/TaskTracker.Core/Interfaces/IAppMappingRepository.cs b/TaskTracker.Core/Interfaces/IAppMappingRepository.cs new file mode 100644 index 0000000..629d203 --- /dev/null +++ b/TaskTracker.Core/Interfaces/IAppMappingRepository.cs @@ -0,0 +1,13 @@ +using TaskTracker.Core.Entities; + +namespace TaskTracker.Core.Interfaces; + +public interface IAppMappingRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(AppMapping mapping); + Task UpdateAsync(AppMapping mapping); + Task DeleteAsync(int id); + Task FindMatchAsync(string appName, string windowTitle, string? url); +} diff --git a/TaskTracker.Core/Interfaces/IContextEventRepository.cs b/TaskTracker.Core/Interfaces/IContextEventRepository.cs new file mode 100644 index 0000000..5a82c83 --- /dev/null +++ b/TaskTracker.Core/Interfaces/IContextEventRepository.cs @@ -0,0 +1,10 @@ +using TaskTracker.Core.Entities; + +namespace TaskTracker.Core.Interfaces; + +public interface IContextEventRepository +{ + Task CreateAsync(ContextEvent contextEvent); + Task> GetRecentAsync(int minutes = 30); + Task> GetByTaskIdAsync(int taskId); +} diff --git a/TaskTracker.Core/Interfaces/ITaskRepository.cs b/TaskTracker.Core/Interfaces/ITaskRepository.cs new file mode 100644 index 0000000..d461a34 --- /dev/null +++ b/TaskTracker.Core/Interfaces/ITaskRepository.cs @@ -0,0 +1,15 @@ +using TaskTracker.Core.Entities; +using TaskTracker.Core.Enums; + +namespace TaskTracker.Core.Interfaces; + +public interface ITaskRepository +{ + Task> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false); + Task GetByIdAsync(int id); + Task GetActiveTaskAsync(); + Task> GetSubTasksAsync(int parentId); + Task CreateAsync(WorkTask task); + Task UpdateAsync(WorkTask task); + Task DeleteAsync(int id); +} diff --git a/TaskTracker.Core/TaskTracker.Core.csproj b/TaskTracker.Core/TaskTracker.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/TaskTracker.Core/TaskTracker.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs b/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs new file mode 100644 index 0000000..16bec61 --- /dev/null +++ b/TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using TaskTracker.Core.Entities; + +namespace TaskTracker.Infrastructure.Data; + +public class TaskTrackerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Tasks => Set(); + public DbSet Notes => Set(); + public DbSet ContextEvents => Set(); + public DbSet AppMappings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasKey(t => t.Id); + e.Property(t => t.Title).HasMaxLength(500).IsRequired(); + e.Property(t => t.Status).HasConversion().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(e => + { + e.HasKey(n => n.Id); + e.Property(n => n.Content).IsRequired(); + e.Property(n => n.Type).HasConversion().HasMaxLength(50); + }); + + modelBuilder.Entity(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(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().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" } + ); + } +} diff --git a/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.Designer.cs b/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.Designer.cs new file mode 100644 index 0000000..ca7fab7 --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.Designer.cs @@ -0,0 +1,242 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MatchType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WindowTitle") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("ContextEvents"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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 + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.cs b/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.cs new file mode 100644 index 0000000..e7ef6b5 --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.cs @@ -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 +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppMappings", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Pattern = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + MatchType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + FriendlyName = table.Column(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(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Title = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Category = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + StartedAt = table.Column(type: "datetime2", nullable: true), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tasks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ContextEvents", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkTaskId = table.Column(type: "int", nullable: true), + Source = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AppName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + WindowTitle = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + Url = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + Timestamp = table.Column(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(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkTaskId = table.Column(type: "int", nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + Type = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppMappings"); + + migrationBuilder.DropTable( + name: "ContextEvents"); + + migrationBuilder.DropTable( + name: "Notes"); + + migrationBuilder.DropTable( + name: "Tasks"); + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.Designer.cs b/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.Designer.cs new file mode 100644 index 0000000..1f5de0c --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.Designer.cs @@ -0,0 +1,259 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MatchType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WindowTitle") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("ContextEvents"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ParentTaskId") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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 + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.cs b/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.cs new file mode 100644 index 0000000..7bbd6d6 --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TaskTracker.Infrastructure.Migrations +{ + /// + public partial class AddSubTasks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs b/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs new file mode 100644 index 0000000..63f16fc --- /dev/null +++ b/TaskTracker.Infrastructure/Migrations/TaskTrackerDbContextModelSnapshot.cs @@ -0,0 +1,256 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FriendlyName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MatchType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("Url") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("WindowTitle") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("ContextEvents"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("WorkTaskId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkTaskId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("ParentTaskId") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("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 + } + } +} diff --git a/TaskTracker.Infrastructure/Repositories/AppMappingRepository.cs b/TaskTracker.Infrastructure/Repositories/AppMappingRepository.cs new file mode 100644 index 0000000..9a65e30 --- /dev/null +++ b/TaskTracker.Infrastructure/Repositories/AppMappingRepository.cs @@ -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> GetAllAsync() + { + return await db.AppMappings.OrderBy(m => m.Category).ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await db.AppMappings.FindAsync(id); + } + + public async Task 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 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 + }); + } +} diff --git a/TaskTracker.Infrastructure/Repositories/ContextEventRepository.cs b/TaskTracker.Infrastructure/Repositories/ContextEventRepository.cs new file mode 100644 index 0000000..ad735e7 --- /dev/null +++ b/TaskTracker.Infrastructure/Repositories/ContextEventRepository.cs @@ -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 CreateAsync(ContextEvent contextEvent) + { + contextEvent.Timestamp = DateTime.UtcNow; + db.ContextEvents.Add(contextEvent); + await db.SaveChangesAsync(); + return contextEvent; + } + + public async Task> 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> GetByTaskIdAsync(int taskId) + { + return await db.ContextEvents + .Where(c => c.WorkTaskId == taskId) + .OrderByDescending(c => c.Timestamp) + .ToListAsync(); + } +} diff --git a/TaskTracker.Infrastructure/Repositories/TaskRepository.cs b/TaskTracker.Infrastructure/Repositories/TaskRepository.cs new file mode 100644 index 0000000..1610c97 --- /dev/null +++ b/TaskTracker.Infrastructure/Repositories/TaskRepository.cs @@ -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> 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 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 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> GetSubTasksAsync(int parentId) + { + return await db.Tasks + .Include(t => t.SubTasks) + .Where(t => t.ParentTaskId == parentId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(); + } + + public async Task 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; + } + } +} diff --git a/TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj b/TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj new file mode 100644 index 0000000..a432771 --- /dev/null +++ b/TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net10.0 + enable + enable + + + diff --git a/TaskTracker.MCP/Program.cs b/TaskTracker.MCP/Program.cs new file mode 100644 index 0000000..2cf632e --- /dev/null +++ b/TaskTracker.MCP/Program.cs @@ -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(); diff --git a/TaskTracker.MCP/TaskTracker.MCP.csproj b/TaskTracker.MCP/TaskTracker.MCP.csproj new file mode 100644 index 0000000..62d502b --- /dev/null +++ b/TaskTracker.MCP/TaskTracker.MCP.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/TaskTracker.MCP/Tools/TaskTools.cs b/TaskTracker.MCP/Tools/TaskTools.cs new file mode 100644 index 0000000..0226c7f --- /dev/null +++ b/TaskTracker.MCP/Tools/TaskTools.cs @@ -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 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 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 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 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 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 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 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 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 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 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(); + } +} diff --git a/WindowWatcher/NativeMethods.cs b/WindowWatcher/NativeMethods.cs new file mode 100644 index 0000000..20c319a --- /dev/null +++ b/WindowWatcher/NativeMethods.cs @@ -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); +} diff --git a/WindowWatcher/Program.cs b/WindowWatcher/Program.cs new file mode 100644 index 0000000..5883527 --- /dev/null +++ b/WindowWatcher/Program.cs @@ -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( + builder.Configuration.GetSection(WindowWatcherOptions.SectionName)); + +builder.Services.AddHttpClient("TaskTrackerApi", (sp, client) => +{ + var config = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(config.ApiBaseUrl); +}); + +builder.Services.AddHostedService(); + +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; diff --git a/WindowWatcher/Properties/launchSettings.json b/WindowWatcher/Properties/launchSettings.json new file mode 100644 index 0000000..dca21ae --- /dev/null +++ b/WindowWatcher/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "WindowWatcher": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/WindowWatcher/TrayApplicationContext.cs b/WindowWatcher/TrayApplicationContext.cs new file mode 100644 index 0000000..b576580 --- /dev/null +++ b/WindowWatcher/TrayApplicationContext.cs @@ -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); + } +} diff --git a/WindowWatcher/WindowWatcher.csproj b/WindowWatcher/WindowWatcher.csproj new file mode 100644 index 0000000..f657808 --- /dev/null +++ b/WindowWatcher/WindowWatcher.csproj @@ -0,0 +1,17 @@ + + + + WinExe + net10.0-windows + enable + enable + true + true + + + + + + + + diff --git a/WindowWatcher/WindowWatcherOptions.cs b/WindowWatcher/WindowWatcherOptions.cs new file mode 100644 index 0000000..a0fa8e3 --- /dev/null +++ b/WindowWatcher/WindowWatcherOptions.cs @@ -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; +} diff --git a/WindowWatcher/Worker.cs b/WindowWatcher/Worker.cs new file mode 100644 index 0000000..a46ea81 --- /dev/null +++ b/WindowWatcher/Worker.cs @@ -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 options, + ILogger 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"); + } + } +} diff --git a/WindowWatcher/appsettings.Development.json b/WindowWatcher/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/WindowWatcher/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/WindowWatcher/appsettings.json b/WindowWatcher/appsettings.json new file mode 100644 index 0000000..caa3a46 --- /dev/null +++ b/WindowWatcher/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "WindowWatcher": { + "ApiBaseUrl": "http://localhost:5200", + "PollIntervalMs": 2000, + "DebounceMs": 3000 + } +} diff --git a/WorkContextTracker.slnx b/WorkContextTracker.slnx new file mode 100644 index 0000000..2bf6157 --- /dev/null +++ b/WorkContextTracker.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/plans/2026-02-26-web-ui-redesign-design.md b/docs/plans/2026-02-26-web-ui-redesign-design.md new file mode 100644 index 0000000..9359647 --- /dev/null +++ b/docs/plans/2026-02-26-web-ui-redesign-design.md @@ -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 | diff --git a/docs/plans/2026-02-26-web-ui-redesign.md b/docs/plans/2026-02-26-web-ui-redesign.md new file mode 100644 index 0000000..dda5289 --- /dev/null +++ b/docs/plans/2026-02-26-web-ui-redesign.md @@ -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 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.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
+

TaskTracker

+
+} +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( + + + , +) +``` + +**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 { + 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(config: Parameters[0]): Promise { + const { data } = await api.request>(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({ url: '/tasks', params: { includeSubTasks } }), + }) +} + +export function useActiveTask() { + return useQuery({ + queryKey: ['tasks', 'active'], + queryFn: () => request({ url: '/tasks/active' }), + refetchInterval: 30_000, + }) +} + +export function useTask(id: number) { + return useQuery({ + queryKey: ['tasks', id], + queryFn: () => request({ 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({ 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({ method: 'PUT', url: `/tasks/${id}`, data: body }), + onSuccess: invalidate, + }) +} + +export function useStartTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ method: 'PUT', url: `/tasks/${id}/start` }), + onSuccess: invalidate, + }) +} + +export function usePauseTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: ({ id, note }: { id: number; note?: string }) => + request({ 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({ method: 'PUT', url: `/tasks/${id}/resume`, data: { note } }), + onSuccess: invalidate, + }) +} + +export function useCompleteTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ method: 'PUT', url: `/tasks/${id}/complete` }), + onSuccess: invalidate, + }) +} + +export function useAbandonTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ 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({ url: '/context/recent', params: { minutes } }), + refetchInterval: 60_000, + }) +} + +export function useContextSummary() { + return useQuery({ + queryKey: ['context', 'summary'], + queryFn: () => request({ 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({ url: '/mappings' }), + }) +} + +export function useCreateMapping() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { pattern: string; matchType: string; category: string; friendlyName?: string }) => + request({ 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({ method: 'PUT', url: `/mappings/${id}`, data: body }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }), + }) +} + +export function useDeleteMapping() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => request({ 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( + + + + + , +) +``` + +**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 +- `` 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

Board

+} +``` + +**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 ( + + + }> + } /> + } /> + } /> + } /> + + + + ) +} +``` + +**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 = { + 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 `` 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**: `` component + - **Notes**: `` 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 ` 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 `` 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 `` 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 `` above the ``. 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 ``: +```html + + + +``` + +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" +```