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%;