Files
TaskTracker/TaskTracker.Api/Pages/Analytics.cshtml
AJ Isaacs d784f9fea8 feat(web): add real-time activity feed via SignalR
- Add ActivityHub and wire up SignalR in Program.cs
- Broadcast new context events from ContextController
- Connect SignalR client on Analytics page for live feed updates
- Restructure activity feed HTML to support live prepending
- Add slide-in animation for new activity items
- Update CORS to allow credentials for SignalR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:30:01 -05:00

217 lines
8.4 KiB
Plaintext

@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">
<div id="activity-feed" class="activity-feed">
@if (Model.ActivityItems.Count == 0)
{
<div class="search-empty" id="activity-empty">No activity recorded in this time range.</div>
}
@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>
}