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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user