chore: initial commit of TaskTracker project

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

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

View File

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

View File

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

View File

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

View File

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