Files
TaskTracker/TaskTracker.Api/Pages/Analytics.cshtml.cs
AJ Isaacs 6ea0e40d38 fix(web): address code review findings from Razor Pages migration
- Remove legacy wwwroot files (old index.html, app.css, api.js, page scripts)
- Add Index page that redirects / to /board
- Fix mapping delete button missing handler=Delete in URL
- Add [IgnoreAntiforgeryToken] to AnalyticsModel for consistency
- Remove duplicate JS from _TaskDetail.cshtml (already in app.js)

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

207 lines
7.3 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;
[IgnoreAntiforgeryToken]
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");
}
}