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 <noreply@anthropic.com>
This commit is contained in:
219
TaskTracker.Api/Pages/Analytics.cshtml
Normal file
219
TaskTracker.Api/Pages/Analytics.cshtml
Normal file
@@ -0,0 +1,219 @@
|
||||
@page
|
||||
@using TaskTracker.Api.Pages
|
||||
@model AnalyticsModel
|
||||
|
||||
<div class="analytics-page">
|
||||
<!-- Header + Filters -->
|
||||
<div class="analytics-header">
|
||||
<h1 class="page-title">Analytics</h1>
|
||||
<div class="analytics-filters">
|
||||
<select class="select" style="width:auto;"
|
||||
onchange="window.location.href='/analytics?minutes='+this.value+'@(Model.TaskId.HasValue ? "&taskId=" + Model.TaskId : "")'">
|
||||
@{
|
||||
var timeOptions = new[] { (1440, "Today"), (10080, "7 days"), (43200, "30 days") };
|
||||
}
|
||||
@foreach (var (val, label) in timeOptions)
|
||||
{
|
||||
if (Model.Minutes == val)
|
||||
{
|
||||
<option value="@val" selected="selected">@label</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@val">@label</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<select class="select" style="width:auto;"
|
||||
onchange="window.location.href='/analytics?minutes=@Model.Minutes'+(this.value ? '&taskId='+this.value : '')">
|
||||
<option value="">All Tasks</option>
|
||||
@foreach (var t in Model.Tasks)
|
||||
{
|
||||
if (Model.TaskId == t.Id)
|
||||
{
|
||||
<option value="@t.Id" selected="selected">@t.Title</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@t.Id">@t.Title</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat Cards -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-label">Open Tasks</span>
|
||||
<p class="stat-card-value">@Model.OpenTaskCount</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-label">Active Time</span>
|
||||
<p class="stat-card-value">@Model.ActiveTimeDisplay</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-label">Top Category</span>
|
||||
<p class="stat-card-value">@Model.TopCategory</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Chart -->
|
||||
<section>
|
||||
<h2 class="section-title">Activity Timeline</h2>
|
||||
<div class="surface">
|
||||
<div class="chart-container">
|
||||
<canvas id="timeline-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<section>
|
||||
<h2 class="section-title">Category Breakdown</h2>
|
||||
<div class="surface">
|
||||
<div style="display:flex; gap:40px; align-items:center;">
|
||||
<div class="chart-container" style="width:280px; height:280px;">
|
||||
<canvas id="category-chart"></canvas>
|
||||
</div>
|
||||
<div id="category-legend" class="category-legend" style="flex:1;">
|
||||
<!-- Legend rendered by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Activity Feed -->
|
||||
<section>
|
||||
<h2 class="section-title">Recent Activity</h2>
|
||||
<div class="surface">
|
||||
@if (Model.ActivityItems.Count == 0)
|
||||
{
|
||||
<div class="search-empty">No activity recorded in this time range.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div id="activity-feed" class="activity-feed">
|
||||
@foreach (var item in Model.ActivityItems)
|
||||
{
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot" style="background: var(--color-accent)"></span>
|
||||
<div class="activity-line"></div>
|
||||
<div class="activity-info">
|
||||
<span class="activity-app">@item.AppName</span>
|
||||
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
|
||||
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalActivityCount > Model.ActivityItems.Count)
|
||||
{
|
||||
<button class="btn btn--ghost btn--full"
|
||||
hx-get="/analytics?handler=ActivityFeed&minutes=@Model.Minutes&taskId=@Model.TaskId&offset=@Model.ActivityItems.Count"
|
||||
hx-target="#activity-feed"
|
||||
hx-swap="beforeend">
|
||||
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js data -->
|
||||
<script>
|
||||
var timelineData = @Html.Raw(Model.TimelineChartJson);
|
||||
var categoryData = @Html.Raw(Model.CategoryChartJson);
|
||||
</script>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Timeline bar chart
|
||||
if (timelineData.length > 0) {
|
||||
new Chart(document.getElementById('timeline-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: timelineData.map(function(d) { return d.hour; }),
|
||||
datasets: [{
|
||||
data: timelineData.map(function(d) { return d.count; }),
|
||||
backgroundColor: timelineData.map(function(d) { return d.color; }),
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(items) { return items[0].label; },
|
||||
label: function(item) {
|
||||
var d = timelineData[item.dataIndex];
|
||||
return d.category + ': ' + d.count + ' events';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#64748b', font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(255,255,255,0.04)' },
|
||||
ticks: { color: '#64748b', font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Category donut chart
|
||||
if (categoryData.length > 0) {
|
||||
new Chart(document.getElementById('category-chart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categoryData.map(function(d) { return d.category; }),
|
||||
datasets: [{
|
||||
data: categoryData.map(function(d) { return d.count; }),
|
||||
backgroundColor: categoryData.map(function(d) { return d.color; }),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(item) {
|
||||
var total = categoryData.reduce(function(a, b) { return a + b.count; }, 0);
|
||||
var pct = Math.round((item.raw / total) * 100);
|
||||
return item.label + ': ' + item.raw + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Render legend
|
||||
var total = categoryData.reduce(function(a, b) { return a + b.count; }, 0);
|
||||
var legendHtml = categoryData.map(function(d) {
|
||||
var pct = Math.round((d.count / total) * 100);
|
||||
return '<div class="category-legend-item">' +
|
||||
'<span class="category-legend-dot" style="background:' + d.color + '"></span>' +
|
||||
'<span class="category-legend-name">' + d.category + '</span>' +
|
||||
'<span class="category-legend-count">' + d.count + '</span>' +
|
||||
'<span class="category-legend-pct">' + pct + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
document.getElementById('category-legend').innerHTML = legendHtml;
|
||||
}
|
||||
</script>
|
||||
}
|
||||
205
TaskTracker.Api/Pages/Analytics.cshtml.cs
Normal file
205
TaskTracker.Api/Pages/Analytics.cshtml.cs
Normal file
@@ -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<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");
|
||||
}
|
||||
}
|
||||
15
TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml
Normal file
15
TaskTracker.Api/Pages/Partials/_ActivityFeedItems.cshtml
Normal file
@@ -0,0 +1,15 @@
|
||||
@using TaskTracker.Api.Pages
|
||||
@model AnalyticsModel
|
||||
|
||||
@foreach (var item in Model.ActivityItems)
|
||||
{
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot" style="background: var(--color-accent)"></span>
|
||||
<div class="activity-line"></div>
|
||||
<div class="activity-info">
|
||||
<span class="activity-app">@item.AppName</span>
|
||||
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
|
||||
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user