- 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>
217 lines
8.4 KiB
Plaintext
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>
|
|
}
|