chore: initial commit of TaskTracker project
Existing ASP.NET API with vanilla JS SPA, WindowWatcher, Chrome extension, and MCP server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.vs/
|
||||
bin/
|
||||
obj/
|
||||
node_modules/
|
||||
*.user
|
||||
*.suo
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
dist/
|
||||
55
ChromeExtension/background.js
Normal file
55
ChromeExtension/background.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const API_BASE = "http://localhost:5200";
|
||||
let debounceTimer = null;
|
||||
let lastReportedUrl = "";
|
||||
let lastReportedTitle = "";
|
||||
|
||||
function reportTab(tab) {
|
||||
if (!tab || !tab.url || tab.url.startsWith("chrome://") || tab.url.startsWith("chrome-extension://")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if same as last reported
|
||||
if (tab.url === lastReportedUrl && tab.title === lastReportedTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: wait 2 seconds before reporting
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
const payload = {
|
||||
source: "ChromeExtension",
|
||||
appName: "chrome.exe",
|
||||
windowTitle: tab.title || "",
|
||||
url: tab.url
|
||||
};
|
||||
|
||||
fetch(`${API_BASE}/api/context`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(() => {
|
||||
lastReportedUrl = tab.url;
|
||||
lastReportedTitle = tab.title;
|
||||
})
|
||||
.catch(err => console.warn("Failed to report context:", err));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Tab switched
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
chrome.tabs.get(activeInfo.tabId, (tab) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
reportTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Navigation within tab
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
if (changeInfo.status === "complete" && tab.active) {
|
||||
reportTab(tab);
|
||||
}
|
||||
});
|
||||
16
ChromeExtension/manifest.json
Normal file
16
ChromeExtension/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Work Context Tracker",
|
||||
"version": "1.0",
|
||||
"description": "Reports active tab URL and title to the Work Context Tracker API",
|
||||
"permissions": ["tabs", "activeTab", "storage"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"host_permissions": [
|
||||
"http://localhost:5200/*"
|
||||
]
|
||||
}
|
||||
60
ChromeExtension/popup.html
Normal file
60
ChromeExtension/popup.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
width: 300px;
|
||||
padding: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #1a73e8;
|
||||
}
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.task-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-Active { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-Paused { background: #fff3e0; color: #e65100; }
|
||||
.status-Pending { background: #e3f2fd; color: #1565c0; }
|
||||
.no-task {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
button:hover { background: #f5f5f5; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
#error { color: #d32f2f; font-size: 11px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>Work Context Tracker</h3>
|
||||
<div id="content">Loading...</div>
|
||||
<div id="error"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
ChromeExtension/popup.js
Normal file
62
ChromeExtension/popup.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const API_BASE = "http://localhost:5200";
|
||||
const content = document.getElementById("content");
|
||||
const errorDiv = document.getElementById("error");
|
||||
|
||||
let currentTask = null;
|
||||
|
||||
async function loadActiveTask() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/active`);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success || !json.data) {
|
||||
content.innerHTML = '<p class="no-task">No active task</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentTask = json.data;
|
||||
renderTask(currentTask);
|
||||
} catch (err) {
|
||||
errorDiv.textContent = "Cannot connect to API";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
const isActive = task.status === "Active";
|
||||
const isPaused = task.status === "Paused";
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
<span class="task-status status-${task.status}">${task.status}</span>
|
||||
${task.category ? `<div style="font-size:11px;color:#666;">Category: ${escapeHtml(task.category)}</div>` : ""}
|
||||
<button id="toggleBtn">${isActive ? "Pause Task" : "Resume Task"}</button>
|
||||
`;
|
||||
|
||||
document.getElementById("toggleBtn").addEventListener("click", () => toggleTask(task.id, isActive));
|
||||
}
|
||||
|
||||
async function toggleTask(taskId, isCurrentlyActive) {
|
||||
try {
|
||||
const action = isCurrentlyActive ? "pause" : "resume";
|
||||
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/${action}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
currentTask = json.data;
|
||||
renderTask(currentTask);
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = "Failed to update task";
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
loadActiveTask();
|
||||
83
TaskTracker.Api/Controllers/ContextController.cs
Normal file
83
TaskTracker.Api/Controllers/ContextController.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
|
||||
namespace TaskTracker.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ContextController(
|
||||
IContextEventRepository contextRepo,
|
||||
IAppMappingRepository mappingRepo,
|
||||
ITaskRepository taskRepo,
|
||||
ILogger<ContextController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingest([FromBody] ContextEventRequest request)
|
||||
{
|
||||
// Try to auto-link to the active task
|
||||
var activeTask = await taskRepo.GetActiveTaskAsync();
|
||||
|
||||
var contextEvent = new ContextEvent
|
||||
{
|
||||
Source = request.Source,
|
||||
AppName = request.AppName,
|
||||
WindowTitle = request.WindowTitle,
|
||||
Url = request.Url,
|
||||
WorkTaskId = activeTask?.Id
|
||||
};
|
||||
|
||||
// Try to find a matching app mapping for category enrichment
|
||||
var mapping = await mappingRepo.FindMatchAsync(request.AppName, request.WindowTitle, request.Url);
|
||||
if (mapping is not null)
|
||||
{
|
||||
logger.LogDebug("Matched context event to category {Category} via {MatchType}",
|
||||
mapping.Category, mapping.MatchType);
|
||||
}
|
||||
|
||||
var created = await contextRepo.CreateAsync(contextEvent);
|
||||
return Ok(ApiResponse<ContextEvent>.Ok(created));
|
||||
}
|
||||
|
||||
[HttpGet("recent")]
|
||||
public async Task<IActionResult> GetRecent([FromQuery] int minutes = 30)
|
||||
{
|
||||
var events = await contextRepo.GetRecentAsync(minutes);
|
||||
return Ok(ApiResponse<List<ContextEvent>>.Ok(events));
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary()
|
||||
{
|
||||
// Get today's events (last 8 hours)
|
||||
var events = await contextRepo.GetRecentAsync(480);
|
||||
var mappings = await mappingRepo.GetAllAsync();
|
||||
|
||||
var summary = events
|
||||
.GroupBy(e => e.AppName)
|
||||
.Select(g =>
|
||||
{
|
||||
var match = mappings.FirstOrDefault(m => m.MatchType switch
|
||||
{
|
||||
"ProcessName" => g.Key.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
|
||||
"TitleContains" => g.Any(e => e.WindowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase)),
|
||||
"UrlContains" => g.Any(e => e.Url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true),
|
||||
_ => false
|
||||
});
|
||||
|
||||
return new ContextSummaryItem
|
||||
{
|
||||
AppName = match?.FriendlyName ?? g.Key,
|
||||
Category = match?.Category ?? "Unknown",
|
||||
EventCount = g.Count(),
|
||||
FirstSeen = g.Min(e => e.Timestamp),
|
||||
LastSeen = g.Max(e => e.Timestamp)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(s => s.EventCount)
|
||||
.ToList();
|
||||
|
||||
return Ok(ApiResponse<List<ContextSummaryItem>>.Ok(summary));
|
||||
}
|
||||
}
|
||||
56
TaskTracker.Api/Controllers/MappingsController.cs
Normal file
56
TaskTracker.Api/Controllers/MappingsController.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
|
||||
namespace TaskTracker.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class MappingsController(IAppMappingRepository mappingRepo) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var mappings = await mappingRepo.GetAllAsync();
|
||||
return Ok(ApiResponse<List<AppMapping>>.Ok(mappings));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateAppMappingRequest request)
|
||||
{
|
||||
var mapping = new AppMapping
|
||||
{
|
||||
Pattern = request.Pattern,
|
||||
MatchType = request.MatchType,
|
||||
Category = request.Category,
|
||||
FriendlyName = request.FriendlyName
|
||||
};
|
||||
|
||||
var created = await mappingRepo.CreateAsync(mapping);
|
||||
return Ok(ApiResponse<AppMapping>.Ok(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] CreateAppMappingRequest request)
|
||||
{
|
||||
var mapping = await mappingRepo.GetByIdAsync(id);
|
||||
if (mapping is null)
|
||||
return NotFound(ApiResponse.Fail("Mapping not found"));
|
||||
|
||||
mapping.Pattern = request.Pattern;
|
||||
mapping.MatchType = request.MatchType;
|
||||
mapping.Category = request.Category;
|
||||
mapping.FriendlyName = request.FriendlyName;
|
||||
await mappingRepo.UpdateAsync(mapping);
|
||||
|
||||
return Ok(ApiResponse<AppMapping>.Ok(mapping));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await mappingRepo.DeleteAsync(id);
|
||||
return Ok(ApiResponse.Ok());
|
||||
}
|
||||
}
|
||||
41
TaskTracker.Api/Controllers/NotesController.cs
Normal file
41
TaskTracker.Api/Controllers/NotesController.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
|
||||
namespace TaskTracker.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/tasks/{taskId:int}/notes")]
|
||||
public class NotesController(ITaskRepository taskRepo) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetNotes(int taskId)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
return Ok(ApiResponse<List<TaskNote>>.Ok(task.Notes));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddNote(int taskId, [FromBody] CreateNoteRequest request)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
var note = new TaskNote
|
||||
{
|
||||
Content = request.Content,
|
||||
Type = request.Type,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
task.Notes.Add(note);
|
||||
await taskRepo.UpdateAsync(task);
|
||||
|
||||
return Ok(ApiResponse<TaskNote>.Ok(note));
|
||||
}
|
||||
}
|
||||
171
TaskTracker.Api/Controllers/TasksController.cs
Normal file
171
TaskTracker.Api/Controllers/TasksController.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Enums;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
|
||||
namespace TaskTracker.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TasksController(ITaskRepository taskRepo, ILogger<TasksController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] WorkTaskStatus? status, [FromQuery] int? parentId, [FromQuery] bool includeSubTasks = false)
|
||||
{
|
||||
var tasks = await taskRepo.GetAllAsync(status, parentId, includeSubTasks);
|
||||
return Ok(ApiResponse<List<WorkTask>>.Ok(tasks));
|
||||
}
|
||||
|
||||
[HttpGet("active")]
|
||||
public async Task<IActionResult> GetActive()
|
||||
{
|
||||
var task = await taskRepo.GetActiveTaskAsync();
|
||||
if (task is null)
|
||||
return Ok(ApiResponse<WorkTask?>.Ok(null));
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateTaskRequest request)
|
||||
{
|
||||
if (request.ParentTaskId.HasValue)
|
||||
{
|
||||
var parent = await taskRepo.GetByIdAsync(request.ParentTaskId.Value);
|
||||
if (parent is null)
|
||||
return BadRequest(ApiResponse.Fail("Parent task not found"));
|
||||
}
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Category = request.Category,
|
||||
ParentTaskId = request.ParentTaskId,
|
||||
Status = WorkTaskStatus.Pending
|
||||
};
|
||||
|
||||
var created = await taskRepo.CreateAsync(task);
|
||||
logger.LogInformation("Created task {TaskId}: {Title}", created.Id, created.Title);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ApiResponse<WorkTask>.Ok(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/start")]
|
||||
public async Task<IActionResult> Start(int id)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
// Pause any currently active task
|
||||
var active = await taskRepo.GetActiveTaskAsync();
|
||||
if (active is not null && active.Id != id)
|
||||
{
|
||||
active.Status = WorkTaskStatus.Paused;
|
||||
await taskRepo.UpdateAsync(active);
|
||||
}
|
||||
|
||||
task.Status = WorkTaskStatus.Active;
|
||||
task.StartedAt ??= DateTime.UtcNow;
|
||||
await taskRepo.UpdateAsync(task);
|
||||
|
||||
logger.LogInformation("Started task {TaskId}", id);
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/pause")]
|
||||
public async Task<IActionResult> Pause(int id, [FromBody] TaskActionRequest? request)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
task.Status = WorkTaskStatus.Paused;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request?.Note))
|
||||
{
|
||||
task.Notes.Add(new TaskNote
|
||||
{
|
||||
Content = request.Note,
|
||||
Type = NoteType.PauseNote,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await taskRepo.UpdateAsync(task);
|
||||
logger.LogInformation("Paused task {TaskId}", id);
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/resume")]
|
||||
public async Task<IActionResult> Resume(int id, [FromBody] TaskActionRequest? request)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
// Pause any currently active task
|
||||
var active = await taskRepo.GetActiveTaskAsync();
|
||||
if (active is not null && active.Id != id)
|
||||
{
|
||||
active.Status = WorkTaskStatus.Paused;
|
||||
await taskRepo.UpdateAsync(active);
|
||||
}
|
||||
|
||||
task.Status = WorkTaskStatus.Active;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request?.Note))
|
||||
{
|
||||
task.Notes.Add(new TaskNote
|
||||
{
|
||||
Content = request.Note,
|
||||
Type = NoteType.ResumeNote,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await taskRepo.UpdateAsync(task);
|
||||
logger.LogInformation("Resumed task {TaskId}", id);
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/complete")]
|
||||
public async Task<IActionResult> Complete(int id)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
// Block completion if any subtasks are not completed/abandoned
|
||||
var incompleteSubTasks = task.SubTasks
|
||||
.Where(st => st.Status != WorkTaskStatus.Completed && st.Status != WorkTaskStatus.Abandoned)
|
||||
.ToList();
|
||||
|
||||
if (incompleteSubTasks.Count > 0)
|
||||
return BadRequest(ApiResponse.Fail($"Cannot complete task: {incompleteSubTasks.Count} subtask(s) are still incomplete"));
|
||||
|
||||
task.Status = WorkTaskStatus.Completed;
|
||||
task.CompletedAt = DateTime.UtcNow;
|
||||
await taskRepo.UpdateAsync(task);
|
||||
|
||||
logger.LogInformation("Completed task {TaskId}", id);
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await taskRepo.DeleteAsync(id);
|
||||
logger.LogInformation("Abandoned task {TaskId}", id);
|
||||
return Ok(ApiResponse.Ok());
|
||||
}
|
||||
}
|
||||
58
TaskTracker.Api/Program.cs
Normal file
58
TaskTracker.Api/Program.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
using TaskTracker.Infrastructure.Repositories;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EF Core
|
||||
builder.Services.AddDbContext<TaskTrackerDbContext>(options =>
|
||||
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Repositories
|
||||
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
||||
builder.Services.AddScoped<IContextEventRepository, ContextEventRepository>();
|
||||
builder.Services.AddScoped<IAppMappingRepository, AppMappingRepository>();
|
||||
|
||||
// Controllers + JSON
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o =>
|
||||
{
|
||||
o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
o.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
|
||||
// Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// CORS — allow Chrome extension and local MCP server
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Auto-migrate on startup in development
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<TaskTrackerDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseCors();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
23
TaskTracker.Api/Properties/launchSettings.json
Normal file
23
TaskTracker.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5200",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:5201;http://localhost:5200",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
TaskTracker.Api/TaskTracker.Api.csproj
Normal file
22
TaskTracker.Api/TaskTracker.Api.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaskTracker.Core\TaskTracker.Core.csproj" />
|
||||
<ProjectReference Include="..\TaskTracker.Infrastructure\TaskTracker.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
6
TaskTracker.Api/TaskTracker.Api.http
Normal file
6
TaskTracker.Api/TaskTracker.Api.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@TaskTracker.Api_HostAddress = http://localhost:5125
|
||||
|
||||
GET {{TaskTracker.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
8
TaskTracker.Api/appsettings.Development.json
Normal file
8
TaskTracker.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
TaskTracker.Api/appsettings.json
Normal file
12
TaskTracker.Api/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost\\SQLEXPRESS;Database=WorkContextTracker;Trusted_Connection=True;TrustServerCertificate=True;"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
619
TaskTracker.Api/wwwroot/css/app.css
Normal file
619
TaskTracker.Api/wwwroot/css/app.css
Normal file
@@ -0,0 +1,619 @@
|
||||
/* ── Variables ── */
|
||||
:root {
|
||||
--bg-primary: #1a1b23;
|
||||
--bg-secondary: #22232e;
|
||||
--bg-card: #2a2b38;
|
||||
--bg-input: #32333f;
|
||||
--bg-hover: #353647;
|
||||
--border: #3a3b4a;
|
||||
--text-primary: #e4e4e8;
|
||||
--text-secondary: #9d9db0;
|
||||
--text-muted: #6b6b80;
|
||||
--accent: #6c8cff;
|
||||
--accent-hover: #8ba3ff;
|
||||
--success: #4caf7c;
|
||||
--warning: #e0a040;
|
||||
--danger: #e05555;
|
||||
--info: #50b0d0;
|
||||
--sidebar-width: 220px;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 20px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 24px 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page { animation: fadeIn 0.15s ease; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Stats Grid ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Status Badges ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badge-pending { background: #3a3b4a; color: var(--text-secondary); }
|
||||
.badge-active { background: rgba(76, 175, 124, 0.2); color: var(--success); }
|
||||
.badge-paused { background: rgba(224, 160, 64, 0.2); color: var(--warning); }
|
||||
.badge-completed { background: rgba(108, 140, 255, 0.2); color: var(--accent); }
|
||||
.badge-abandoned { background: rgba(224, 85, 85, 0.2); color: var(--danger); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
||||
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
|
||||
.btn-success:hover { opacity: 0.9; }
|
||||
|
||||
.btn-warning { background: var(--warning); border-color: var(--warning); color: #1a1b23; }
|
||||
.btn-warning:hover { opacity: 0.9; }
|
||||
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
.form-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-inline .form-group { margin-bottom: 0; flex: 1; }
|
||||
|
||||
/* ── Tables ── */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* ── Tabs / Filters ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.filter-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
||||
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* ── Active Task Card ── */
|
||||
.active-task-card {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.active-task-card .card-title {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.no-active-task {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ── Task List ── */
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.task-item:hover { border-color: var(--accent); }
|
||||
|
||||
.task-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Task Detail ── */
|
||||
.task-detail { margin-top: 16px; }
|
||||
|
||||
.task-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.note-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.note-type {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.note-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Context Summary ── */
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summary-item:last-child { border-bottom: none; }
|
||||
|
||||
.summary-app {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-category {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.summary-count {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
min-width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ── Utilities ── */
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* ── Breadcrumbs ── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-muted);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-parent {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Subtasks ── */
|
||||
.subtask-list {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtask-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.subtask-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtask-item-title {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtask-item-title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtask-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
38
TaskTracker.Api/wwwroot/index.html
Normal file
38
TaskTracker.Api/wwwroot/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskTracker</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-brand">TaskTracker</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#/" data-page="dashboard" class="nav-link active">
|
||||
<span class="nav-icon">[D]</span> Dashboard
|
||||
</a></li>
|
||||
<li><a href="#/tasks" data-page="tasks" class="nav-link">
|
||||
<span class="nav-icon">[T]</span> Tasks
|
||||
</a></li>
|
||||
<li><a href="#/context" data-page="context" class="nav-link">
|
||||
<span class="nav-icon">[C]</span> Context
|
||||
</a></li>
|
||||
<li><a href="#/mappings" data-page="mappings" class="nav-link">
|
||||
<span class="nav-icon">[M]</span> Mappings
|
||||
</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<div id="page-dashboard" class="page"></div>
|
||||
<div id="page-tasks" class="page hidden"></div>
|
||||
<div id="page-context" class="page hidden"></div>
|
||||
<div id="page-mappings" class="page hidden"></div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="modal-overlay" class="modal-overlay hidden"></div>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
TaskTracker.Api/wwwroot/js/api.js
Normal file
48
TaskTracker.Api/wwwroot/js/api.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const BASE = '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error || 'Request failed');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export const tasks = {
|
||||
list: (status, { parentId, includeSubTasks } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (parentId != null) params.set('parentId', parentId);
|
||||
if (includeSubTasks) params.set('includeSubTasks', 'true');
|
||||
const qs = params.toString();
|
||||
return request(`/tasks${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
subtasks: (parentId) => request(`/tasks?parentId=${parentId}`),
|
||||
active: () => request('/tasks/active'),
|
||||
get: (id) => request(`/tasks/${id}`),
|
||||
create: (body) => request('/tasks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
start: (id) => request(`/tasks/${id}/start`, { method: 'PUT' }),
|
||||
pause: (id, note) => request(`/tasks/${id}/pause`, { method: 'PUT', body: JSON.stringify({ note }) }),
|
||||
resume: (id, note) => request(`/tasks/${id}/resume`, { method: 'PUT', body: JSON.stringify({ note }) }),
|
||||
complete: (id) => request(`/tasks/${id}/complete`, { method: 'PUT' }),
|
||||
abandon: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const notes = {
|
||||
list: (taskId) => request(`/tasks/${taskId}/notes`),
|
||||
create: (taskId, body) => request(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export const context = {
|
||||
recent: (minutes = 30) => request(`/context/recent?minutes=${minutes}`),
|
||||
summary: () => request('/context/summary'),
|
||||
};
|
||||
|
||||
export const mappings = {
|
||||
list: () => request('/mappings'),
|
||||
create: (body) => request('/mappings', { method: 'POST', body: JSON.stringify(body) }),
|
||||
update: (id, body) => request(`/mappings/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
remove: (id) => request(`/mappings/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
53
TaskTracker.Api/wwwroot/js/app.js
Normal file
53
TaskTracker.Api/wwwroot/js/app.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { initDashboard, refreshDashboard } from './pages/dashboard.js';
|
||||
import { initTasks } from './pages/tasks.js';
|
||||
import { initContext } from './pages/context.js';
|
||||
import { initMappings } from './pages/mappings.js';
|
||||
|
||||
const pages = ['dashboard', 'tasks', 'context', 'mappings'];
|
||||
let currentPage = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
function navigate(page) {
|
||||
if (!pages.includes(page)) page = 'dashboard';
|
||||
if (currentPage === page) return;
|
||||
currentPage = page;
|
||||
|
||||
// Update nav
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.toggle('active', link.dataset.page === page);
|
||||
});
|
||||
|
||||
// Show/hide pages
|
||||
pages.forEach(p => {
|
||||
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
|
||||
});
|
||||
|
||||
// Load page content
|
||||
const loaders = { dashboard: refreshDashboard, tasks: initTasks, context: initContext, mappings: initMappings };
|
||||
loaders[page]?.();
|
||||
|
||||
// Auto-refresh for dashboard and context
|
||||
clearInterval(refreshTimer);
|
||||
if (page === 'dashboard' || page === 'context') {
|
||||
refreshTimer = setInterval(() => loaders[page]?.(), 30000);
|
||||
}
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const hash = location.hash.slice(2) || 'dashboard';
|
||||
navigate(hash);
|
||||
}
|
||||
|
||||
// Init
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
location.hash = link.getAttribute('href').slice(1);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
|
||||
initDashboard();
|
||||
initTasks();
|
||||
onHashChange();
|
||||
33
TaskTracker.Api/wwwroot/js/components/modal.js
Normal file
33
TaskTracker.Api/wwwroot/js/components/modal.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const overlay = document.getElementById('modal-overlay');
|
||||
|
||||
export function showModal(title, contentHtml, actions = []) {
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-title">${title}</div>
|
||||
<div class="modal-body">${contentHtml}</div>
|
||||
<div class="modal-actions" id="modal-actions"></div>
|
||||
</div>`;
|
||||
const actionsEl = document.getElementById('modal-actions');
|
||||
actions.forEach(({ label, cls, onClick }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `btn ${cls || ''}`;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', async () => {
|
||||
await onClick(overlay.querySelector('.modal'));
|
||||
closeModal();
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
});
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
}
|
||||
|
||||
export function closeModal() {
|
||||
overlay.classList.add('hidden');
|
||||
overlay.innerHTML = '';
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
}
|
||||
|
||||
function onOverlayClick(e) {
|
||||
if (e.target === overlay) closeModal();
|
||||
}
|
||||
91
TaskTracker.Api/wwwroot/js/pages/context.js
Normal file
91
TaskTracker.Api/wwwroot/js/pages/context.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as api from '../api.js';
|
||||
|
||||
const el = () => document.getElementById('page-context');
|
||||
|
||||
export async function initContext() {
|
||||
el().innerHTML = `
|
||||
<h1 class="page-title">Context</h1>
|
||||
<div class="section-title">App Summary (8 hours)</div>
|
||||
<div id="ctx-summary" class="card mb-16"></div>
|
||||
<div class="flex-between mb-8">
|
||||
<div class="section-title" style="margin-bottom:0">Recent Events</div>
|
||||
<select class="form-select" id="ctx-minutes" style="width:auto">
|
||||
<option value="15">Last 15 min</option>
|
||||
<option value="30" selected>Last 30 min</option>
|
||||
<option value="60">Last hour</option>
|
||||
<option value="120">Last 2 hours</option>
|
||||
<option value="480">Last 8 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ctx-events" class="card table-wrap"></div>`;
|
||||
|
||||
document.getElementById('ctx-minutes').addEventListener('change', loadEvents);
|
||||
await Promise.all([loadSummary(), loadEvents()]);
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const summary = await api.context.summary();
|
||||
const container = document.getElementById('ctx-summary');
|
||||
if (!summary || !summary.length) {
|
||||
container.innerHTML = `<div class="empty-state">No activity recorded</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>Application</th><th>Category</th><th>Events</th><th>First Seen</th><th>Last Seen</th></tr></thead>
|
||||
<tbody>
|
||||
${summary.map(s => `
|
||||
<tr>
|
||||
<td>${esc(s.appName)}</td>
|
||||
<td>${esc(s.category)}</td>
|
||||
<td>${s.eventCount}</td>
|
||||
<td>${formatTime(s.firstSeen)}</td>
|
||||
<td>${formatTime(s.lastSeen)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
document.getElementById('ctx-summary').innerHTML = `<div class="empty-state">Failed to load summary</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
const minutes = parseInt(document.getElementById('ctx-minutes').value);
|
||||
try {
|
||||
const events = await api.context.recent(minutes);
|
||||
const container = document.getElementById('ctx-events');
|
||||
if (!events || !events.length) {
|
||||
container.innerHTML = `<div class="empty-state">No recent events</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>Source</th><th>App</th><th>Window Title</th><th>URL</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
${events.map(e => `
|
||||
<tr>
|
||||
<td>${esc(e.source)}</td>
|
||||
<td>${esc(e.appName)}</td>
|
||||
<td class="truncate">${esc(e.windowTitle)}</td>
|
||||
<td class="truncate">${e.url ? esc(e.url) : '-'}</td>
|
||||
<td>${formatTime(e.timestamp)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
document.getElementById('ctx-events').innerHTML = `<div class="empty-state">Failed to load events</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
127
TaskTracker.Api/wwwroot/js/pages/dashboard.js
Normal file
127
TaskTracker.Api/wwwroot/js/pages/dashboard.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as api from '../api.js';
|
||||
|
||||
const el = () => document.getElementById('page-dashboard');
|
||||
|
||||
export function initDashboard() {
|
||||
el().innerHTML = `
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<div id="dash-active-task"></div>
|
||||
<div class="section-title mt-16">Task Summary</div>
|
||||
<div id="dash-stats" class="stats-grid"></div>
|
||||
<div class="section-title mt-16">Recent Activity (8 hours)</div>
|
||||
<div id="dash-context" class="card"></div>`;
|
||||
}
|
||||
|
||||
export async function refreshDashboard() {
|
||||
try {
|
||||
const [active, allTasks, summary] = await Promise.all([
|
||||
api.tasks.active(),
|
||||
api.tasks.list(null, { includeSubTasks: true }),
|
||||
api.context.summary(),
|
||||
]);
|
||||
await renderActiveTask(active);
|
||||
renderStats(allTasks);
|
||||
renderContextSummary(summary);
|
||||
} catch (e) {
|
||||
console.error('Dashboard refresh failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildParentTrail(task) {
|
||||
const trail = [];
|
||||
let current = task;
|
||||
while (current.parentTaskId) {
|
||||
try {
|
||||
current = await api.tasks.get(current.parentTaskId);
|
||||
trail.unshift({ id: current.id, title: current.title });
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
async function renderActiveTask(task) {
|
||||
const container = document.getElementById('dash-active-task');
|
||||
if (!task) {
|
||||
container.innerHTML = `<div class="card"><div class="no-active-task">No active task</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const parentTrail = await buildParentTrail(task);
|
||||
const breadcrumbHtml = parentTrail.length > 0
|
||||
? `<div class="breadcrumb text-sm mt-8">${parentTrail.map(p => `<span class="breadcrumb-parent">${esc(p.title)}</span><span class="breadcrumb-sep">/</span>`).join('')}<span class="breadcrumb-current">${esc(task.title)}</span></div>`
|
||||
: '';
|
||||
|
||||
const elapsed = task.startedAt ? formatElapsed(new Date(task.startedAt)) : '';
|
||||
container.innerHTML = `
|
||||
<div class="card active-task-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">${esc(task.title)}</div>
|
||||
${breadcrumbHtml}
|
||||
${task.description ? `<div class="text-sm text-muted mt-8">${esc(task.description)}</div>` : ''}
|
||||
${task.category ? `<div class="text-sm text-muted">Category: ${esc(task.category)}</div>` : ''}
|
||||
${elapsed ? `<div class="text-sm text-muted">Active for ${elapsed}</div>` : ''}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-warning btn-sm" data-action="pause" data-id="${task.id}">Pause</button>
|
||||
<button class="btn btn-success btn-sm" data-action="complete" data-id="${task.id}">Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
container.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.dataset.action;
|
||||
const id = btn.dataset.id;
|
||||
try {
|
||||
if (action === 'pause') await api.tasks.pause(id);
|
||||
else if (action === 'complete') await api.tasks.complete(id);
|
||||
refreshDashboard();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderStats(allTasks) {
|
||||
const counts = { Pending: 0, Active: 0, Paused: 0, Completed: 0, Abandoned: 0 };
|
||||
allTasks.forEach(t => counts[t.status] = (counts[t.status] || 0) + 1);
|
||||
const container = document.getElementById('dash-stats');
|
||||
container.innerHTML = Object.entries(counts).map(([status, count]) => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${count}</div>
|
||||
<div class="stat-label">${status}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderContextSummary(summary) {
|
||||
const container = document.getElementById('dash-context');
|
||||
if (!summary || summary.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state">No recent activity</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = summary.slice(0, 10).map(item => `
|
||||
<div class="summary-item">
|
||||
<div>
|
||||
<div class="summary-app">${esc(item.appName)}</div>
|
||||
<div class="summary-category">${esc(item.category)}</div>
|
||||
</div>
|
||||
<div class="summary-count">${item.eventCount}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function formatElapsed(since) {
|
||||
const diff = Math.floor((Date.now() - since.getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
119
TaskTracker.Api/wwwroot/js/pages/mappings.js
Normal file
119
TaskTracker.Api/wwwroot/js/pages/mappings.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as api from '../api.js';
|
||||
import { showModal } from '../components/modal.js';
|
||||
|
||||
const el = () => document.getElementById('page-mappings');
|
||||
|
||||
export async function initMappings() {
|
||||
el().innerHTML = `
|
||||
<h1 class="page-title">App Mappings</h1>
|
||||
<div class="flex-between mb-16">
|
||||
<div class="text-muted text-sm">Map process names, window titles, or URLs to categories</div>
|
||||
<button class="btn btn-primary" id="btn-new-mapping">+ New Mapping</button>
|
||||
</div>
|
||||
<div id="mapping-list" class="card table-wrap"></div>`;
|
||||
|
||||
document.getElementById('btn-new-mapping').addEventListener('click', () => showMappingForm());
|
||||
await loadMappings();
|
||||
}
|
||||
|
||||
async function loadMappings() {
|
||||
try {
|
||||
const mappings = await api.mappings.list();
|
||||
const container = document.getElementById('mapping-list');
|
||||
if (!mappings || !mappings.length) {
|
||||
container.innerHTML = `<div class="empty-state">No mappings configured</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>Pattern</th><th>Match Type</th><th>Category</th><th>Friendly Name</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
${mappings.map(m => `
|
||||
<tr>
|
||||
<td><code>${esc(m.pattern)}</code></td>
|
||||
<td><span class="badge badge-pending">${m.matchType}</span></td>
|
||||
<td>${esc(m.category)}</td>
|
||||
<td>${esc(m.friendlyName) || '<span class="text-muted">-</span>'}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm" data-edit="${m.id}">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" data-delete="${m.id}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
container.querySelectorAll('[data-edit]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const m = mappings.find(x => x.id === parseInt(btn.dataset.edit));
|
||||
if (m) showMappingForm(m);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('[data-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', () => confirmDelete(parseInt(btn.dataset.delete)));
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('mapping-list').innerHTML = `<div class="empty-state">Failed to load mappings</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showMappingForm(existing = null) {
|
||||
const title = existing ? 'Edit Mapping' : 'New Mapping';
|
||||
showModal(title, `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pattern *</label>
|
||||
<input type="text" class="form-input" id="map-pattern" value="${esc(existing?.pattern || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Match Type *</label>
|
||||
<select class="form-select" id="map-match-type">
|
||||
<option value="ProcessName" ${existing?.matchType === 'ProcessName' ? 'selected' : ''}>Process Name</option>
|
||||
<option value="TitleContains" ${existing?.matchType === 'TitleContains' ? 'selected' : ''}>Title Contains</option>
|
||||
<option value="UrlContains" ${existing?.matchType === 'UrlContains' ? 'selected' : ''}>URL Contains</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category *</label>
|
||||
<input type="text" class="form-input" id="map-category" value="${esc(existing?.category || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Friendly Name</label>
|
||||
<input type="text" class="form-input" id="map-friendly" value="${esc(existing?.friendlyName || '')}">
|
||||
</div>`,
|
||||
[
|
||||
{ label: 'Cancel', onClick: () => {} },
|
||||
{
|
||||
label: existing ? 'Save' : 'Create', cls: 'btn-primary', onClick: async (modal) => {
|
||||
const pattern = modal.querySelector('#map-pattern').value.trim();
|
||||
const matchType = modal.querySelector('#map-match-type').value;
|
||||
const category = modal.querySelector('#map-category').value.trim();
|
||||
const friendlyName = modal.querySelector('#map-friendly').value.trim() || null;
|
||||
if (!pattern || !category) { alert('Pattern and Category are required'); throw new Error('cancel'); }
|
||||
const body = { pattern, matchType, category, friendlyName };
|
||||
if (existing) await api.mappings.update(existing.id, body);
|
||||
else await api.mappings.create(body);
|
||||
loadMappings();
|
||||
},
|
||||
},
|
||||
]);
|
||||
setTimeout(() => document.getElementById('map-pattern')?.focus(), 100);
|
||||
}
|
||||
|
||||
function confirmDelete(id) {
|
||||
showModal('Delete Mapping', `<p>Are you sure you want to delete this mapping?</p>`, [
|
||||
{ label: 'Cancel', onClick: () => {} },
|
||||
{
|
||||
label: 'Delete', cls: 'btn-danger', onClick: async () => {
|
||||
await api.mappings.remove(id);
|
||||
loadMappings();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
357
TaskTracker.Api/wwwroot/js/pages/tasks.js
Normal file
357
TaskTracker.Api/wwwroot/js/pages/tasks.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import * as api from '../api.js';
|
||||
import { showModal, closeModal } from '../components/modal.js';
|
||||
|
||||
const el = () => document.getElementById('page-tasks');
|
||||
let currentFilter = null;
|
||||
let selectedTaskId = null;
|
||||
|
||||
export function initTasks() {
|
||||
el().innerHTML = `
|
||||
<h1 class="page-title">Tasks</h1>
|
||||
<div class="flex-between mb-16">
|
||||
<div id="task-filters" class="filter-bar"></div>
|
||||
<button class="btn btn-primary" id="btn-new-task">+ New Task</button>
|
||||
</div>
|
||||
<div id="task-list"></div>
|
||||
<div id="task-detail" class="hidden"></div>`;
|
||||
|
||||
renderFilters();
|
||||
document.getElementById('btn-new-task').addEventListener('click', () => showNewTaskModal());
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const statuses = [null, 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
|
||||
const labels = ['All', 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
|
||||
const container = document.getElementById('task-filters');
|
||||
container.innerHTML = statuses.map((s, i) => `
|
||||
<button class="filter-btn ${s === currentFilter ? 'active' : ''}" data-status="${s || ''}">${labels[i]}</button>`).join('');
|
||||
container.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentFilter = btn.dataset.status || null;
|
||||
renderFilters();
|
||||
loadTasks();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const tasks = await api.tasks.list(currentFilter);
|
||||
renderTaskList(tasks);
|
||||
} catch (e) {
|
||||
document.getElementById('task-list').innerHTML = `<div class="empty-state">Failed to load tasks</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskList(tasks) {
|
||||
const container = document.getElementById('task-list');
|
||||
document.getElementById('task-detail').classList.add('hidden');
|
||||
container.classList.remove('hidden');
|
||||
if (!tasks.length) {
|
||||
container.innerHTML = `<div class="empty-state">No tasks found</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = tasks.map(t => {
|
||||
const subCount = t.subTasks ? t.subTasks.length : 0;
|
||||
return `
|
||||
<div class="task-item" data-id="${t.id}">
|
||||
<div class="task-item-left">
|
||||
<span class="badge badge-${t.status.toLowerCase()}">${t.status}</span>
|
||||
<span class="task-item-title">${esc(t.title)}</span>
|
||||
${subCount > 0 ? `<span class="subtask-count">${subCount} subtask${subCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
</div>
|
||||
<div class="task-item-meta">${t.category ? esc(t.category) + ' · ' : ''}${formatDate(t.createdAt)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.querySelectorAll('.task-item').forEach(item => {
|
||||
item.addEventListener('click', () => showTaskDetail(parseInt(item.dataset.id)));
|
||||
});
|
||||
}
|
||||
|
||||
async function buildBreadcrumbs(task) {
|
||||
const trail = [{ id: task.id, title: task.title }];
|
||||
let current = task;
|
||||
while (current.parentTaskId) {
|
||||
try {
|
||||
current = await api.tasks.get(current.parentTaskId);
|
||||
trail.unshift({ id: current.id, title: current.title });
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
async function showTaskDetail(id) {
|
||||
selectedTaskId = id;
|
||||
try {
|
||||
const task = await api.tasks.get(id);
|
||||
const container = document.getElementById('task-detail');
|
||||
document.getElementById('task-list').classList.add('hidden');
|
||||
container.classList.remove('hidden');
|
||||
|
||||
// Build breadcrumb trail
|
||||
const breadcrumbs = await buildBreadcrumbs(task);
|
||||
const breadcrumbHtml = breadcrumbs.length > 1
|
||||
? `<div class="breadcrumb">${breadcrumbs.map((b, i) =>
|
||||
i < breadcrumbs.length - 1
|
||||
? `<a href="#" class="breadcrumb-link" data-id="${b.id}">${esc(b.title)}</a><span class="breadcrumb-sep">/</span>`
|
||||
: `<span class="breadcrumb-current">${esc(b.title)}</span>`
|
||||
).join('')}</div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="btn btn-sm mb-16" id="btn-back-tasks">← ${task.parentTaskId ? 'Back to parent' : 'Back to list'}</button>
|
||||
${breadcrumbHtml}
|
||||
<div class="task-detail">
|
||||
<div class="task-detail-header">
|
||||
<div>
|
||||
<div class="task-detail-title">${esc(task.title)}</div>
|
||||
<span class="badge badge-${task.status.toLowerCase()}">${task.status}</span>
|
||||
</div>
|
||||
<div class="btn-group" id="task-actions"></div>
|
||||
</div>
|
||||
${task.description ? `<p class="text-muted mb-16">${esc(task.description)}</p>` : ''}
|
||||
<div class="task-meta-grid card">
|
||||
<div class="meta-item"><div class="meta-label">Category</div>${esc(task.category) || 'None'}</div>
|
||||
<div class="meta-item"><div class="meta-label">Created</div>${formatDateTime(task.createdAt)}</div>
|
||||
<div class="meta-item"><div class="meta-label">Started</div>${task.startedAt ? formatDateTime(task.startedAt) : 'Not started'}</div>
|
||||
<div class="meta-item"><div class="meta-label">Completed</div>${task.completedAt ? formatDateTime(task.completedAt) : '-'}</div>
|
||||
</div>
|
||||
<div class="section-title mt-16">Subtasks</div>
|
||||
<div id="task-subtasks" class="subtask-list"></div>
|
||||
${task.status !== 'Completed' && task.status !== 'Abandoned' ? `<button class="btn btn-sm btn-primary mt-8" id="btn-add-subtask">+ Add Subtask</button>` : ''}
|
||||
<div class="section-title mt-16">Notes</div>
|
||||
<div id="task-notes"></div>
|
||||
<div class="form-inline mt-8">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-input" id="note-input" placeholder="Add a note...">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-add-note">Add</button>
|
||||
</div>
|
||||
${task.contextEvents && task.contextEvents.length ? `
|
||||
<div class="section-title mt-16">Linked Context Events</div>
|
||||
<div class="table-wrap card">
|
||||
<table>
|
||||
<thead><tr><th>App</th><th>Title</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
${task.contextEvents.slice(0, 50).map(e => `
|
||||
<tr>
|
||||
<td>${esc(e.appName)}</td>
|
||||
<td class="truncate">${esc(e.windowTitle)}</td>
|
||||
<td>${formatTime(e.timestamp)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
// Render action buttons based on status
|
||||
renderActions(task);
|
||||
|
||||
// Render subtasks
|
||||
renderSubTasks(task.subTasks || []);
|
||||
|
||||
// Render notes
|
||||
renderNotes(task.notes || []);
|
||||
|
||||
// Breadcrumb navigation
|
||||
container.querySelectorAll('.breadcrumb-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showTaskDetail(parseInt(link.dataset.id));
|
||||
});
|
||||
});
|
||||
|
||||
// Back button
|
||||
document.getElementById('btn-back-tasks').addEventListener('click', () => {
|
||||
if (task.parentTaskId) {
|
||||
showTaskDetail(task.parentTaskId);
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
document.getElementById('task-list').classList.remove('hidden');
|
||||
loadTasks();
|
||||
}
|
||||
});
|
||||
|
||||
// Add subtask button
|
||||
const addSubBtn = document.getElementById('btn-add-subtask');
|
||||
if (addSubBtn) {
|
||||
addSubBtn.addEventListener('click', () => showNewTaskModal(task.id));
|
||||
}
|
||||
|
||||
// Add note
|
||||
const noteInput = document.getElementById('note-input');
|
||||
document.getElementById('btn-add-note').addEventListener('click', () => addNote(task.id, noteInput));
|
||||
noteInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addNote(task.id, noteInput);
|
||||
});
|
||||
} catch (e) {
|
||||
alert('Failed to load task: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubTasks(subTasks) {
|
||||
const container = document.getElementById('task-subtasks');
|
||||
if (!subTasks.length) {
|
||||
container.innerHTML = `<div class="text-muted text-sm">No subtasks</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = subTasks.map(st => {
|
||||
const subCount = st.subTasks ? st.subTasks.length : 0;
|
||||
const canStart = st.status === 'Pending' || st.status === 'Paused';
|
||||
const canComplete = st.status === 'Active' || st.status === 'Paused';
|
||||
return `
|
||||
<div class="subtask-item" data-id="${st.id}">
|
||||
<div class="subtask-item-left">
|
||||
<span class="badge badge-${st.status.toLowerCase()}">${st.status}</span>
|
||||
<a href="#" class="subtask-item-title" data-id="${st.id}">${esc(st.title)}</a>
|
||||
${subCount > 0 ? `<span class="subtask-count">${subCount}</span>` : ''}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
${canStart ? `<button class="btn btn-sm btn-success subtask-action" data-action="start" data-id="${st.id}">${st.status === 'Paused' ? 'Resume' : 'Start'}</button>` : ''}
|
||||
${canComplete ? `<button class="btn btn-sm btn-success subtask-action" data-action="complete" data-id="${st.id}">Complete</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Navigate to subtask detail
|
||||
container.querySelectorAll('.subtask-item-title').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showTaskDetail(parseInt(link.dataset.id));
|
||||
});
|
||||
});
|
||||
|
||||
// Inline subtask actions
|
||||
container.querySelectorAll('.subtask-action').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const stId = parseInt(btn.dataset.id);
|
||||
try {
|
||||
if (action === 'start') await api.tasks.start(stId);
|
||||
else if (action === 'complete') await api.tasks.complete(stId);
|
||||
showTaskDetail(selectedTaskId);
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderActions(task) {
|
||||
const container = document.getElementById('task-actions');
|
||||
const actions = [];
|
||||
switch (task.status) {
|
||||
case 'Pending':
|
||||
actions.push({ label: 'Start', cls: 'btn-success', action: () => api.tasks.start(task.id) });
|
||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
||||
break;
|
||||
case 'Active':
|
||||
actions.push({ label: 'Pause', cls: 'btn-warning', action: () => api.tasks.pause(task.id) });
|
||||
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
|
||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
||||
break;
|
||||
case 'Paused':
|
||||
actions.push({ label: 'Resume', cls: 'btn-success', action: () => api.tasks.resume(task.id) });
|
||||
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
|
||||
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
|
||||
break;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
actions.forEach(({ label, cls, action }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `btn btn-sm ${cls}`;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await action();
|
||||
showTaskDetail(task.id);
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const container = document.getElementById('task-notes');
|
||||
if (!notes.length) {
|
||||
container.innerHTML = `<div class="text-muted text-sm">No notes yet</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notes.map(n => `
|
||||
<div class="note-item">
|
||||
<div class="note-item-header">
|
||||
<span class="note-type">${n.type}</span>
|
||||
<span class="note-time">${formatDateTime(n.createdAt)}</span>
|
||||
</div>
|
||||
<div>${esc(n.content)}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function addNote(taskId, input) {
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
try {
|
||||
await api.notes.create(taskId, { content, type: 'General' });
|
||||
input.value = '';
|
||||
showTaskDetail(taskId);
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
function showNewTaskModal(parentTaskId = null) {
|
||||
const title = parentTaskId ? 'New Subtask' : 'New Task';
|
||||
showModal(title, `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" class="form-input" id="new-task-title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-textarea" id="new-task-desc"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Category</label>
|
||||
<input type="text" class="form-input" id="new-task-cat">
|
||||
</div>`,
|
||||
[
|
||||
{ label: 'Cancel', onClick: () => {} },
|
||||
{
|
||||
label: 'Create', cls: 'btn-primary', onClick: async (modal) => {
|
||||
const taskTitle = modal.querySelector('#new-task-title').value.trim();
|
||||
if (!taskTitle) { alert('Title is required'); throw new Error('cancel'); }
|
||||
const description = modal.querySelector('#new-task-desc').value.trim() || null;
|
||||
const category = modal.querySelector('#new-task-cat').value.trim() || null;
|
||||
const body = { title: taskTitle, description, category };
|
||||
if (parentTaskId) body.parentTaskId = parentTaskId;
|
||||
await api.tasks.create(body);
|
||||
if (parentTaskId) {
|
||||
showTaskDetail(parentTaskId);
|
||||
} else {
|
||||
loadTasks();
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
setTimeout(() => document.getElementById('new-task-title')?.focus(), 100);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDateTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
20
TaskTracker.Core/DTOs/ApiResponse.cs
Normal file
20
TaskTracker.Core/DTOs/ApiResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public T? Data { get; set; }
|
||||
public string? Error { get; set; }
|
||||
|
||||
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
|
||||
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
|
||||
}
|
||||
|
||||
public class ApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
|
||||
public static ApiResponse Ok() => new() { Success = true };
|
||||
public static ApiResponse Fail(string error) => new() { Success = false, Error = error };
|
||||
}
|
||||
9
TaskTracker.Core/DTOs/ContextEventRequest.cs
Normal file
9
TaskTracker.Core/DTOs/ContextEventRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class ContextEventRequest
|
||||
{
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
public string WindowTitle { get; set; } = string.Empty;
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
10
TaskTracker.Core/DTOs/ContextSummaryItem.cs
Normal file
10
TaskTracker.Core/DTOs/ContextSummaryItem.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class ContextSummaryItem
|
||||
{
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int EventCount { get; set; }
|
||||
public DateTime FirstSeen { get; set; }
|
||||
public DateTime LastSeen { get; set; }
|
||||
}
|
||||
9
TaskTracker.Core/DTOs/CreateAppMappingRequest.cs
Normal file
9
TaskTracker.Core/DTOs/CreateAppMappingRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class CreateAppMappingRequest
|
||||
{
|
||||
public string Pattern { get; set; } = string.Empty;
|
||||
public string MatchType { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string? FriendlyName { get; set; }
|
||||
}
|
||||
9
TaskTracker.Core/DTOs/CreateNoteRequest.cs
Normal file
9
TaskTracker.Core/DTOs/CreateNoteRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using TaskTracker.Core.Enums;
|
||||
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class CreateNoteRequest
|
||||
{
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public NoteType Type { get; set; } = NoteType.General;
|
||||
}
|
||||
9
TaskTracker.Core/DTOs/CreateTaskRequest.cs
Normal file
9
TaskTracker.Core/DTOs/CreateTaskRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class CreateTaskRequest
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public int? ParentTaskId { get; set; }
|
||||
}
|
||||
6
TaskTracker.Core/DTOs/TaskActionRequest.cs
Normal file
6
TaskTracker.Core/DTOs/TaskActionRequest.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class TaskActionRequest
|
||||
{
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
10
TaskTracker.Core/Entities/AppMapping.cs
Normal file
10
TaskTracker.Core/Entities/AppMapping.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TaskTracker.Core.Entities;
|
||||
|
||||
public class AppMapping
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Pattern { get; set; } = string.Empty;
|
||||
public string MatchType { get; set; } = string.Empty; // ProcessName, UrlContains, TitleContains
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string? FriendlyName { get; set; }
|
||||
}
|
||||
14
TaskTracker.Core/Entities/ContextEvent.cs
Normal file
14
TaskTracker.Core/Entities/ContextEvent.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace TaskTracker.Core.Entities;
|
||||
|
||||
public class ContextEvent
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? WorkTaskId { get; set; }
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
public string WindowTitle { get; set; } = string.Empty;
|
||||
public string? Url { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public WorkTask? WorkTask { get; set; }
|
||||
}
|
||||
14
TaskTracker.Core/Entities/TaskNote.cs
Normal file
14
TaskTracker.Core/Entities/TaskNote.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using TaskTracker.Core.Enums;
|
||||
|
||||
namespace TaskTracker.Core.Entities;
|
||||
|
||||
public class TaskNote
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkTaskId { get; set; }
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public NoteType Type { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public WorkTask WorkTask { get; set; } = null!;
|
||||
}
|
||||
22
TaskTracker.Core/Entities/WorkTask.cs
Normal file
22
TaskTracker.Core/Entities/WorkTask.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using TaskTracker.Core.Enums;
|
||||
|
||||
namespace TaskTracker.Core.Entities;
|
||||
|
||||
public class WorkTask
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public WorkTaskStatus Status { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
public int? ParentTaskId { get; set; }
|
||||
public WorkTask? ParentTask { get; set; }
|
||||
public List<WorkTask> SubTasks { get; set; } = new();
|
||||
|
||||
public List<TaskNote> Notes { get; set; } = [];
|
||||
public List<ContextEvent> ContextEvents { get; set; } = [];
|
||||
}
|
||||
8
TaskTracker.Core/Enums/NoteType.cs
Normal file
8
TaskTracker.Core/Enums/NoteType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TaskTracker.Core.Enums;
|
||||
|
||||
public enum NoteType
|
||||
{
|
||||
PauseNote,
|
||||
ResumeNote,
|
||||
General
|
||||
}
|
||||
10
TaskTracker.Core/Enums/TaskStatus.cs
Normal file
10
TaskTracker.Core/Enums/TaskStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TaskTracker.Core.Enums;
|
||||
|
||||
public enum WorkTaskStatus
|
||||
{
|
||||
Pending,
|
||||
Active,
|
||||
Paused,
|
||||
Completed,
|
||||
Abandoned
|
||||
}
|
||||
13
TaskTracker.Core/Interfaces/IAppMappingRepository.cs
Normal file
13
TaskTracker.Core/Interfaces/IAppMappingRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using TaskTracker.Core.Entities;
|
||||
|
||||
namespace TaskTracker.Core.Interfaces;
|
||||
|
||||
public interface IAppMappingRepository
|
||||
{
|
||||
Task<List<AppMapping>> GetAllAsync();
|
||||
Task<AppMapping?> GetByIdAsync(int id);
|
||||
Task<AppMapping> CreateAsync(AppMapping mapping);
|
||||
Task UpdateAsync(AppMapping mapping);
|
||||
Task DeleteAsync(int id);
|
||||
Task<AppMapping?> FindMatchAsync(string appName, string windowTitle, string? url);
|
||||
}
|
||||
10
TaskTracker.Core/Interfaces/IContextEventRepository.cs
Normal file
10
TaskTracker.Core/Interfaces/IContextEventRepository.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using TaskTracker.Core.Entities;
|
||||
|
||||
namespace TaskTracker.Core.Interfaces;
|
||||
|
||||
public interface IContextEventRepository
|
||||
{
|
||||
Task<ContextEvent> CreateAsync(ContextEvent contextEvent);
|
||||
Task<List<ContextEvent>> GetRecentAsync(int minutes = 30);
|
||||
Task<List<ContextEvent>> GetByTaskIdAsync(int taskId);
|
||||
}
|
||||
15
TaskTracker.Core/Interfaces/ITaskRepository.cs
Normal file
15
TaskTracker.Core/Interfaces/ITaskRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Enums;
|
||||
|
||||
namespace TaskTracker.Core.Interfaces;
|
||||
|
||||
public interface ITaskRepository
|
||||
{
|
||||
Task<List<WorkTask>> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false);
|
||||
Task<WorkTask?> GetByIdAsync(int id);
|
||||
Task<WorkTask?> GetActiveTaskAsync();
|
||||
Task<List<WorkTask>> GetSubTasksAsync(int parentId);
|
||||
Task<WorkTask> CreateAsync(WorkTask task);
|
||||
Task UpdateAsync(WorkTask task);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
9
TaskTracker.Core/TaskTracker.Core.csproj
Normal file
9
TaskTracker.Core/TaskTracker.Core.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
62
TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs
Normal file
62
TaskTracker.Infrastructure/Data/TaskTrackerDbContext.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Core.Entities;
|
||||
|
||||
namespace TaskTracker.Infrastructure.Data;
|
||||
|
||||
public class TaskTrackerDbContext(DbContextOptions<TaskTrackerDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
||||
public DbSet<TaskNote> Notes => Set<TaskNote>();
|
||||
public DbSet<ContextEvent> ContextEvents => Set<ContextEvent>();
|
||||
public DbSet<AppMapping> AppMappings => Set<AppMapping>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<WorkTask>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
e.Property(t => t.Title).HasMaxLength(500).IsRequired();
|
||||
e.Property(t => t.Status).HasConversion<string>().HasMaxLength(50);
|
||||
e.Property(t => t.Category).HasMaxLength(100);
|
||||
e.HasMany(t => t.SubTasks).WithOne(t => t.ParentTask).HasForeignKey(t => t.ParentTaskId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasIndex(t => t.ParentTaskId);
|
||||
e.HasMany(t => t.Notes).WithOne(n => n.WorkTask).HasForeignKey(n => n.WorkTaskId);
|
||||
e.HasMany(t => t.ContextEvents).WithOne(c => c.WorkTask).HasForeignKey(c => c.WorkTaskId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TaskNote>(e =>
|
||||
{
|
||||
e.HasKey(n => n.Id);
|
||||
e.Property(n => n.Content).IsRequired();
|
||||
e.Property(n => n.Type).HasConversion<string>().HasMaxLength(50);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContextEvent>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.Property(c => c.Source).HasMaxLength(100).IsRequired();
|
||||
e.Property(c => c.AppName).HasMaxLength(200).IsRequired();
|
||||
e.Property(c => c.WindowTitle).HasMaxLength(1000).IsRequired();
|
||||
e.Property(c => c.Url).HasMaxLength(2000);
|
||||
e.HasIndex(c => c.Timestamp);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AppMapping>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.Property(m => m.Pattern).HasMaxLength(500).IsRequired();
|
||||
e.Property(m => m.MatchType).HasMaxLength(50).IsRequired();
|
||||
e.Property(m => m.Category).HasMaxLength(100).IsRequired();
|
||||
e.Property(m => m.FriendlyName).HasMaxLength(200);
|
||||
});
|
||||
|
||||
// Seed default app mappings
|
||||
modelBuilder.Entity<AppMapping>().HasData(
|
||||
new AppMapping { Id = 1, Pattern = "SLDWORKS", MatchType = "ProcessName", Category = "Engineering", FriendlyName = "SolidWorks" },
|
||||
new AppMapping { Id = 2, Pattern = "OUTLOOK", MatchType = "ProcessName", Category = "Email", FriendlyName = "Outlook" },
|
||||
new AppMapping { Id = 3, Pattern = "notepad", MatchType = "ProcessName", Category = "General", FriendlyName = "Notepad" },
|
||||
new AppMapping { Id = 4, Pattern = "pep", MatchType = "UrlContains", Category = "LaserCutting", FriendlyName = "PEP System" },
|
||||
new AppMapping { Id = 5, Pattern = "solidworks", MatchType = "TitleContains", Category = "Engineering", FriendlyName = "SolidWorks" }
|
||||
);
|
||||
}
|
||||
}
|
||||
242
TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.Designer.cs
generated
Normal file
242
TaskTracker.Infrastructure/Migrations/20260226032729_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,242 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(TaskTrackerDbContext))]
|
||||
[Migration("20260226032729_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("MatchType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppMappings");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "SLDWORKS"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
Category = "Email",
|
||||
FriendlyName = "Outlook",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "OUTLOOK"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
Category = "General",
|
||||
FriendlyName = "Notepad",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "notepad"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
Category = "LaserCutting",
|
||||
FriendlyName = "PEP System",
|
||||
MatchType = "UrlContains",
|
||||
Pattern = "pep"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "TitleContains",
|
||||
Pattern = "solidworks"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AppName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("WindowTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("ContextEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("Notes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("ContextEvents")
|
||||
.HasForeignKey("WorkTaskId");
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("Notes")
|
||||
.HasForeignKey("WorkTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ContextEvents");
|
||||
|
||||
b.Navigation("Notes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppMappings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Pattern = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
MatchType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
FriendlyName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppMappings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Title = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ContextEvents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
WorkTaskId = table.Column<int>(type: "int", nullable: true),
|
||||
Source = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
AppName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
WindowTitle = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
Url = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
Timestamp = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ContextEvents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ContextEvents_Tasks_WorkTaskId",
|
||||
column: x => x.WorkTaskId,
|
||||
principalTable: "Tasks",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Notes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
WorkTaskId = table.Column<int>(type: "int", nullable: false),
|
||||
Content = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Type = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Notes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Notes_Tasks_WorkTaskId",
|
||||
column: x => x.WorkTaskId,
|
||||
principalTable: "Tasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "AppMappings",
|
||||
columns: new[] { "Id", "Category", "FriendlyName", "MatchType", "Pattern" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "Engineering", "SolidWorks", "ProcessName", "SLDWORKS" },
|
||||
{ 2, "Email", "Outlook", "ProcessName", "OUTLOOK" },
|
||||
{ 3, "General", "Notepad", "ProcessName", "notepad" },
|
||||
{ 4, "LaserCutting", "PEP System", "UrlContains", "pep" },
|
||||
{ 5, "Engineering", "SolidWorks", "TitleContains", "solidworks" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContextEvents_Timestamp",
|
||||
table: "ContextEvents",
|
||||
column: "Timestamp");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContextEvents_WorkTaskId",
|
||||
table: "ContextEvents",
|
||||
column: "WorkTaskId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notes_WorkTaskId",
|
||||
table: "Notes",
|
||||
column: "WorkTaskId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppMappings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ContextEvents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Notes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
259
TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.Designer.cs
generated
Normal file
259
TaskTracker.Infrastructure/Migrations/20260227013459_AddSubTasks.Designer.cs
generated
Normal file
@@ -0,0 +1,259 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(TaskTrackerDbContext))]
|
||||
[Migration("20260227013459_AddSubTasks")]
|
||||
partial class AddSubTasks
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("MatchType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppMappings");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "SLDWORKS"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
Category = "Email",
|
||||
FriendlyName = "Outlook",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "OUTLOOK"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
Category = "General",
|
||||
FriendlyName = "Notepad",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "notepad"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
Category = "LaserCutting",
|
||||
FriendlyName = "PEP System",
|
||||
MatchType = "UrlContains",
|
||||
Pattern = "pep"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "TitleContains",
|
||||
Pattern = "solidworks"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AppName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("WindowTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("ContextEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("Notes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("ContextEvents")
|
||||
.HasForeignKey("WorkTaskId");
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("Notes")
|
||||
.HasForeignKey("WorkTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "ParentTask")
|
||||
.WithMany("SubTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ContextEvents");
|
||||
|
||||
b.Navigation("Notes");
|
||||
|
||||
b.Navigation("SubTasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSubTasks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ParentTaskId",
|
||||
table: "Tasks",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_ParentTaskId",
|
||||
table: "Tasks",
|
||||
column: "ParentTaskId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||
table: "Tasks",
|
||||
column: "ParentTaskId",
|
||||
principalTable: "Tasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_ParentTaskId",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParentTaskId",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TaskTracker.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(TaskTrackerDbContext))]
|
||||
partial class TaskTrackerDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.3")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.AppMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("MatchType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Pattern")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppMappings");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "SLDWORKS"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
Category = "Email",
|
||||
FriendlyName = "Outlook",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "OUTLOOK"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
Category = "General",
|
||||
FriendlyName = "Notepad",
|
||||
MatchType = "ProcessName",
|
||||
Pattern = "notepad"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
Category = "LaserCutting",
|
||||
FriendlyName = "PEP System",
|
||||
MatchType = "UrlContains",
|
||||
Pattern = "pep"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5,
|
||||
Category = "Engineering",
|
||||
FriendlyName = "SolidWorks",
|
||||
MatchType = "TitleContains",
|
||||
Pattern = "solidworks"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AppName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("WindowTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("ContextEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("WorkTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkTaskId");
|
||||
|
||||
b.ToTable("Notes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.ContextEvent", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("ContextEvents")
|
||||
.HasForeignKey("WorkTaskId");
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.TaskNote", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "WorkTask")
|
||||
.WithMany("Notes")
|
||||
.HasForeignKey("WorkTaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.HasOne("TaskTracker.Core.Entities.WorkTask", "ParentTask")
|
||||
.WithMany("SubTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TaskTracker.Core.Entities.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ContextEvents");
|
||||
|
||||
b.Navigation("Notes");
|
||||
|
||||
b.Navigation("SubTasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
namespace TaskTracker.Infrastructure.Repositories;
|
||||
|
||||
public class AppMappingRepository(TaskTrackerDbContext db) : IAppMappingRepository
|
||||
{
|
||||
public async Task<List<AppMapping>> GetAllAsync()
|
||||
{
|
||||
return await db.AppMappings.OrderBy(m => m.Category).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppMapping?> GetByIdAsync(int id)
|
||||
{
|
||||
return await db.AppMappings.FindAsync(id);
|
||||
}
|
||||
|
||||
public async Task<AppMapping> CreateAsync(AppMapping mapping)
|
||||
{
|
||||
db.AppMappings.Add(mapping);
|
||||
await db.SaveChangesAsync();
|
||||
return mapping;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(AppMapping mapping)
|
||||
{
|
||||
db.AppMappings.Update(mapping);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var mapping = await db.AppMappings.FindAsync(id);
|
||||
if (mapping is not null)
|
||||
{
|
||||
db.AppMappings.Remove(mapping);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AppMapping?> FindMatchAsync(string appName, string windowTitle, string? url)
|
||||
{
|
||||
var mappings = await db.AppMappings.ToListAsync();
|
||||
|
||||
return mappings.FirstOrDefault(m => m.MatchType switch
|
||||
{
|
||||
"ProcessName" => appName.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
|
||||
"TitleContains" => windowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
|
||||
"UrlContains" => url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true,
|
||||
_ => false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
namespace TaskTracker.Infrastructure.Repositories;
|
||||
|
||||
public class ContextEventRepository(TaskTrackerDbContext db) : IContextEventRepository
|
||||
{
|
||||
public async Task<ContextEvent> CreateAsync(ContextEvent contextEvent)
|
||||
{
|
||||
contextEvent.Timestamp = DateTime.UtcNow;
|
||||
db.ContextEvents.Add(contextEvent);
|
||||
await db.SaveChangesAsync();
|
||||
return contextEvent;
|
||||
}
|
||||
|
||||
public async Task<List<ContextEvent>> GetRecentAsync(int minutes = 30)
|
||||
{
|
||||
var since = DateTime.UtcNow.AddMinutes(-minutes);
|
||||
return await db.ContextEvents
|
||||
.Where(c => c.Timestamp >= since)
|
||||
.OrderByDescending(c => c.Timestamp)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<ContextEvent>> GetByTaskIdAsync(int taskId)
|
||||
{
|
||||
return await db.ContextEvents
|
||||
.Where(c => c.WorkTaskId == taskId)
|
||||
.OrderByDescending(c => c.Timestamp)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
89
TaskTracker.Infrastructure/Repositories/TaskRepository.cs
Normal file
89
TaskTracker.Infrastructure/Repositories/TaskRepository.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Enums;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
|
||||
namespace TaskTracker.Infrastructure.Repositories;
|
||||
|
||||
public class TaskRepository(TaskTrackerDbContext db) : ITaskRepository
|
||||
{
|
||||
public async Task<List<WorkTask>> GetAllAsync(WorkTaskStatus? status = null, int? parentId = null, bool includeSubTasks = false)
|
||||
{
|
||||
var query = db.Tasks.Include(t => t.Notes).Include(t => t.SubTasks).AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(t => t.Status == status.Value);
|
||||
|
||||
if (parentId.HasValue)
|
||||
query = query.Where(t => t.ParentTaskId == parentId.Value);
|
||||
else if (!includeSubTasks)
|
||||
query = query.Where(t => t.ParentTaskId == null);
|
||||
|
||||
return await query.OrderByDescending(t => t.CreatedAt).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkTask?> GetByIdAsync(int id)
|
||||
{
|
||||
return await db.Tasks
|
||||
.Include(t => t.Notes.OrderByDescending(n => n.CreatedAt))
|
||||
.Include(t => t.ContextEvents.OrderByDescending(c => c.Timestamp).Take(20))
|
||||
.Include(t => t.SubTasks)
|
||||
.Include(t => t.ParentTask)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
}
|
||||
|
||||
public async Task<WorkTask?> GetActiveTaskAsync()
|
||||
{
|
||||
return await db.Tasks
|
||||
.Include(t => t.Notes.OrderByDescending(n => n.CreatedAt))
|
||||
.Include(t => t.ContextEvents.OrderByDescending(c => c.Timestamp).Take(20))
|
||||
.Include(t => t.SubTasks)
|
||||
.Include(t => t.ParentTask)
|
||||
.FirstOrDefaultAsync(t => t.Status == WorkTaskStatus.Active);
|
||||
}
|
||||
|
||||
public async Task<List<WorkTask>> GetSubTasksAsync(int parentId)
|
||||
{
|
||||
return await db.Tasks
|
||||
.Include(t => t.SubTasks)
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkTask> CreateAsync(WorkTask task)
|
||||
{
|
||||
task.CreatedAt = DateTime.UtcNow;
|
||||
db.Tasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(WorkTask task)
|
||||
{
|
||||
db.Tasks.Update(task);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var task = await db.Tasks.Include(t => t.SubTasks).FirstOrDefaultAsync(t => t.Id == id);
|
||||
if (task is not null)
|
||||
{
|
||||
await AbandonDescendantsAsync(task);
|
||||
task.Status = WorkTaskStatus.Abandoned;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task AbandonDescendantsAsync(WorkTask task)
|
||||
{
|
||||
var children = await db.Tasks.Include(t => t.SubTasks).Where(t => t.ParentTaskId == task.Id).ToListAsync();
|
||||
foreach (var child in children)
|
||||
{
|
||||
await AbandonDescendantsAsync(child);
|
||||
child.Status = WorkTaskStatus.Abandoned;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj
Normal file
21
TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TaskTracker.Core\TaskTracker.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
17
TaskTracker.MCP/Program.cs
Normal file
17
TaskTracker.MCP/Program.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
var apiBaseUrl = Environment.GetEnvironmentVariable("API_BASE_URL") ?? "http://localhost:5200";
|
||||
|
||||
builder.Services.AddSingleton(_ => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(apiBaseUrl)
|
||||
});
|
||||
|
||||
builder.Services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
16
TaskTracker.MCP/TaskTracker.MCP.csproj
Normal file
16
TaskTracker.MCP/TaskTracker.MCP.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
116
TaskTracker.MCP/Tools/TaskTools.cs
Normal file
116
TaskTracker.MCP/Tools/TaskTools.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.ComponentModel;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace TaskTracker.MCP.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class TaskTools
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
[McpServerTool, Description("Get the currently active work task with its latest notes and recent context events")]
|
||||
public static async Task<string> GetActiveTask(HttpClient client)
|
||||
{
|
||||
var response = await client.GetAsync("/api/tasks/active");
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List work tasks, optionally filtered by status (Pending, Active, Paused, Completed, Abandoned)")]
|
||||
public static async Task<string> ListTasks(
|
||||
HttpClient client,
|
||||
[Description("Filter by status: Pending, Active, Paused, Completed, Abandoned")] string? status = null)
|
||||
{
|
||||
var url = "/api/tasks";
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
url += $"?status={status}";
|
||||
|
||||
var response = await client.GetAsync(url);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Create a new work task")]
|
||||
public static async Task<string> CreateTask(
|
||||
HttpClient client,
|
||||
[Description("Title of the task")] string title,
|
||||
[Description("Optional description")] string? description = null,
|
||||
[Description("Optional category (e.g. Engineering, Email, LaserCutting)")] string? category = null)
|
||||
{
|
||||
var payload = new { title, description, category };
|
||||
var response = await client.PostAsJsonAsync("/api/tasks", payload, JsonOptions);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Start/activate a task by ID. Automatically pauses any currently active task.")]
|
||||
public static async Task<string> StartTask(
|
||||
HttpClient client,
|
||||
[Description("The task ID to start")] int taskId)
|
||||
{
|
||||
var response = await client.PutAsync($"/api/tasks/{taskId}/start", null);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Pause a task with an optional note about why it was paused")]
|
||||
public static async Task<string> PauseTask(
|
||||
HttpClient client,
|
||||
[Description("The task ID to pause")] int taskId,
|
||||
[Description("Optional note about why the task is being paused")] string? note = null)
|
||||
{
|
||||
var payload = new { note };
|
||||
var response = await client.PutAsJsonAsync($"/api/tasks/{taskId}/pause", payload, JsonOptions);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Resume a paused task with an optional note about context for resuming")]
|
||||
public static async Task<string> ResumeTask(
|
||||
HttpClient client,
|
||||
[Description("The task ID to resume")] int taskId,
|
||||
[Description("Optional note about context for resuming")] string? note = null)
|
||||
{
|
||||
var payload = new { note };
|
||||
var response = await client.PutAsJsonAsync($"/api/tasks/{taskId}/resume", payload, JsonOptions);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Mark a task as complete")]
|
||||
public static async Task<string> CompleteTask(
|
||||
HttpClient client,
|
||||
[Description("The task ID to complete")] int taskId)
|
||||
{
|
||||
var response = await client.PutAsync($"/api/tasks/{taskId}/complete", null);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Add a note to a task")]
|
||||
public static async Task<string> AddNote(
|
||||
HttpClient client,
|
||||
[Description("The task ID")] int taskId,
|
||||
[Description("The note content")] string content,
|
||||
[Description("Note type: General, PauseNote, or ResumeNote")] string type = "General")
|
||||
{
|
||||
var payload = new { content, type };
|
||||
var response = await client.PostAsJsonAsync($"/api/tasks/{taskId}/notes", payload, JsonOptions);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a summary of recent window/browser activity grouped by application, useful for understanding what the user has been working on")]
|
||||
public static async Task<string> GetContextSummary(HttpClient client)
|
||||
{
|
||||
var response = await client.GetAsync("/api/context/summary");
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get raw context events (window switches, browser navigations) from the last N minutes")]
|
||||
public static async Task<string> GetRecentContext(
|
||||
HttpClient client,
|
||||
[Description("Number of minutes to look back (default 30)")] int minutes = 30)
|
||||
{
|
||||
var response = await client.GetAsync($"/api/context/recent?minutes={minutes}");
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
}
|
||||
16
WindowWatcher/NativeMethods.cs
Normal file
16
WindowWatcher/NativeMethods.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace WindowWatcher;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
internal static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
|
||||
[LibraryImport("user32.dll", SetLastError = true)]
|
||||
internal static partial uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
}
|
||||
32
WindowWatcher/Program.cs
Normal file
32
WindowWatcher/Program.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using WindowWatcher;
|
||||
|
||||
var hostCts = new CancellationTokenSource();
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.Configure<WindowWatcherOptions>(
|
||||
builder.Configuration.GetSection(WindowWatcherOptions.SectionName));
|
||||
|
||||
builder.Services.AddHttpClient("TaskTrackerApi", (sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<WindowWatcherOptions>>().Value;
|
||||
client.BaseAddress = new Uri(config.ApiBaseUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
// Run the host in a background thread
|
||||
var hostTask = Task.Run(() => host.RunAsync(hostCts.Token));
|
||||
|
||||
// Run WinForms tray icon on the main thread
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new TrayApplicationContext(hostCts));
|
||||
|
||||
// Wait for host to finish
|
||||
await hostTask;
|
||||
12
WindowWatcher/Properties/launchSettings.json
Normal file
12
WindowWatcher/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"WindowWatcher": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
WindowWatcher/TrayApplicationContext.cs
Normal file
58
WindowWatcher/TrayApplicationContext.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace WindowWatcher;
|
||||
|
||||
public class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly NotifyIcon _trayIcon;
|
||||
private readonly CancellationTokenSource _hostCts;
|
||||
private bool _trackingPaused;
|
||||
|
||||
public TrayApplicationContext(CancellationTokenSource hostCts)
|
||||
{
|
||||
_hostCts = hostCts;
|
||||
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = SystemIcons.Application,
|
||||
Text = "Work Context Tracker",
|
||||
Visible = true,
|
||||
ContextMenuStrip = CreateMenu()
|
||||
};
|
||||
}
|
||||
|
||||
private ContextMenuStrip CreateMenu()
|
||||
{
|
||||
var menu = new ContextMenuStrip();
|
||||
|
||||
var pauseItem = new ToolStripMenuItem("Pause Tracking");
|
||||
pauseItem.Click += (_, _) =>
|
||||
{
|
||||
_trackingPaused = !_trackingPaused;
|
||||
pauseItem.Text = _trackingPaused ? "Resume Tracking" : "Pause Tracking";
|
||||
_trayIcon.Text = _trackingPaused ? "Work Context Tracker (Paused)" : "Work Context Tracker";
|
||||
};
|
||||
|
||||
var exitItem = new ToolStripMenuItem("Exit");
|
||||
exitItem.Click += (_, _) =>
|
||||
{
|
||||
_trayIcon.Visible = false;
|
||||
_hostCts.Cancel();
|
||||
Application.Exit();
|
||||
};
|
||||
|
||||
menu.Items.Add(pauseItem);
|
||||
menu.Items.Add(new ToolStripSeparator());
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_trayIcon.Visible = false;
|
||||
_trayIcon.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
17
WindowWatcher/WindowWatcher.csproj
Normal file
17
WindowWatcher/WindowWatcher.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
WindowWatcher/WindowWatcherOptions.cs
Normal file
10
WindowWatcher/WindowWatcherOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace WindowWatcher;
|
||||
|
||||
public class WindowWatcherOptions
|
||||
{
|
||||
public const string SectionName = "WindowWatcher";
|
||||
|
||||
public string ApiBaseUrl { get; set; } = "http://localhost:5200";
|
||||
public int PollIntervalMs { get; set; } = 2000;
|
||||
public int DebounceMs { get; set; } = 3000;
|
||||
}
|
||||
111
WindowWatcher/Worker.cs
Normal file
111
WindowWatcher/Worker.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace WindowWatcher;
|
||||
|
||||
public class Worker(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<WindowWatcherOptions> options,
|
||||
ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
private string _lastAppName = string.Empty;
|
||||
private string _lastWindowTitle = string.Empty;
|
||||
private DateTime _lastChangeTime = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = options.Value;
|
||||
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
|
||||
config.PollIntervalMs, config.DebounceMs);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hwnd = NativeMethods.GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero)
|
||||
{
|
||||
await Task.Delay(config.PollIntervalMs, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get window title
|
||||
var sb = new StringBuilder(512);
|
||||
NativeMethods.GetWindowText(hwnd, sb, sb.Capacity);
|
||||
var windowTitle = sb.ToString();
|
||||
|
||||
// Get process name
|
||||
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
|
||||
string appName;
|
||||
try
|
||||
{
|
||||
var process = Process.GetProcessById((int)pid);
|
||||
appName = process.ProcessName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
appName = "Unknown";
|
||||
}
|
||||
|
||||
// Check if changed
|
||||
if (appName != _lastAppName || windowTitle != _lastWindowTitle)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Debounce: only report if last change was more than DebounceMs ago
|
||||
if ((now - _lastChangeTime).TotalMilliseconds >= config.DebounceMs
|
||||
&& !string.IsNullOrWhiteSpace(windowTitle))
|
||||
{
|
||||
await ReportContextEvent(appName, windowTitle, stoppingToken);
|
||||
}
|
||||
|
||||
_lastAppName = appName;
|
||||
_lastWindowTitle = windowTitle;
|
||||
_lastChangeTime = now;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error polling foreground window");
|
||||
}
|
||||
|
||||
await Task.Delay(config.PollIntervalMs, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReportContextEvent(string appName, string windowTitle, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("TaskTrackerApi");
|
||||
var payload = new
|
||||
{
|
||||
source = "WindowWatcher",
|
||||
appName,
|
||||
windowTitle
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/context", payload, ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogDebug("Reported: {App} - {Title}", appName, windowTitle);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("API returned {StatusCode} for context event", response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to report context event to API");
|
||||
}
|
||||
}
|
||||
}
|
||||
8
WindowWatcher/appsettings.Development.json
Normal file
8
WindowWatcher/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
WindowWatcher/appsettings.json
Normal file
13
WindowWatcher/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"WindowWatcher": {
|
||||
"ApiBaseUrl": "http://localhost:5200",
|
||||
"PollIntervalMs": 2000,
|
||||
"DebounceMs": 3000
|
||||
}
|
||||
}
|
||||
7
WorkContextTracker.slnx
Normal file
7
WorkContextTracker.slnx
Normal file
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="TaskTracker.Api/TaskTracker.Api.csproj" />
|
||||
<Project Path="TaskTracker.Core/TaskTracker.Core.csproj" />
|
||||
<Project Path="TaskTracker.Infrastructure/TaskTracker.Infrastructure.csproj" />
|
||||
<Project Path="TaskTracker.MCP/TaskTracker.MCP.csproj" />
|
||||
<Project Path="WindowWatcher/WindowWatcher.csproj" />
|
||||
</Solution>
|
||||
200
docs/plans/2026-02-26-web-ui-redesign-design.md
Normal file
200
docs/plans/2026-02-26-web-ui-redesign-design.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# TaskTracker Web UI Redesign
|
||||
|
||||
**Date**: 2026-02-26
|
||||
**Status**: Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Ground-up redesign of the TaskTracker web UI. Replace the current vanilla JS SPA in `wwwroot/` with a React-based Kanban board application in a separate `TaskTracker.Web` project.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: React 19, TypeScript
|
||||
- **Build**: Vite
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Drag-and-drop**: @dnd-kit/core + @dnd-kit/sortable
|
||||
- **Data fetching**: @tanstack/react-query
|
||||
- **Routing**: react-router-dom
|
||||
- **Charts**: Recharts
|
||||
- **HTTP**: Axios
|
||||
- **Icons**: lucide-react
|
||||
- **Font**: Inter
|
||||
|
||||
Dev server on port 5173, proxies `/api` to `localhost:5200`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
TaskTracker.Web/
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # Axios wrapper → localhost:5200
|
||||
│ │ ├── tasks.ts # useTasksQuery, useStartTask, etc.
|
||||
│ │ ├── context.ts # useContextSummary, useRecentEvents
|
||||
│ │ └── mappings.ts # useMappings, useCreateMapping
|
||||
│ ├── components/
|
||||
│ │ ├── Layout.tsx # Sidebar + top bar + content area
|
||||
│ │ ├── KanbanBoard.tsx
|
||||
│ │ ├── KanbanColumn.tsx
|
||||
│ │ ├── TaskCard.tsx
|
||||
│ │ ├── TaskModal.tsx
|
||||
│ │ ├── SearchBar.tsx
|
||||
│ │ └── ...
|
||||
│ ├── pages/
|
||||
│ │ ├── Board.tsx # Kanban board (main view)
|
||||
│ │ ├── Analytics.tsx # Context tracking visualizations
|
||||
│ │ ├── Mappings.tsx # App-to-category mapping management
|
||||
│ │ └── Settings.tsx # (future)
|
||||
│ ├── types/ # TypeScript types matching API DTOs
|
||||
│ └── lib/ # Utilities, constants, theme tokens
|
||||
└── public/
|
||||
```
|
||||
|
||||
## Pages & Features
|
||||
|
||||
### Kanban Board (main page)
|
||||
|
||||
Four columns: Pending, Active, Paused, Completed.
|
||||
|
||||
**Drag-and-drop behavior**:
|
||||
- Pending → Active: calls `PUT /tasks/{id}/start` (auto-pauses any active task)
|
||||
- Active → Paused: calls `PUT /tasks/{id}/pause`
|
||||
- Paused → Active: calls `PUT /tasks/{id}/resume`
|
||||
- Any → Completed: calls `PUT /tasks/{id}/complete`
|
||||
- Dragging to Pending from other columns is blocked
|
||||
- Optimistic updates via TanStack Query; rolls back on API error
|
||||
|
||||
**Task card displays**:
|
||||
- Title
|
||||
- Category badge (color-coded)
|
||||
- Elapsed time (live-updating for active task)
|
||||
- Progress bar (actual vs estimated time, if estimate set)
|
||||
- Subtask count indicator (e.g., "2/4 subtasks done")
|
||||
- Active task gets a pulsing cyan accent border
|
||||
|
||||
**"+ Add Task"** button in Pending column opens quick-create form.
|
||||
|
||||
**Card click** opens the Task Detail Panel.
|
||||
|
||||
**Filter bar** below board header:
|
||||
- Category filter chips
|
||||
- Date range picker
|
||||
- "Has subtasks" toggle
|
||||
- Filters are additive (AND), shown as dismissible chips
|
||||
|
||||
### Task Detail Panel
|
||||
|
||||
Slide-over panel from the right (~400px), board visible behind (dimmed).
|
||||
|
||||
**Sections**:
|
||||
- **Header**: Title (inline editable), status, category dropdown
|
||||
- **Description**: Inline editable text area
|
||||
- **Time**: Elapsed display, estimate input field, progress bar (actual/estimated)
|
||||
- **Subtasks**: Checklist of child tasks. Check to complete, "+" to add new subtask (creates child task via API)
|
||||
- **Notes**: Chronological list with type badges (Pause, Resume, General). "+" for inline add
|
||||
- **Actions**: Context-aware buttons (Start/Pause/Resume/Complete/Abandon)
|
||||
|
||||
Close with Escape or click outside.
|
||||
|
||||
### Analytics Page
|
||||
|
||||
Three visualization sections, filterable by time range and task.
|
||||
|
||||
**Filters** (top bar):
|
||||
- Time range: Today, Last 7 days, Last 30 days, Custom
|
||||
- Task filter: All Tasks or specific task
|
||||
|
||||
**Timeline**:
|
||||
- Horizontal swim lane showing app usage blocks, color-coded by category
|
||||
- Task lifecycle markers (started/paused/resumed/completed) as annotations
|
||||
- Hover shows app name, window title, duration
|
||||
|
||||
**Category Breakdown**:
|
||||
- Donut chart + horizontal bar list with time and percentage per category
|
||||
- Colors match category badges
|
||||
|
||||
**Activity Feed**:
|
||||
- Reverse-chronological log of context events
|
||||
- Each row: colored dot, timestamp, app name, window title/URL
|
||||
- Task lifecycle events interleaved
|
||||
- Paginated with "Load more"
|
||||
|
||||
### Mappings Page
|
||||
|
||||
CRUD table for app-to-category rules.
|
||||
- Columns: Pattern, Match Type, Category (color badge), Friendly Name, Edit/Delete actions
|
||||
- "+ Add Rule" opens inline form row at top
|
||||
- Inline editing on existing rows
|
||||
|
||||
### Search
|
||||
|
||||
Global search bar in top bar, always visible.
|
||||
- Client-side search over task titles and descriptions
|
||||
- Results as dropdown below search bar
|
||||
- Keyboard navigable (arrows, Enter, Escape)
|
||||
|
||||
## New Features (require API changes)
|
||||
|
||||
- **Time estimates**: New field on WorkTask entity for estimated duration. Progress bar = elapsed / estimated.
|
||||
- **Subtask progress rollup**: Parent task cards show child completion count (may be client-side calculation from existing data).
|
||||
|
||||
## Visual Design
|
||||
|
||||
**Palette**:
|
||||
- Background: `#0f1117` (deep charcoal)
|
||||
- Surface/cards: `#1a1d27`
|
||||
- Accent primary: `#6366f1` (electric indigo)
|
||||
- Accent secondary: `#06b6d4` (vivid cyan)
|
||||
- Success: `#10b981` (emerald)
|
||||
- Warning: `#f59e0b` (amber)
|
||||
- Danger: `#f43f5e` (rose)
|
||||
- Per-category saturated colors: dev=indigo, research=cyan, comms=violet, devops=orange
|
||||
|
||||
**Typography**:
|
||||
- Font: Inter
|
||||
- Body: 13px / Medium (500)
|
||||
- Labels: 11px
|
||||
- Headings: 18px / Semibold (600)
|
||||
|
||||
**Visual details**:
|
||||
- Subtle gradient on sidebar
|
||||
- Colored left border on task cards (category color)
|
||||
- Active task: soft glowing cyan border animation
|
||||
- Column headers: subtle colored underline per status
|
||||
- Smooth transitions on drag, hover, panel open/close
|
||||
- Colored-tint shadows for depth
|
||||
|
||||
**Layout**:
|
||||
- Sidebar: ~60px collapsed, ~200px expanded, icon + label nav
|
||||
- Top bar: search, minimal
|
||||
- Board: full remaining width, columns flex evenly
|
||||
- Detail panel: ~400px, slides from right, overlays board
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
| Endpoint | Used By |
|
||||
|----------|---------|
|
||||
| `GET /api/tasks` | Board, Search |
|
||||
| `GET /api/tasks/active` | Board (active indicator) |
|
||||
| `GET /api/tasks/{id}` | Detail panel |
|
||||
| `POST /api/tasks` | New task, new subtask |
|
||||
| `PUT /api/tasks/{id}/start` | Drag to Active |
|
||||
| `PUT /api/tasks/{id}/pause` | Drag to Paused |
|
||||
| `PUT /api/tasks/{id}/resume` | Drag to Active from Paused |
|
||||
| `PUT /api/tasks/{id}/complete` | Drag to Completed, subtask checkbox |
|
||||
| `DELETE /api/tasks/{id}` | Abandon button |
|
||||
| `GET /api/tasks/{taskId}/notes` | Detail panel notes |
|
||||
| `POST /api/tasks/{taskId}/notes` | Add note |
|
||||
| `GET /api/context/summary` | Analytics category breakdown |
|
||||
| `GET /api/context/recent` | Analytics timeline + feed |
|
||||
| `GET /api/mappings` | Mappings page, category colors |
|
||||
| `POST /api/mappings` | Add mapping |
|
||||
| `PUT /api/mappings/{id}` | Edit mapping |
|
||||
| `DELETE /api/mappings/{id}` | Delete mapping |
|
||||
965
docs/plans/2026-02-26-web-ui-redesign.md
Normal file
965
docs/plans/2026-02-26-web-ui-redesign.md
Normal file
@@ -0,0 +1,965 @@
|
||||
# TaskTracker Web UI Redesign — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the vanilla JS SPA with a React Kanban board app featuring rich analytics, subtask management, time estimates, and search/filters.
|
||||
|
||||
**Architecture:** Separate Vite+React project (`TaskTracker.Web`) calling the existing ASP.NET API at `localhost:5200`. Two small API changes needed first (time estimate field, task update endpoint). Frontend uses TanStack Query for server state, dnd-kit for drag-and-drop, Recharts for analytics.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vite, Tailwind CSS, @dnd-kit, @tanstack/react-query, react-router-dom, Recharts, Axios, lucide-react
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-26-web-ui-redesign-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: API — Add EstimatedMinutes field and Update endpoint
|
||||
|
||||
The React UI needs two things the API doesn't have yet: a time estimate field on tasks, and a general-purpose update endpoint for inline editing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `TaskTracker.Core/Entities/WorkTask.cs`
|
||||
- Modify: `TaskTracker.Core/DTOs/CreateTaskRequest.cs`
|
||||
- Create: `TaskTracker.Core/DTOs/UpdateTaskRequest.cs`
|
||||
- Modify: `TaskTracker.Api/Controllers/TasksController.cs`
|
||||
- Modify: `TaskTracker.Infrastructure/Data/AppDbContext.cs` (if migration needed)
|
||||
|
||||
**Step 1: Add EstimatedMinutes to WorkTask entity**
|
||||
|
||||
In `TaskTracker.Core/Entities/WorkTask.cs`, add:
|
||||
```csharp
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
```
|
||||
|
||||
**Step 2: Add EstimatedMinutes to CreateTaskRequest**
|
||||
|
||||
In `TaskTracker.Core/DTOs/CreateTaskRequest.cs`, add:
|
||||
```csharp
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
```
|
||||
|
||||
**Step 3: Create UpdateTaskRequest DTO**
|
||||
|
||||
Create `TaskTracker.Core/DTOs/UpdateTaskRequest.cs`:
|
||||
```csharp
|
||||
namespace TaskTracker.Core.DTOs;
|
||||
|
||||
public class UpdateTaskRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Wire EstimatedMinutes in TasksController.Create**
|
||||
|
||||
In `TasksController.cs`, update the `Create` method's `new WorkTask` block to include:
|
||||
```csharp
|
||||
EstimatedMinutes = request.EstimatedMinutes,
|
||||
```
|
||||
|
||||
**Step 5: Add PUT update endpoint to TasksController**
|
||||
|
||||
Add to `TasksController.cs`:
|
||||
```csharp
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaskRequest request)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id);
|
||||
if (task is null)
|
||||
return NotFound(ApiResponse.Fail("Task not found"));
|
||||
|
||||
if (request.Title is not null) task.Title = request.Title;
|
||||
if (request.Description is not null) task.Description = request.Description;
|
||||
if (request.Category is not null) task.Category = request.Category;
|
||||
if (request.EstimatedMinutes.HasValue) task.EstimatedMinutes = request.EstimatedMinutes;
|
||||
|
||||
await taskRepo.UpdateAsync(task);
|
||||
return Ok(ApiResponse<WorkTask>.Ok(task));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Create and apply EF migration**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd TaskTracker.Infrastructure
|
||||
dotnet ef migrations add AddEstimatedMinutes --startup-project ../TaskTracker.Api
|
||||
dotnet ef database update --startup-project ../TaskTracker.Api
|
||||
```
|
||||
|
||||
**Step 7: Verify with Swagger**
|
||||
|
||||
Run the API (`dotnet run --project TaskTracker.Api`) and test the new PUT endpoint at `/swagger`.
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Core/Entities/WorkTask.cs TaskTracker.Core/DTOs/CreateTaskRequest.cs TaskTracker.Core/DTOs/UpdateTaskRequest.cs TaskTracker.Api/Controllers/TasksController.cs TaskTracker.Infrastructure/
|
||||
git commit -m "feat: add EstimatedMinutes field and general PUT update endpoint for tasks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Scaffold React project with Vite + Tailwind
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/` (entire project scaffold)
|
||||
|
||||
**Step 1: Create Vite React TypeScript project**
|
||||
|
||||
```bash
|
||||
cd C:/Users/AJ/Desktop/Projects/TaskTracker
|
||||
npm create vite@latest TaskTracker.Web -- --template react-ts
|
||||
```
|
||||
|
||||
**Step 2: Install dependencies**
|
||||
|
||||
```bash
|
||||
cd TaskTracker.Web
|
||||
npm install axios @tanstack/react-query react-router-dom @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities recharts lucide-react
|
||||
npm install -D tailwindcss @tailwindcss/vite
|
||||
```
|
||||
|
||||
**Step 3: Configure Tailwind**
|
||||
|
||||
Replace `TaskTracker.Web/src/index.css` with:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
Add Tailwind plugin to `TaskTracker.Web/vite.config.ts`:
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5200',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Step 4: Clean up scaffold**
|
||||
|
||||
- Delete `src/App.css`, `src/assets/`
|
||||
- Replace `src/App.tsx` with a minimal placeholder:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return <div className="bg-[#0f1117] min-h-screen text-white p-8">
|
||||
<h1 className="text-2xl font-semibold">TaskTracker</h1>
|
||||
</div>
|
||||
}
|
||||
export default App
|
||||
```
|
||||
|
||||
- Replace `src/main.tsx`:
|
||||
|
||||
```tsx
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
**Step 5: Verify it runs**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` — should see "TaskTracker" on a dark background.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/
|
||||
git commit -m "feat: scaffold TaskTracker.Web with Vite, React, TypeScript, Tailwind"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: TypeScript types + API client + TanStack Query provider
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/types/index.ts`
|
||||
- Create: `TaskTracker.Web/src/api/client.ts`
|
||||
- Create: `TaskTracker.Web/src/api/tasks.ts`
|
||||
- Create: `TaskTracker.Web/src/api/context.ts`
|
||||
- Create: `TaskTracker.Web/src/api/mappings.ts`
|
||||
- Modify: `TaskTracker.Web/src/main.tsx`
|
||||
|
||||
**Step 1: Create TypeScript types matching the API**
|
||||
|
||||
Create `TaskTracker.Web/src/types/index.ts`:
|
||||
```typescript
|
||||
export enum WorkTaskStatus {
|
||||
Pending = 0,
|
||||
Active = 1,
|
||||
Paused = 2,
|
||||
Completed = 3,
|
||||
Abandoned = 4,
|
||||
}
|
||||
|
||||
export enum NoteType {
|
||||
PauseNote = 0,
|
||||
ResumeNote = 1,
|
||||
General = 2,
|
||||
}
|
||||
|
||||
export interface WorkTask {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
status: WorkTaskStatus
|
||||
category: string | null
|
||||
createdAt: string
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
estimatedMinutes: number | null
|
||||
parentTaskId: number | null
|
||||
subTasks: WorkTask[]
|
||||
notes: TaskNote[]
|
||||
contextEvents: ContextEvent[]
|
||||
}
|
||||
|
||||
export interface TaskNote {
|
||||
id: number
|
||||
workTaskId: number
|
||||
content: string
|
||||
type: NoteType
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ContextEvent {
|
||||
id: number
|
||||
workTaskId: number | null
|
||||
source: string
|
||||
appName: string
|
||||
windowTitle: string
|
||||
url: string | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AppMapping {
|
||||
id: number
|
||||
pattern: string
|
||||
matchType: string
|
||||
category: string
|
||||
friendlyName: string | null
|
||||
}
|
||||
|
||||
export interface ContextSummaryItem {
|
||||
appName: string
|
||||
category: string
|
||||
eventCount: number
|
||||
firstSeen: string
|
||||
lastSeen: string
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T
|
||||
error: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create Axios client**
|
||||
|
||||
Create `TaskTracker.Web/src/api/client.ts`:
|
||||
```typescript
|
||||
import axios from 'axios'
|
||||
import type { ApiResponse } from '../types'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
export async function request<T>(config: Parameters<typeof api.request>[0]): Promise<T> {
|
||||
const { data } = await api.request<ApiResponse<T>>(config)
|
||||
if (!data.success) throw new Error(data.error ?? 'API error')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export default api
|
||||
```
|
||||
|
||||
**Step 3: Create task API hooks**
|
||||
|
||||
Create `TaskTracker.Web/src/api/tasks.ts`:
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { request } from './client'
|
||||
import type { WorkTask } from '../types'
|
||||
|
||||
export function useTasks(includeSubTasks = true) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', { includeSubTasks }],
|
||||
queryFn: () => request<WorkTask[]>({ url: '/tasks', params: { includeSubTasks } }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useActiveTask() {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', 'active'],
|
||||
queryFn: () => request<WorkTask | null>({ url: '/tasks/active' }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTask(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', id],
|
||||
queryFn: () => request<WorkTask>({ url: `/tasks/${id}` }),
|
||||
})
|
||||
}
|
||||
|
||||
function useInvalidateTasks() {
|
||||
const qc = useQueryClient()
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: (body: { title: string; description?: string; category?: string; parentTaskId?: number; estimatedMinutes?: number }) =>
|
||||
request<WorkTask>({ method: 'POST', url: '/tasks', data: body }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: number; title?: string; description?: string; category?: string; estimatedMinutes?: number }) =>
|
||||
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}`, data: body }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/start` }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePauseTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: number; note?: string }) =>
|
||||
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/pause`, data: { note } }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useResumeTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, note }: { id: number; note?: string }) =>
|
||||
request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/resume`, data: { note } }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCompleteTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/complete` }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAbandonTask() {
|
||||
const invalidate = useInvalidateTasks()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/tasks/${id}` }),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create context API hooks**
|
||||
|
||||
Create `TaskTracker.Web/src/api/context.ts`:
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { request } from './client'
|
||||
import type { ContextEvent, ContextSummaryItem } from '../types'
|
||||
|
||||
export function useRecentContext(minutes = 30) {
|
||||
return useQuery({
|
||||
queryKey: ['context', 'recent', minutes],
|
||||
queryFn: () => request<ContextEvent[]>({ url: '/context/recent', params: { minutes } }),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useContextSummary() {
|
||||
return useQuery({
|
||||
queryKey: ['context', 'summary'],
|
||||
queryFn: () => request<ContextSummaryItem[]>({ url: '/context/summary' }),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Create mappings API hooks**
|
||||
|
||||
Create `TaskTracker.Web/src/api/mappings.ts`:
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { request } from './client'
|
||||
import type { AppMapping } from '../types'
|
||||
|
||||
export function useMappings() {
|
||||
return useQuery({
|
||||
queryKey: ['mappings'],
|
||||
queryFn: () => request<AppMapping[]>({ url: '/mappings' }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMapping() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { pattern: string; matchType: string; category: string; friendlyName?: string }) =>
|
||||
request<AppMapping>({ method: 'POST', url: '/mappings', data: body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateMapping() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: number; pattern: string; matchType: string; category: string; friendlyName?: string }) =>
|
||||
request<AppMapping>({ method: 'PUT', url: `/mappings/${id}`, data: body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteMapping() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/mappings/${id}` }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Add QueryClientProvider to main.tsx**
|
||||
|
||||
```tsx
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
**Step 7: Verify compilation**
|
||||
|
||||
```bash
|
||||
cd TaskTracker.Web && npm run build
|
||||
```
|
||||
|
||||
Expected: clean build with no errors.
|
||||
|
||||
**Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/types/ TaskTracker.Web/src/api/ TaskTracker.Web/src/main.tsx
|
||||
git commit -m "feat: add TypeScript types, API client, and TanStack Query hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Layout shell — sidebar, top bar, routing
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/components/Layout.tsx`
|
||||
- Create: `TaskTracker.Web/src/pages/Board.tsx` (placeholder)
|
||||
- Create: `TaskTracker.Web/src/pages/Analytics.tsx` (placeholder)
|
||||
- Create: `TaskTracker.Web/src/pages/Mappings.tsx` (placeholder)
|
||||
- Modify: `TaskTracker.Web/src/App.tsx`
|
||||
|
||||
**Step 1: Create Layout component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/Layout.tsx` with:
|
||||
- Collapsible sidebar (60px collapsed, 200px expanded) with gradient background
|
||||
- Nav items: Board (LayoutGrid icon), Analytics (BarChart3 icon), Mappings (Link icon)
|
||||
- Top bar with app title and SearchBar placeholder
|
||||
- `<Outlet />` for page content
|
||||
- Use `lucide-react` for icons
|
||||
- Use `react-router-dom` `NavLink` with active styling (indigo highlight)
|
||||
|
||||
**Step 2: Create placeholder pages**
|
||||
|
||||
Each page as a simple component with just a heading, e.g.:
|
||||
```tsx
|
||||
export default function Board() {
|
||||
return <div><h1 className="text-xl font-semibold text-white">Board</h1></div>
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Wire up routing in App.tsx**
|
||||
|
||||
```tsx
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Board from './pages/Board'
|
||||
import Analytics from './pages/Analytics'
|
||||
import Mappings from './pages/Mappings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/board" replace />} />
|
||||
<Route path="/board" element={<Board />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/mappings" element={<Mappings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Verify navigation works**
|
||||
|
||||
Run `npm run dev`. Click sidebar links — URL changes and placeholder pages render.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add layout shell with sidebar navigation and routing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Kanban board — columns, cards, drag-and-drop
|
||||
|
||||
This is the core feature. Build it incrementally.
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/lib/constants.ts` (colors, status labels)
|
||||
- Create: `TaskTracker.Web/src/components/KanbanColumn.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/TaskCard.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/KanbanBoard.tsx`
|
||||
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
|
||||
|
||||
**Step 1: Create constants for status/category config**
|
||||
|
||||
Create `TaskTracker.Web/src/lib/constants.ts`:
|
||||
```typescript
|
||||
import { WorkTaskStatus } from '../types'
|
||||
|
||||
export const COLUMN_CONFIG = [
|
||||
{ status: WorkTaskStatus.Pending, label: 'Pending', color: '#94a3b8' },
|
||||
{ status: WorkTaskStatus.Active, label: 'Active', color: '#06b6d4' },
|
||||
{ status: WorkTaskStatus.Paused, label: 'Paused', color: '#f59e0b' },
|
||||
{ status: WorkTaskStatus.Completed, label: 'Completed', color: '#10b981' },
|
||||
] as const
|
||||
|
||||
export const CATEGORY_COLORS: Record<string, string> = {
|
||||
Development: '#6366f1',
|
||||
Research: '#06b6d4',
|
||||
Communication: '#8b5cf6',
|
||||
DevOps: '#f97316',
|
||||
Documentation: '#14b8a6',
|
||||
Design: '#ec4899',
|
||||
Unknown: '#64748b',
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build TaskCard component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/TaskCard.tsx`:
|
||||
- Renders task title, category badge (color-coded from `CATEGORY_COLORS`), elapsed time
|
||||
- If `estimatedMinutes` set, show progress bar (elapsed / estimated)
|
||||
- If task has subtasks, show "N/M done" indicator
|
||||
- Active task gets a pulsing cyan border (`animate-pulse` or custom keyframe)
|
||||
- Colored left border matching category
|
||||
- Uses `useSortable` from dnd-kit for drag handle
|
||||
- `onClick` prop to open detail panel
|
||||
|
||||
**Step 3: Build KanbanColumn component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/KanbanColumn.tsx`:
|
||||
- Column header with status label + count + colored underline
|
||||
- Uses `useDroppable` from dnd-kit
|
||||
- Renders list of `TaskCard` components
|
||||
- "Pending" column has "+ Add Task" button at bottom
|
||||
- Visual drop indicator when dragging over
|
||||
|
||||
**Step 4: Build KanbanBoard component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/KanbanBoard.tsx`:
|
||||
- `DndContext` + `SortableContext` from dnd-kit wrapping the columns
|
||||
- `useTasks()` hook to fetch all tasks
|
||||
- Groups tasks by status into columns
|
||||
- `onDragEnd` handler that:
|
||||
- Determines source/target column
|
||||
- Maps column transitions to API calls (start/pause/resume/complete)
|
||||
- Blocks invalid transitions (e.g., moving to Pending)
|
||||
- Shows error toast on failure
|
||||
- Filters out subtasks from board (only show top-level tasks, `parentTaskId === null`)
|
||||
|
||||
**Step 5: Wire Board page**
|
||||
|
||||
Update `TaskTracker.Web/src/pages/Board.tsx` to render `<KanbanBoard />` and pass an `onTaskClick` callback that opens the detail panel (wired in Task 6).
|
||||
|
||||
**Step 6: Verify board renders with real data**
|
||||
|
||||
Start the API (`dotnet run --project TaskTracker.Api`), then `npm run dev`. Create a few tasks via Swagger, verify they show in the correct columns. Test drag-and-drop transitions.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: implement Kanban board with drag-and-drop task management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Task detail slide-over panel
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/components/TaskDetailPanel.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/SubtaskList.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/NotesList.tsx`
|
||||
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
|
||||
|
||||
**Step 1: Build TaskDetailPanel**
|
||||
|
||||
Create `TaskTracker.Web/src/components/TaskDetailPanel.tsx`:
|
||||
- Slide-over panel from right (~400px), board dimmed behind with semi-transparent overlay
|
||||
- Fetches task detail via `useTask(id)`
|
||||
- Sections:
|
||||
- **Header**: Title (click to edit inline, blur to save via `useUpdateTask`), status badge, category dropdown
|
||||
- **Description**: Click-to-edit text area, saves on blur
|
||||
- **Time**: Show elapsed time. Input for estimated minutes, saves on blur. Progress bar if estimate set.
|
||||
- **Subtasks**: `<SubtaskList />` component
|
||||
- **Notes**: `<NotesList />` component
|
||||
- **Actions**: Context-aware buttons — Start/Pause/Resume/Complete/Abandon based on current status
|
||||
- Close on Escape keypress or overlay click
|
||||
- Animate slide-in/out with CSS transition
|
||||
|
||||
**Step 2: Build SubtaskList**
|
||||
|
||||
Create `TaskTracker.Web/src/components/SubtaskList.tsx`:
|
||||
- Renders `task.subTasks` as checkboxes
|
||||
- Checking a subtask calls `useCompleteTask()` on it
|
||||
- "+" button at top shows inline text input → `useCreateTask()` with `parentTaskId`
|
||||
- Shows subtask status (completed = strikethrough + checkmark)
|
||||
|
||||
**Step 3: Build NotesList**
|
||||
|
||||
Create `TaskTracker.Web/src/components/NotesList.tsx`:
|
||||
- Renders `task.notes` chronologically
|
||||
- Each note shows: type badge (PauseNote/ResumeNote/General), content, relative timestamp
|
||||
- "+" button shows inline text input → `POST /api/tasks/{id}/notes` with type General
|
||||
|
||||
**Step 4: Wire panel into Board page**
|
||||
|
||||
In `Board.tsx`:
|
||||
- Add `selectedTaskId` state
|
||||
- Pass `onTaskClick={(id) => setSelectedTaskId(id)}` to `KanbanBoard`
|
||||
- Render `<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />` when set
|
||||
|
||||
**Step 5: Verify full workflow**
|
||||
|
||||
Test: click card → panel opens → edit title → add subtask → add note → change status → close panel. All changes persist.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add task detail slide-over panel with inline editing, subtasks, and notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create task form
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/components/CreateTaskForm.tsx`
|
||||
- Modify: `TaskTracker.Web/src/components/KanbanColumn.tsx`
|
||||
|
||||
**Step 1: Build CreateTaskForm**
|
||||
|
||||
Create `TaskTracker.Web/src/components/CreateTaskForm.tsx`:
|
||||
- Inline form that expands in the Pending column when "+ Add Task" is clicked
|
||||
- Fields: Title (required), Description (optional textarea), Category (dropdown from known categories), Estimated Minutes (optional number)
|
||||
- Submit calls `useCreateTask()`
|
||||
- Escape or click away cancels
|
||||
- Auto-focus title input on open
|
||||
|
||||
**Step 2: Wire into Pending column**
|
||||
|
||||
Modify `KanbanColumn.tsx`:
|
||||
- When column is Pending, show "+ Add Task" button
|
||||
- On click, toggle showing `<CreateTaskForm />` at bottom of column
|
||||
- On submit/cancel, hide the form
|
||||
|
||||
**Step 3: Verify**
|
||||
|
||||
Create a task via the inline form. It should appear in the Pending column immediately.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add inline create task form in Pending column"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Search bar + board filters
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/components/SearchBar.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/FilterBar.tsx`
|
||||
- Modify: `TaskTracker.Web/src/components/Layout.tsx`
|
||||
- Modify: `TaskTracker.Web/src/pages/Board.tsx`
|
||||
|
||||
**Step 1: Build SearchBar**
|
||||
|
||||
Create `TaskTracker.Web/src/components/SearchBar.tsx`:
|
||||
- Input with search icon (lucide `Search`)
|
||||
- On typing, filters tasks client-side (title + description match)
|
||||
- Results shown as dropdown list of matching tasks
|
||||
- Click result → opens TaskDetailPanel (via callback prop)
|
||||
- Keyboard navigation: arrow keys to move, Enter to select, Escape to close
|
||||
- Debounce input by 200ms
|
||||
|
||||
**Step 2: Build FilterBar**
|
||||
|
||||
Create `TaskTracker.Web/src/components/FilterBar.tsx`:
|
||||
- Row of filter chips rendered below the board header
|
||||
- Category chips: derived from unique categories across all tasks + mappings
|
||||
- "Has subtasks" toggle chip
|
||||
- Active filters shown as colored chips with "x" to dismiss
|
||||
- Exposes `filters` state and `filteredTasks` computation to parent
|
||||
|
||||
**Step 3: Wire SearchBar into Layout**
|
||||
|
||||
Add `<SearchBar />` to the top bar in `Layout.tsx`. It needs access to task data — use `useTasks()` inside the component.
|
||||
|
||||
**Step 4: Wire FilterBar into Board page**
|
||||
|
||||
In `Board.tsx`, add `<FilterBar />` above the `<KanbanBoard />`. Pass filtered tasks down to the board instead of all tasks. Lift task fetching up to `Board.tsx` and pass tasks as props to both `FilterBar` and `KanbanBoard`.
|
||||
|
||||
**Step 5: Verify**
|
||||
|
||||
- Type in search bar → dropdown shows matching tasks
|
||||
- Click category chip → board filters to that category
|
||||
- Combine filters → board shows intersection
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add global search bar and board filter chips"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Analytics page — timeline, charts, activity feed
|
||||
|
||||
**Files:**
|
||||
- Create: `TaskTracker.Web/src/components/analytics/Timeline.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx`
|
||||
- Create: `TaskTracker.Web/src/components/analytics/ActivityFeed.tsx`
|
||||
- Modify: `TaskTracker.Web/src/pages/Analytics.tsx`
|
||||
|
||||
**Step 1: Build Analytics page shell**
|
||||
|
||||
Update `TaskTracker.Web/src/pages/Analytics.tsx`:
|
||||
- Filter bar at top: time range dropdown (Today/7d/30d), task filter dropdown
|
||||
- Time range maps to `minutes` param for `/context/recent` (1440 for today, 10080 for 7d, 43200 for 30d)
|
||||
- Three sections stacked vertically: Timeline, Category Breakdown, Activity Feed
|
||||
|
||||
**Step 2: Build Timeline component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/analytics/Timeline.tsx`:
|
||||
- Recharts `BarChart` with custom rendering
|
||||
- X-axis: time of day (hourly buckets)
|
||||
- Bars: colored by category (from mappings)
|
||||
- Tooltip on hover: app name, window title, duration
|
||||
- Uses `useRecentContext()` data, groups events into time buckets
|
||||
|
||||
**Step 3: Build CategoryBreakdown component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx`:
|
||||
- Left side: Recharts `PieChart` (donut style with inner radius)
|
||||
- Right side: list of categories with horizontal bar, time, percentage
|
||||
- Uses `useContextSummary()` data
|
||||
- Colors from `CATEGORY_COLORS` constant
|
||||
|
||||
**Step 4: Build ActivityFeed component**
|
||||
|
||||
Create `TaskTracker.Web/src/components/analytics/ActivityFeed.tsx`:
|
||||
- Reverse-chronological list of context events
|
||||
- Each row: colored category dot, relative timestamp, app name (bold), window title/URL
|
||||
- "Load more" button at bottom (increase `minutes` param or paginate client-side)
|
||||
- Uses `useRecentContext()` with the selected time range
|
||||
|
||||
**Step 5: Verify analytics page**
|
||||
|
||||
Need some context data — either run WindowWatcher/Chrome extension to generate real data, or POST a few test events via Swagger. Verify all three visualizations render.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add analytics page with timeline, category breakdown, and activity feed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Mappings page
|
||||
|
||||
**Files:**
|
||||
- Modify: `TaskTracker.Web/src/pages/Mappings.tsx`
|
||||
|
||||
**Step 1: Build Mappings page**
|
||||
|
||||
Update `TaskTracker.Web/src/pages/Mappings.tsx`:
|
||||
- Table with columns: Pattern, Match Type, Category (color badge), Friendly Name, Actions (edit/delete)
|
||||
- Uses `useMappings()` to fetch data
|
||||
- "+ Add Rule" button at top → inserts inline form row at top of table
|
||||
- Form fields: Pattern (text), Match Type (dropdown: ProcessName/TitleContains/UrlContains), Category (text), Friendly Name (text)
|
||||
- Submit → `useCreateMapping()`
|
||||
- Edit button → row becomes editable inline → save → `useUpdateMapping()`
|
||||
- Delete button → confirm dialog → `useDeleteMapping()`
|
||||
|
||||
**Step 2: Verify CRUD**
|
||||
|
||||
Create a mapping, edit it, delete it. Verify all operations work.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/src/
|
||||
git commit -m "feat: add mappings page with inline CRUD table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Visual polish and animations
|
||||
|
||||
**Files:**
|
||||
- Modify: Various component files for animation/transition polish
|
||||
- Modify: `TaskTracker.Web/index.html` (add Inter font)
|
||||
|
||||
**Step 1: Add Inter font**
|
||||
|
||||
In `TaskTracker.Web/index.html`, add to `<head>`:
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
Add to `index.css` (inside `@theme`):
|
||||
```css
|
||||
@theme {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add animations**
|
||||
|
||||
- Active task card: glowing cyan border pulse (CSS `@keyframes` animation)
|
||||
- Detail panel: slide-in from right with opacity transition
|
||||
- Drag-and-drop: smooth card movement via dnd-kit's built-in transitions
|
||||
- Hover effects: subtle card lift with colored shadow
|
||||
- Column drop target: border highlight on drag-over
|
||||
|
||||
**Step 3: Polish visual details**
|
||||
|
||||
- Sidebar gradient background
|
||||
- Colored left border on task cards
|
||||
- Category badge colors consistent everywhere
|
||||
- Column header colored underlines
|
||||
- Colored-tint shadows on cards (not plain gray)
|
||||
- Consistent spacing and typography
|
||||
|
||||
**Step 4: Verify visual quality**
|
||||
|
||||
Walk through every page and interaction. Check dark background rendering, color contrast, animation smoothness.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add TaskTracker.Web/
|
||||
git commit -m "feat: add visual polish — Inter font, animations, colored shadows, hover effects"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Final integration testing and cleanup
|
||||
|
||||
**Step 1: Full workflow test**
|
||||
|
||||
Run both the API and the React dev server. Walk through the complete workflow:
|
||||
1. Create a task from the board
|
||||
2. Start it (drag or button)
|
||||
3. Add a subtask, complete it
|
||||
4. Add a note
|
||||
5. Set time estimate, verify progress bar
|
||||
6. Pause with note, resume
|
||||
7. Complete the task
|
||||
8. Check analytics page
|
||||
9. Add/edit/delete a mapping
|
||||
10. Test search and filters
|
||||
11. Test sidebar collapse/expand
|
||||
|
||||
**Step 2: Fix any issues found**
|
||||
|
||||
Address bugs or visual issues found during testing.
|
||||
|
||||
**Step 3: Clean up**
|
||||
|
||||
- Remove any unused imports or dead code
|
||||
- Ensure no console warnings in browser
|
||||
- Verify `npm run build` produces a clean production build
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final cleanup and integration testing"
|
||||
```
|
||||
Reference in New Issue
Block a user