Files
TaskTracker/TaskTracker.Api/Pages/Analytics.cshtml
T
ajandClaude Fable 5 d12839d6ba fix(web): show empty-state message when analytics charts have no data
Charts previously rendered as blank boxes when no context events
existed in the selected range, which looked broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:12:43 -04:00

230 lines
9.0 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>
function showChartEmptyState(canvasId, message) {
var canvas = document.getElementById(canvasId);
var container = canvas.closest('.chart-container');
container.innerHTML = '<div class="search-empty">' + message + '</div>';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
}
// Timeline bar chart
if (timelineData.length === 0) {
showChartEmptyState('timeline-chart', 'No activity recorded in this time range.');
} else {
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) {
showChartEmptyState('category-chart', 'No categorized activity in this time range.');
} else {
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>
}