- 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>
207 lines
7.3 KiB
C#
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");
|
|
}
|
|
}
|