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>
220 lines
8.5 KiB
Plaintext
220 lines
8.5 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">
|
|
@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>
|
|
}
|