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 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 ActivityItems { get; set; } = []; public int TotalActivityCount { get; set; } public static readonly Dictionary 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 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 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 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"); } }