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>
This commit is contained in:
2026-03-01 23:30:01 -05:00
parent 5a273ba667
commit d784f9fea8
8 changed files with 125 additions and 29 deletions

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using TaskTracker.Api.Hubs;
using TaskTracker.Core.DTOs;
using TaskTracker.Core.Entities;
using TaskTracker.Core.Interfaces;
@@ -11,6 +13,7 @@ public class ContextController(
IContextEventRepository contextRepo,
IAppMappingRepository mappingRepo,
ITaskRepository taskRepo,
IHubContext<ActivityHub> hubContext,
ILogger<ContextController> logger) : ControllerBase
{
[HttpPost]
@@ -37,6 +40,17 @@ public class ContextController(
}
var created = await contextRepo.CreateAsync(contextEvent);
await hubContext.Clients.All.SendAsync("NewActivity", new
{
id = created.Id,
appName = created.AppName,
windowTitle = created.WindowTitle,
url = created.Url,
timestamp = created.Timestamp,
workTaskId = created.WorkTaskId
});
return Ok(ApiResponse<ContextEvent>.Ok(created));
}

View File

@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.SignalR;
namespace TaskTracker.Api.Hubs;
public class ActivityHub : Hub { }

View File

@@ -87,35 +87,32 @@
<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)
<div id="activity-feed" class="activity-feed">
@if (Model.ActivityItems.Count == 0)
{
<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 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>

View File

@@ -78,6 +78,7 @@
<script src="~/lib/htmx.min.js"></script>
<script src="~/lib/Sortable.min.js"></script>
<script src="~/lib/chart.umd.min.js"></script>
<script src="~/lib/signalr.min.js"></script>
<script src="~/js/app.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TaskTracker.Api.Hubs;
using TaskTracker.Core.Interfaces;
using TaskTracker.Infrastructure.Data;
using TaskTracker.Infrastructure.Repositories;
@@ -31,12 +32,16 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
policy.SetIsOriginAllowed(_ => true)
.AllowAnyHeader()
.AllowAnyMethod();
.AllowAnyMethod()
.AllowCredentials();
});
});
// SignalR
builder.Services.AddSignalR();
// Razor Pages
builder.Services.AddRazorPages();
@@ -57,5 +62,6 @@ app.UseCors();
app.UseStaticFiles();
app.MapRazorPages();
app.MapControllers();
app.MapHub<ActivityHub>("/hubs/activity");
app.Run();

View File

@@ -1281,6 +1281,21 @@ body::before {
flex-shrink: 0;
}
@keyframes activity-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.activity-item--new {
animation: activity-slide-in 0.3s ease-out;
}
/* ============================================================
CHART CONTAINER

View File

@@ -272,4 +272,60 @@
}
});
// ========================================
// SignalR — Real-time Activity Feed
// ========================================
function initActivityHub() {
var feed = document.getElementById('activity-feed');
if (!feed) return; // Not on the Analytics page
var connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/activity')
.withAutomaticReconnect()
.build();
connection.on('NewActivity', function(data) {
// Remove the empty-state placeholder if present
var empty = document.getElementById('activity-empty');
if (empty) empty.remove();
var item = document.createElement('div');
item.className = 'activity-item activity-item--new';
var displayText = data.url || data.windowTitle || '';
// Escape HTML to prevent XSS
var div = document.createElement('div');
div.textContent = data.appName || '';
var safeApp = div.innerHTML;
div.textContent = displayText;
var safeTitle = div.innerHTML;
item.innerHTML =
'<span class="activity-dot" style="background: var(--color-accent)"></span>' +
'<div class="activity-line"></div>' +
'<div class="activity-info">' +
'<span class="activity-app">' + safeApp + '</span>' +
'<span class="activity-title">' + safeTitle + '</span>' +
'<span class="activity-time">just now</span>' +
'</div>';
feed.insertBefore(item, feed.firstChild);
// Cap visible items at 100 to prevent memory bloat
var items = feed.querySelectorAll('.activity-item');
if (items.length > 100) {
items[items.length - 1].remove();
}
});
connection.start().catch(function(err) {
console.error('SignalR connection error:', err);
});
}
document.addEventListener('DOMContentLoaded', function() {
initActivityHub();
});
})();

File diff suppressed because one or more lines are too long