From 91f2eec922b87dcb70fc44d9f4fd8d8a82910a7f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Mar 2026 22:33:12 -0500 Subject: [PATCH] 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 --- TaskTracker.Api/Pages/Analytics.cshtml | 219 ++++++++++++++++++ TaskTracker.Api/Pages/Analytics.cshtml.cs | 205 ++++++++++++++++ .../Pages/Partials/_ActivityFeedItems.cshtml | 15 ++ TaskTracker.Api/wwwroot/css/site.css | 26 ++- 4 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 TaskTracker.Api/Pages/Analytics.cshtml create mode 100644 TaskTracker.Api/Pages/Analytics.cshtml.cs create mode 100644 TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml diff --git a/TaskTracker.Api/Pages/Analytics.cshtml b/TaskTracker.Api/Pages/Analytics.cshtml new file mode 100644 index 0000000..75fa594 --- /dev/null +++ b/TaskTracker.Api/Pages/Analytics.cshtml @@ -0,0 +1,219 @@ +@page +@using TaskTracker.Api.Pages +@model AnalyticsModel + +
+ +
+

Analytics

+
+ + +
+
+ + +
+
+ Open Tasks +

@Model.OpenTaskCount

+
+
+ Active Time +

@Model.ActiveTimeDisplay

+
+
+ Top Category +

@Model.TopCategory

+
+
+ + +
+

Activity Timeline

+
+
+ +
+
+
+ + +
+

Category Breakdown

+
+
+
+ +
+
+ +
+
+
+
+ + +
+

Recent Activity

+
+ @if (Model.ActivityItems.Count == 0) + { +
No activity recorded in this time range.
+ } + else + { +
+ @foreach (var item in Model.ActivityItems) + { +
+ +
+
+ @item.AppName + @(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url) + @AnalyticsModel.FormatRelativeTime(item.Timestamp) +
+
+ } +
+ @if (Model.TotalActivityCount > Model.ActivityItems.Count) + { + + } + } +
+
+
+ + + + +@section Scripts { + +} diff --git a/TaskTracker.Api/Pages/Analytics.cshtml.cs b/TaskTracker.Api/Pages/Analytics.cshtml.cs new file mode 100644 index 0000000..9e2a01e --- /dev/null +++ b/TaskTracker.Api/Pages/Analytics.cshtml.cs @@ -0,0 +1,205 @@ +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 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"); + } +} diff --git a/TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml b/TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml new file mode 100644 index 0000000..806cfa5 --- /dev/null +++ b/TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml @@ -0,0 +1,15 @@ +@using TaskTracker.Api.Pages +@model AnalyticsModel + +@foreach (var item in Model.ActivityItems) +{ +
+ +
+
+ @item.AppName + @(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url) + @AnalyticsModel.FormatRelativeTime(item.Timestamp) +
+
+} diff --git a/TaskTracker.Api/wwwroot/css/site.css b/TaskTracker.Api/wwwroot/css/site.css index e94ccf1..18ad78e 100644 --- a/TaskTracker.Api/wwwroot/css/site.css +++ b/TaskTracker.Api/wwwroot/css/site.css @@ -1638,6 +1638,24 @@ body::before { .space-y-8 > * + * { margin-top: 32px; } +/* ============================================================ + ANALYTICS PAGE + ============================================================ */ + +.analytics-page { max-width: 1152px; margin: 0 auto; } +.analytics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 32px; } +.analytics-filters { display: flex; gap: 12px; } +.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; } +.category-legend { display: flex; flex-direction: column; gap: 8px; } +.category-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.category-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.category-legend-name { color: var(--color-text-primary); flex: 1; } +.category-legend-count { color: var(--color-text-secondary); font-size: 12px; } +.category-legend-pct { color: var(--color-text-tertiary); font-size: 11px; width: 32px; text-align: right; } +section { margin-bottom: 32px; } +.search-empty { padding: 24px; text-align: center; color: var(--color-text-secondary); font-size: 14px; } + + /* ============================================================ RESPONSIVE ============================================================ */ @@ -1657,10 +1675,16 @@ body::before { grid-template-columns: 1fr; } - .grid-cols-3 { + .grid-cols-3, + .stat-grid { grid-template-columns: 1fr; } + .analytics-header { + flex-direction: column; + align-items: flex-start; + } + .search-modal { width: calc(100vw - 20px); top: 10%;