11===============================================================================
22PROJECT EXPORT
3- Generated: Fri Jan 16 08:24:55 PM EST 2026
3+ Generated: Fri Jan 16 08:42:23 PM EST 2026
44Project Path: /home/kushal/src/dotnet/MyBlog
55===============================================================================
66
@@ -144,6 +144,8 @@ DIRECTORY STRUCTURE:
144144│ │ │ ├── App.razor
145145│ │ │ ├── _Imports.razor
146146│ │ │ └── Routes.razor
147+ │ │ ├── Hubs
148+ │ │ │ └── ReaderHub.cs
147149│ │ ├── Middleware
148150│ │ │ └── LoginRateLimitMiddleware.cs
149151│ │ ├── wwwroot
@@ -4097,8 +4099,8 @@ MODIFIED: 2026-01-01 22:21:17
40974099
40984100================================================================================
40994101FILE: src/Directory.Packages.props
4100- SIZE: 1.36 KB
4101- MODIFIED: 2026-01-13 21:25:12
4102+ SIZE: 1.45 KB
4103+ MODIFIED: 2026-01-16 20:29:40
41024104================================================================================
41034105
41044106<Project>
@@ -4108,6 +4110,7 @@ MODIFIED: 2026-01-13 21:25:12
41084110 </PropertyGroup>
41094111 <ItemGroup>
41104112 <!-- Core Framework (.NET 10) -->
4113+ <PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
41114114 <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
41124115 <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1" />
41134116 <PackageVersion Include="Microsoft.AspNetCore.Identity" Version="2.3.9" />
@@ -10004,25 +10007,36 @@ public interface IPostRepository
1000410007
1000510008================================================================================
1000610009FILE: src/MyBlog.Core/Interfaces/IReaderTrackingService.cs
10007- SIZE: .39 KB
10008- MODIFIED: 2026-01-13 08:55:34
10010+ SIZE: .90 KB
10011+ MODIFIED: 2026-01-16 20:30:07
1000910012================================================================================
1001010013
1001110014namespace MyBlog.Core.Interfaces;
1001210015
1001310016public interface IReaderTrackingService
1001410017{
10015- // Called when a user opens a post
10016- void JoinPost(string slug);
10018+ /// <summary>
10019+ /// Registers a connection viewing a specific post.
10020+ /// </summary>
10021+ /// <returns>The new reader count for this slug.</returns>
10022+ int JoinPost(string slug, string connectionId);
1001710023
10018- // Called when a user leaves (closes tab/navigates away)
10019- void LeavePost(string slug);
10024+ /// <summary>
10025+ /// Unregisters a connection from a specific post.
10026+ /// </summary>
10027+ /// <returns>The new reader count for this slug.</returns>
10028+ int LeavePost(string slug, string connectionId);
1002010029
10021- // Gets the current count
10022- int GetReaderCount(string slug);
10030+ /// <summary>
10031+ /// Handles a disconnection event (e.g. tab closed) and determines which slug was being viewed.
10032+ /// </summary>
10033+ /// <returns>A tuple containing the Slug that was left (if any) and the new count.</returns>
10034+ (string? Slug, int NewCount) Disconnect(string connectionId);
1002310035
10024- // Event that fires when the count changes
10025- event Action<string, int>? OnCountChanged;
10036+ /// <summary>
10037+ /// Gets the current count for a specific post.
10038+ /// </summary>
10039+ int GetReaderCount(string slug);
1002610040}
1002710041
1002810042
@@ -11374,8 +11388,8 @@ public sealed class PasswordService : IPasswordService
1137411388
1137511389================================================================================
1137611390FILE: src/MyBlog.Infrastructure/Services/ReaderTrackingService.cs
11377- SIZE: 1.19 KB
11378- MODIFIED: 2026-01-16 19:03:44
11391+ SIZE: 1.57 KB
11392+ MODIFIED: 2026-01-16 20:30:23
1137911393================================================================================
1138011394
1138111395using System.Collections.Concurrent;
@@ -11385,38 +11399,46 @@ namespace MyBlog.Infrastructure.Services;
1138511399
1138611400public class ReaderTrackingService : IReaderTrackingService
1138711401{
11388- // Thread-safe dictionary to store counts: Slug -> Count
11389- private readonly ConcurrentDictionary<string, int> _activeReaders = new();
11402+ // Maps Slug -> Count of active readers
11403+ private readonly ConcurrentDictionary<string, int> _slugCounts = new();
1139011404
11391- public event Action<string, int>? OnCountChanged;
11405+ // Maps ConnectionId -> Slug (Reverse lookup to handle disconnects)
11406+ private readonly ConcurrentDictionary<string, string> _connectionMap = new();
1139211407
11393- public void JoinPost(string slug)
11408+ public int JoinPost(string slug, string connectionId )
1139411409 {
11395- // Atomically increment the count
11396- var newCount = _activeReaders .AddOrUpdate(slug, 1 , (_, count ) => count + 1 );
11410+ // Map the connection to the slug
11411+ _connectionMap .AddOrUpdate(connectionId, slug , (_, _ ) => slug );
1139711412
11398- // Notify subscribers
11399- OnCountChanged?.Invoke (slug, newCount );
11413+ // Increment the count for this slug
11414+ return _slugCounts.AddOrUpdate (slug, 1, (_, count) => count + 1 );
1140011415 }
1140111416
11402- public void LeavePost(string slug)
11417+ public int LeavePost(string slug, string connectionId )
1140311418 {
11404- // Atomically decrement the count
11405- var newCount = _activeReaders.AddOrUpdate(slug, 0, (_, count) => count > 0 ? count - 1 : 0);
11419+ // Remove the connection mapping
11420+ _connectionMap.TryRemove(connectionId, out _);
11421+
11422+ // Decrement the count
11423+ return _slugCounts.AddOrUpdate(slug, 0, (_, count) => count > 0 ? count - 1 : 0);
11424+ }
1140611425
11407- // If count is 0, we could remove the key, but keeping it is harmless for small blogs
11408- if (newCount == 0)
11426+ public (string? Slug, int NewCount) Disconnect(string connectionId)
11427+ {
11428+ // Find which slug this connection was watching
11429+ if (_connectionMap.TryRemove(connectionId, out var slug))
1140911430 {
11410- _activeReaders.TryRemove(slug, out _);
11431+ // Decrement that slug's count
11432+ var newCount = _slugCounts.AddOrUpdate(slug, 0, (_, count) => count > 0 ? count - 1 : 0);
11433+ return (slug, newCount);
1141111434 }
1141211435
11413- // Notify subscribers
11414- OnCountChanged?.Invoke(slug, newCount);
11436+ return (null, 0);
1141511437 }
1141611438
1141711439 public int GetReaderCount(string slug)
1141811440 {
11419- return _activeReaders .TryGetValue(slug, out var count) ? count : 0;
11441+ return _slugCounts .TryGetValue(slug, out var count) ? count : 0;
1142011442 }
1142111443}
1142211444
@@ -14136,6 +14158,62 @@ MODIFIED: 2026-01-13 21:06:07
1413614158}
1413714159
1413814160
14161+ ================================================================================
14162+ FILE: src/MyBlog.Web/Hubs/ReaderHub.cs
14163+ SIZE: 1.39 KB
14164+ MODIFIED: 2026-01-16 20:30:48
14165+ ================================================================================
14166+
14167+ using Microsoft.AspNetCore.SignalR;
14168+ using MyBlog.Core.Interfaces;
14169+
14170+ namespace MyBlog.Web.Hubs;
14171+
14172+ public class ReaderHub : Hub
14173+ {
14174+ private readonly IReaderTrackingService _trackingService;
14175+
14176+ public ReaderHub(IReaderTrackingService trackingService)
14177+ {
14178+ _trackingService = trackingService;
14179+ }
14180+
14181+ public async Task JoinPage(string slug)
14182+ {
14183+ // Add this connection to the SignalR group for this slug
14184+ await Groups.AddToGroupAsync(Context.ConnectionId, slug);
14185+
14186+ // Update state
14187+ var newCount = _trackingService.JoinPost(slug, Context.ConnectionId);
14188+
14189+ // Broadcast new count to everyone in this group
14190+ await Clients.Group(slug).SendAsync("UpdateCount", newCount);
14191+ }
14192+
14193+ public async Task LeavePage(string slug)
14194+ {
14195+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, slug);
14196+
14197+ var newCount = _trackingService.LeavePost(slug, Context.ConnectionId);
14198+
14199+ await Clients.Group(slug).SendAsync("UpdateCount", newCount);
14200+ }
14201+
14202+ public override async Task OnDisconnectedAsync(Exception? exception)
14203+ {
14204+ // Handle abrupt disconnects (tab closed, network lost)
14205+ var (slug, newCount) = _trackingService.Disconnect(Context.ConnectionId);
14206+
14207+ if (!string.IsNullOrEmpty(slug))
14208+ {
14209+ await Clients.Group(slug).SendAsync("UpdateCount", newCount);
14210+ }
14211+
14212+ await base.OnDisconnectedAsync(exception);
14213+ }
14214+ }
14215+
14216+
1413914217================================================================================
1414014218FILE: src/MyBlog.Web/Middleware/LoginRateLimitMiddleware.cs
1414114219SIZE: 5.64 KB
@@ -14337,8 +14415,8 @@ public static class LoginRateLimitMiddlewareExtensions
1433714415
1433814416================================================================================
1433914417FILE: src/MyBlog.Web/MyBlog.Web.csproj
14340- SIZE: .55 KB
14341- MODIFIED: 2026-01-13 21:22:06
14418+ SIZE: .62 KB
14419+ MODIFIED: 2026-01-16 20:29:40
1434214420================================================================================
1434314421
1434414422<Project Sdk="Microsoft.NET.Sdk.Web">
@@ -14347,6 +14425,7 @@ MODIFIED: 2026-01-13 21:22:06
1434714425 </PropertyGroup>
1434814426
1434914427 <ItemGroup>
14428+ <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
1435014429 <PackageReference Include="OpenTelemetry.Extensions.Hosting" />
1435114430 <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
1435214431 <PackageReference Include="OpenTelemetry.Exporter.Console" />
@@ -14360,8 +14439,8 @@ MODIFIED: 2026-01-13 21:22:06
1436014439
1436114440================================================================================
1436214441FILE: src/MyBlog.Web/Program.cs
14363- SIZE: 3.73 KB
14364- MODIFIED: 2026-01-14 19:57:11
14442+ SIZE: 3.84 KB
14443+ MODIFIED: 2026-01-16 20:31:17
1436514444================================================================================
1436614445
1436714446using Microsoft.AspNetCore.Authentication;
@@ -14374,6 +14453,7 @@ using MyBlog.Infrastructure.Data;
1437414453using MyBlog.Infrastructure.Services;
1437514454using MyBlog.Infrastructure.Telemetry;
1437614455using MyBlog.Web.Components;
14456+ using MyBlog.Web.Hubs; // ADD THIS
1437714457using MyBlog.Web.Middleware;
1437814458using OpenTelemetry;
1437914459using OpenTelemetry.Logs;
@@ -14387,6 +14467,9 @@ var builder = WebApplication.CreateBuilder(args);
1438714467builder.Services.AddRazorComponents()
1438814468 .AddInteractiveServerComponents();
1438914469
14470+ // ADD THIS: Register SignalR
14471+ builder.Services.AddSignalR();
14472+
1439014473builder.Services.AddInfrastructure(builder.Configuration);
1439114474
1439214475// Register TelemetryCleanupService as a hosted service
@@ -14400,7 +14483,7 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
1440014483 options.Cookie.Name = AppConstants.AuthCookieName;
1440114484 options.LoginPath = "/login";
1440214485 options.LogoutPath = "/logout";
14403- options.AccessDeniedPath = "/access-denied"; // FIX: Added explicit Access Denied path
14486+ options.AccessDeniedPath = "/access-denied";
1440414487 options.ExpireTimeSpan = TimeSpan.FromMinutes(sessionTimeout);
1440514488 options.SlidingExpiration = true;
1440614489 options.Cookie.HttpOnly = true;
@@ -14417,6 +14500,7 @@ builder.Services.AddAntiforgery();
1441714500// OpenTelemetry configuration
1441814501var serviceName = "MyBlog.Web";
1441914502var serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0";
14503+
1442014504builder.Services.AddOpenTelemetry()
1442114505 .ConfigureResource(resource => resource
1442214506 .AddService(serviceName: serviceName, serviceVersion: serviceVersion))
@@ -14477,6 +14561,9 @@ app.MapPost("/logout", async (HttpContext context) =>
1447714561 return Results.Redirect("/");
1447814562}).RequireAuthorization();
1447914563
14564+ // ADD THIS: Map the Hub
14565+ app.MapHub<ReaderHub>("/readerHub");
14566+
1448014567app.MapRazorComponents<App>()
1448114568 .AddInteractiveServerRenderMode();
1448214569
@@ -16292,9 +16379,9 @@ echo "=============================================="
1629216379
1629316380
1629416381===============================================================================
16295- EXPORT COMPLETED: Fri Jan 16 08:24:56 PM EST 2026
16296- Total Files Found: 85
16297- Files Exported: 85
16382+ EXPORT COMPLETED: Fri Jan 16 08:42:24 PM EST 2026
16383+ Total Files Found: 86
16384+ Files Exported: 86
1629816385Files Skipped: 0 (binary or large files)
1629916386Output File: /home/kushal/src/dotnet/MyBlog/docs/llm/dump.txt
1630016387===============================================================================
0 commit comments