Files
TaskTracker/TaskTracker.Api/Pages/Analytics.cshtml.cs
AJ Isaacs 91f2eec922 feat(web): add Analytics page with Chart.js charts and activity feed
Add Analytics page with stat cards (open tasks, active time, top category),
Chart.js timeline bar chart bucketed by hour, category donut chart with
legend, and paginated activity feed with htmx "Load more" support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:33:12 -05:00

206 lines
7.2 KiB
C#

using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Enums;
using TaskTracker.Core.Interfaces;
namespace TaskTracker.Api.Pages;
public class AnalyticsModel : PageModel
{
private readonly ITaskRepository _taskRepo;
private readonly IContextEventRepository _contextRepo;
private readonly IAppMappingRepository _mappingRepo;
public AnalyticsModel(
ITaskRepository taskRepo,
IContextEventRepository contextRepo,
IAppMappingRepository mappingRepo)
{
_taskRepo = taskRepo;
_contextRepo = contextRepo;
_mappingRepo = mappingRepo;
}
public int Minutes { get; set; } = 1440;
public int? TaskId { get; set; }
public List<WorkTask> Tasks { get; set; } = [];
// Stat cards
public int OpenTaskCount { get; set; }
public string ActiveTimeDisplay { get; set; } = "--";
public string TopCategory { get; set; } = "\u2014";
// Chart data (serialized as JSON for Chart.js)
public string TimelineChartJson { get; set; } = "[]";
public string CategoryChartJson { get; set; } = "[]";
// Activity feed
public List<ContextEvent> ActivityItems { get; set; } = [];
public int TotalActivityCount { get; set; }
public static readonly Dictionary<string, string> CategoryColors = new()
{
["Development"] = "#6366f1",
["Research"] = "#06b6d4",
["Communication"] = "#8b5cf6",
["DevOps"] = "#f97316",
["Documentation"] = "#14b8a6",
["Design"] = "#ec4899",
["Testing"] = "#3b82f6",
["General"] = "#64748b",
["Email"] = "#f59e0b",
["Engineering"] = "#6366f1",
["LaserCutting"] = "#ef4444",
["Unknown"] = "#475569",
};
public async Task<IActionResult> OnGetAsync(int minutes = 1440, int? taskId = null)
{
Minutes = minutes;
TaskId = taskId;
// Load all tasks
Tasks = await _taskRepo.GetAllAsync(includeSubTasks: false);
// Stat cards
OpenTaskCount = Tasks.Count(t =>
t.Status != WorkTaskStatus.Completed && t.Status != WorkTaskStatus.Abandoned);
// Active time: sum elapsed across all tasks with StartedAt
var totalMinutes = 0.0;
foreach (var task in Tasks.Where(t => t.StartedAt.HasValue))
{
var start = task.StartedAt!.Value;
var end = task.CompletedAt ?? (task.Status == WorkTaskStatus.Active ? DateTime.UtcNow : start);
totalMinutes += (end - start).TotalMinutes;
}
if (totalMinutes >= 60)
ActiveTimeDisplay = $"{(int)(totalMinutes / 60)}h {(int)(totalMinutes % 60)}m";
else if (totalMinutes >= 1)
ActiveTimeDisplay = $"{(int)totalMinutes}m";
// Top category
var topCat = Tasks
.Where(t => !string.IsNullOrEmpty(t.Category))
.GroupBy(t => t.Category)
.OrderByDescending(g => g.Count())
.FirstOrDefault();
if (topCat != null)
TopCategory = topCat.Key!;
// Context events for the time range
var events = await _contextRepo.GetRecentAsync(minutes);
if (taskId.HasValue)
events = events.Where(e => e.WorkTaskId == taskId.Value).ToList();
// Mappings
var mappings = await _mappingRepo.GetAllAsync();
// Timeline chart data: bucket by hour
var timelineData = events
.GroupBy(e => e.Timestamp.ToLocalTime().Hour)
.OrderBy(g => g.Key)
.Select(g =>
{
// Determine dominant category for this hour
var categoryCounts = g
.Select(e => ResolveCategory(e, mappings))
.GroupBy(c => c)
.OrderByDescending(cg => cg.Count())
.First();
var category = categoryCounts.Key;
var color = GetCategoryColor(category);
var hour = g.Key;
var label = hour == 0 ? "12 AM"
: hour < 12 ? $"{hour} AM"
: hour == 12 ? "12 PM"
: $"{hour - 12} PM";
return new { hour = label, count = g.Count(), category, color };
})
.ToList();
TimelineChartJson = JsonSerializer.Serialize(timelineData,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
// Category chart data
var categoryData = events
.Select(e => ResolveCategory(e, mappings))
.GroupBy(c => c)
.OrderByDescending(g => g.Count())
.Select(g => new
{
category = g.Key,
count = g.Count(),
color = GetCategoryColor(g.Key)
})
.ToList();
CategoryChartJson = JsonSerializer.Serialize(categoryData,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
// Activity feed: most recent 20
var orderedEvents = events.OrderByDescending(e => e.Timestamp).ToList();
TotalActivityCount = orderedEvents.Count;
ActivityItems = orderedEvents.Take(20).ToList();
return Page();
}
public async Task<IActionResult> OnGetActivityFeedAsync(int minutes = 1440, int? taskId = null, int offset = 0)
{
var events = await _contextRepo.GetRecentAsync(minutes);
if (taskId.HasValue)
events = events.Where(e => e.WorkTaskId == taskId.Value).ToList();
var items = events
.OrderByDescending(e => e.Timestamp)
.Skip(offset)
.Take(20)
.ToList();
TotalActivityCount = events.Count;
ActivityItems = items;
return Partial("Partials/_ActivityFeedItems", this);
}
private static string ResolveCategory(ContextEvent e, List<AppMapping> mappings)
{
foreach (var m in mappings)
{
bool match = m.MatchType switch
{
"ProcessName" => e.AppName.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"TitleContains" => e.WindowTitle.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase),
"UrlContains" => e.Url?.Contains(m.Pattern, StringComparison.OrdinalIgnoreCase) == true,
_ => false
};
if (match) return m.Category;
}
return "Unknown";
}
private static string GetCategoryColor(string? category)
{
if (string.IsNullOrEmpty(category)) return CategoryColors["Unknown"];
return CategoryColors.TryGetValue(category, out var color) ? color : CategoryColors["Unknown"];
}
public static string FormatRelativeTime(DateTime dt)
{
var diff = DateTime.UtcNow - dt;
if (diff.TotalMinutes < 1) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 2) return "yesterday";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)}w ago";
return dt.ToLocalTime().ToString("MMM d, yyyy");
}
}