diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs new file mode 100644 index 00000000..10289651 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Net.Http.Json; +using EssentialCSharp.Web.Controllers; + +namespace EssentialCSharp.Web.Tests; + +public class ReadingControllerTests : IntegrationTestBase +{ + + [Test] + public async Task GetBookStats_IsPublic_Returns200() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task GetBookStats_ReturnsExpectedShape() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + await Assert.That(body!.TotalWordCount).IsGreaterThanOrEqualTo(0); + await Assert.That(body.Chapters).IsNotNull(); + } + + // ---- profile (requires auth) ---- + + [Test] + public async Task GetProfile_Anonymous_Returns401() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await client.GetAsync("/api/reading/profile"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // ---- POST session (requires auth) ---- + + [Test] + public async Task PostSession_Anonymous_Returns401() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + var intervals = new[] + { + new ReadingController.ReadingIntervalDto("page1", 60, 100, false) + }; + using HttpResponseMessage response = await client.PostAsJsonAsync("/api/reading/session", intervals); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // ---- POST reset (requires auth) ---- + + [Test] + public async Task PostReset_Anonymous_Returns401() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // ---- WPM algorithm unit tests (test logic directly, no HTTP) ---- + + [Test] + public async Task WpmAlgorithm_FastInterval_IsDiscarded() + { + // Hard discard: >900 WPM → interval dropped entirely + long discardWords = 901; + long discardSeconds = 60; + double discardWpm = discardWords / (discardSeconds / 60.0); // 901 WPM + + await Assert.That(discardWpm).IsGreaterThan(900.0); + + // Accepted interval: 600 WPM (below cutoff) + long acceptWords = 600; + long acceptSeconds = 60; + double acceptWpm = acceptWords / (acceptSeconds / 60.0); // 600 WPM + + await Assert.That(acceptWpm).IsLessThanOrEqualTo(900.0); + } + + [Test] + public async Task WpmAlgorithm_SlowInterval_IsClamped() + { + // Reference WPM = 200. An interval of 10 words in 300 seconds = + // 10 / (300/60) = 2 WPM. 0.25 * 200 = 50 WPM → interval is below threshold → clamp. + double referenceWpm = 200.0; + double slowOutlierFactor = 0.25; + double intervalWpm = 10.0 / (300.0 / 60.0); // ~2 WPM + + bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm; + await Assert.That(shouldClamp).IsTrue(); + + // After clamp: effectiveSeconds = words / (0.25 * referenceWpm) * 60 + int words = 10; + int effectiveSeconds = (int)Math.Round(words / (slowOutlierFactor * referenceWpm) * 60.0); + double clampedWpm = words / (effectiveSeconds / 60.0); + + // Clamped WPM should equal exactly 0.25 * referenceWpm (within rounding) + await Assert.That(clampedWpm).IsEqualTo(50.0).Within(1.0); + } + + [Test] + public async Task WpmAlgorithm_NormalInterval_PassesThrough() + { + // 180 WPM interval with referenceWpm = 200. + // 180 > 0.25 * 200 = 50, so no clamping. + double referenceWpm = 200.0; + const double slowOutlierFactor = 0.25; + double intervalWpm = 180.0; + + bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm; + await Assert.That(shouldClamp).IsFalse(); + + bool shouldDiscard = intervalWpm > 900.0; + await Assert.That(shouldDiscard).IsFalse(); + } + + [Test] + public async Task GetBookStats_Returns200WithChapters() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); + await Assert.That((int)response.StatusCode).IsEqualTo(200); + + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body).IsNotNull(); + } + + // ---- DTO for deserializing book-stats response ---- + + private sealed class BookStatsResponse + { + public int TotalWordCount { get; set; } + public IEnumerable? Chapters { get; set; } + } + + private sealed class ChapterInfo + { + public int ChapterNumber { get; set; } + public int WordCount { get; set; } + } +} diff --git a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs new file mode 100644 index 00000000..3dcf5557 --- /dev/null +++ b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs @@ -0,0 +1,232 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Hosting; +using Moq; + +namespace EssentialCSharp.Web.Tests; + +public class WordCountServiceTests +{ + // ---- Helpers ---- + + private static (string tempDir, string filePath) WriteTempHtmlFile(string html) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + string filePath = Path.Combine(tempDir, "page.html"); + File.WriteAllText(filePath, html); + return (tempDir, filePath); + } + + private static SiteMapping MakeMapping(string key, string contentRoot, string relativePath, int chapter, int page, int order = 1) + { + // PagePath is relative to contentRoot as individual path segments. + string[] segments = relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(); + return new SiteMapping( + keys: [key], + primaryKey: key, + pagePath: segments, + chapterNumber: chapter, + pageNumber: page, + orderOnPage: order, + chapterTitle: $"Chapter {chapter}", + rawHeading: key, + anchorId: key, + indentLevel: 0 + ); + } + + private static (WordCountService service, string tempDir) CreateService(IList mappings, string contentRoot) + { + Mock siteMappingMock = new(); + siteMappingMock.Setup(s => s.SiteMappings).Returns(mappings); + + Mock envMock = new(); + envMock.Setup(e => e.ContentRootPath).Returns(contentRoot); + // IWebHostEnvironment also inherits IHostEnvironment, but WordCountService only uses ContentRootPath. + + var service = new WordCountService(siteMappingMock.Object, envMock.Object); + return (service, contentRoot); + } + + // ---- Tests ---- + + [Test] + public async Task GetPageWordCount_PlainProse_ReturnsCorrectCount() + { + // Arrange + const string html = "

Hello world this is a test.

"; + (string tempDir, string filePath) = WriteTempHtmlFile(html); + try + { + string relativePath = Path.GetFileName(filePath); + SiteMapping mapping = MakeMapping("page1", tempDir, relativePath, 1, 1); + (WordCountService service, _) = CreateService([mapping], tempDir); + + // Act + int count = service.GetPageWordCount("page1"); + + // Assert — "Hello world this is a test." = 6 words + await Assert.That(count).IsEqualTo(6); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetPageWordCount_ExcludesPreAndCodeBlocks() + { + // Arrange — 3 prose words + code block that should be excluded + const string html = """ + +

Hello world prose.

+
public static void Main() { var x = 1; }
+ Console.WriteLine(x); + + """; + (string tempDir, string filePath) = WriteTempHtmlFile(html); + try + { + string relativePath = Path.GetFileName(filePath); + SiteMapping mapping = MakeMapping("page2", tempDir, relativePath, 1, 1); + (WordCountService service, _) = CreateService([mapping], tempDir); + + // Act + int count = service.GetPageWordCount("page2"); + + // Assert — only "Hello world prose." = 3 words + await Assert.That(count).IsEqualTo(3); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetPageWordCount_ExcludesScriptAndStyle() + { + const string html = """ + + +

Two words.

+ + + """; + (string tempDir, string filePath) = WriteTempHtmlFile(html); + try + { + string relativePath = Path.GetFileName(filePath); + SiteMapping mapping = MakeMapping("page3", tempDir, relativePath, 1, 1); + (WordCountService service, _) = CreateService([mapping], tempDir); + + int count = service.GetPageWordCount("page3"); + + // "Two words." = 2 + await Assert.That(count).IsEqualTo(2); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetPageWordCount_FileNotFound_ReturnsZero() + { + // Arrange — map a page that doesn't have a corresponding file + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + SiteMapping mapping = MakeMapping("missing", tempDir, "nonexistent.html", 1, 1); + (WordCountService service, _) = CreateService([mapping], tempDir); + + int count = service.GetPageWordCount("missing"); + + await Assert.That(count).IsEqualTo(0); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetChapterWordCount_SumsAllPagesInChapter() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // Page A: 3 words; Page B: 4 words → chapter total 7 + File.WriteAllText(Path.Combine(tempDir, "a.html"), "

one two three

"); + File.WriteAllText(Path.Combine(tempDir, "b.html"), "

four five six seven

"); + + SiteMapping[] mappings = + [ + MakeMapping("a", tempDir, "a.html", 1, 1), + MakeMapping("b", tempDir, "b.html", 1, 2), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(7); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetBookWordCount_SumsAllChapters() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "ch1.html"), "

alpha beta

"); // 2 words + File.WriteAllText(Path.Combine(tempDir, "ch2.html"), "

gamma delta epsilon

"); // 3 words + + SiteMapping[] mappings = + [ + MakeMapping("ch1", tempDir, "ch1.html", 1, 1), + MakeMapping("ch2", tempDir, "ch2.html", 2, 1), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetBookWordCount()).IsEqualTo(5); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetWordsBeforePage_ReturnsCorrectPrefixSum() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "p1.html"), "

one two

"); // 2 words + File.WriteAllText(Path.Combine(tempDir, "p2.html"), "

three

"); // 1 word + + SiteMapping[] mappings = + [ + MakeMapping("p1", tempDir, "p1.html", 1, 1), + MakeMapping("p2", tempDir, "p2.html", 1, 2), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetWordsBeforePage("p1")).IsEqualTo(0); // first page + await Assert.That(service.GetWordsBeforePage("p2")).IsEqualTo(2); // 2 words from p1 + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index aaf81356..545fb2c4 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -11,9 +11,28 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!; public DbSet McpApiTokens { get; set; } = null!; + public DbSet ReadingActivities { get; set; } = null!; + public DbSet UserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => e.UserId); + entity.HasOne(e => e.User) + .WithOne() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index f63c961f..549d2667 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -35,9 +35,54 @@ ViewData["ActivePage"] = ManageNavPages.Index; + +
+

Reading Speed

+

+ Essential C# tracks your reading speed to estimate how long chapters and the book will take you. + Resetting clears all recorded data and returns to the default estimate. +

+ + @section Scripts { + } diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 8829bd0e..8aa8f869 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -12,7 +12,7 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings) : BaseController(routeConfigurationService, httpContextAccessor) +public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings, IWordCountService wordCountService) : BaseController(routeConfigurationService, httpContextAccessor) { [EnableRateLimiting("content")] public IActionResult Index() @@ -34,12 +34,18 @@ public IActionResult Index() string headHtml = doc.DocumentNode.Element("html").Element("head").InnerHtml; string html = doc.DocumentNode.Element("html").Element("body").InnerHtml; + string pageKey = siteMapping.Keys.FirstOrDefault() ?? siteMapping.PrimaryKey ?? string.Empty; ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true); - ViewBag.CurrentPageKey = siteMapping.PrimaryKey; + ViewBag.CurrentPageKey = pageKey; ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; + ViewBag.PageWordCount = wordCountService.GetPageWordCount(pageKey); + ViewBag.ChapterWordCount = wordCountService.GetChapterWordCount(siteMapping.ChapterNumber); + ViewBag.WordsBeforePage = wordCountService.GetWordsBeforePage(pageKey); + ViewBag.ChapterStartWords = wordCountService.GetChapterStartWords(pageKey); + ViewBag.BookWordCount = wordCountService.GetBookWordCount(); return View(); } else diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs new file mode 100644 index 00000000..00719944 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -0,0 +1,253 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/reading")] +public partial class ReadingController( + EssentialCSharpWebContext context, + IWordCountService wordCountService, + ILogger logger) : ControllerBase +{ + // Algorithm constants (mirrors Kindle's ReadingTimer values). + private const int MaxWpmHardCutoff = 900; + private const double SlowOutlierFactor = 0.25; + private const int MaxReadingActivityRowsPerUser = 500; + + // High-performance logger messages (CA1848). + [LoggerMessage(Level = LogLevel.Debug, Message = "Discarding interval for {PageKey}: {Wpm:F0} WPM exceeds hard cutoff of {Cutoff}")] + private static partial void LogIntervalDiscarded(ILogger logger, string pageKey, double wpm, int cutoff); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Clamping slow interval for {PageKey}: {Wpm:F0} WPM → effective {EffSeconds}s")] + private static partial void LogIntervalClamped(ILogger logger, string pageKey, double wpm, int effSeconds); + + // ------------------------------------------------------------------------- + // GET /api/reading/book-stats (public — used by anonymous clients too) + // ------------------------------------------------------------------------- + + [HttpGet("book-stats")] + public IActionResult GetBookStats() + { + var chapters = wordCountService.GetChapterWordCounts() + .Select(c => new { chapterNumber = c.ChapterNumber, wordCount = c.WordCount }); + + return Ok(new + { + totalWordCount = wordCountService.GetBookWordCount(), + chapters + }); + } + + // ------------------------------------------------------------------------- + // GET /api/reading/profile (authenticated) + // ------------------------------------------------------------------------- + + [HttpGet("profile")] + [Authorize] + public async Task GetProfile(CancellationToken cancellationToken) + { + string userId = GetUserId(); + + UserReadingProfile? profile = await context.UserReadingProfiles + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + if (profile is null) + { + return Ok(new { totalWordsRead = 0L, totalActiveSeconds = 0L, wpm = (double?)null }); + } + + return Ok(new + { + totalWordsRead = profile.TotalWordsRead, + totalActiveSeconds = profile.TotalActiveSeconds, + wpm = profile.DeriveWpm() + }); + } + + // ------------------------------------------------------------------------- + // POST /api/reading/session (authenticated) + // ------------------------------------------------------------------------- + + public record ReadingIntervalDto( + string PageKey, + int ActiveSeconds, + int WordsRead, + bool Completed); + + [HttpPost("session")] + [Authorize] + public async Task PostSession( + [FromBody] IEnumerable intervals, + CancellationToken cancellationToken) + { + string userId = GetUserId(); + + if (intervals is null) + { + return BadRequest("intervals is required"); + } + + List intervalList = intervals.ToList(); + if (intervalList.Count == 0) + { + return Ok(); + } + + // Load or create profile (used as reference WPM for outlier clamping). + UserReadingProfile? profile = await context.UserReadingProfiles + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + double referenceWpm = profile?.DeriveWpm() ?? 0; + + long deltaWords = 0; + long deltaSeconds = 0; + var activities = new List(intervalList.Count); + + foreach (ReadingIntervalDto interval in intervalList) + { + if (interval.ActiveSeconds <= 0 || interval.WordsRead <= 0) + { + continue; + } + + double intervalWpm = interval.WordsRead / (interval.ActiveSeconds / 60.0); + + // Hard discard: too fast to be genuine reading. + if (intervalWpm > MaxWpmHardCutoff) + { + LogIntervalDiscarded(logger, interval.PageKey, intervalWpm, MaxWpmHardCutoff); + continue; + } + + // Soft clamp: interval is implausibly slow relative to reader's own rate. + int effectiveWords = interval.WordsRead; + int effectiveSeconds = interval.ActiveSeconds; + if (referenceWpm > 0 && intervalWpm < SlowOutlierFactor * referenceWpm) + { + // Clamp: keep the words, shrink the time so effective WPM = 0.25 × referenceWpm. + effectiveSeconds = (int)Math.Round(effectiveWords / (SlowOutlierFactor * referenceWpm) * 60.0); + LogIntervalClamped(logger, interval.PageKey, intervalWpm, effectiveSeconds); + } + + deltaWords += effectiveWords; + deltaSeconds += effectiveSeconds; + + activities.Add(new ReadingActivity + { + UserId = userId, + PageKey = interval.PageKey, + RecordedAtUtc = DateTime.UtcNow, + ActiveSeconds = interval.ActiveSeconds, + WordsRead = interval.WordsRead, + Completed = interval.Completed + }); + } + + // Persist inside a single transaction: insert detail rows, update aggregate, trim retention. + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + try + { + if (activities.Count > 0) + { + await context.ReadingActivities.AddRangeAsync(activities, cancellationToken); + } + + if (deltaWords > 0 || deltaSeconds > 0) + { + if (profile is null) + { + profile = new UserReadingProfile + { + UserId = userId, + TotalWordsRead = deltaWords, + TotalActiveSeconds = deltaSeconds, + UpdatedAtUtc = DateTime.UtcNow + }; + context.UserReadingProfiles.Add(profile); + } + else + { + profile.TotalWordsRead += deltaWords; + profile.TotalActiveSeconds += deltaSeconds; + profile.UpdatedAtUtc = DateTime.UtcNow; + } + } + + await context.SaveChangesAsync(cancellationToken); + + // Trim ReadingActivity to the newest 500 rows for this user. + // We do this after SaveChanges so the new rows are visible in the sub-query. + await context.Database.ExecuteSqlRawAsync( + """ + DELETE FROM [ReadingActivities] + WHERE [UserId] = {0} + AND [Id] NOT IN ( + SELECT TOP ({1}) [Id] + FROM [ReadingActivities] + WHERE [UserId] = {0} + ORDER BY [RecordedAtUtc] DESC + ) + """, + [userId, MaxReadingActivityRowsPerUser], + cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return Ok(new + { + totalWordsRead = profile?.TotalWordsRead ?? 0, + totalActiveSeconds = profile?.TotalActiveSeconds ?? 0, + wpm = profile?.DeriveWpm() + }); + } + + // ------------------------------------------------------------------------- + // POST /api/reading/reset (authenticated) + // ------------------------------------------------------------------------- + + [HttpPost("reset")] + [Authorize] + public async Task Reset(CancellationToken cancellationToken) + { + string userId = GetUserId(); + + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + try + { + await context.ReadingActivities + .Where(a => a.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return Ok(); + } + + // ------------------------------------------------------------------------- + + private string GetUserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new InvalidOperationException("Authenticated user has no NameIdentifier claim."); +} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs new file mode 100644 index 00000000..be4d7218 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs @@ -0,0 +1,448 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20260809064123_AddReadingTimeTracking")] + partial class AddReadingTimeTracking + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ReferralCount") + .HasColumnType("int"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("UsageCount") + .HasColumnType("bigint"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("McpApiTokens"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActiveSeconds") + .HasColumnType("int"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("PageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RecordedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("WordsRead") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PageKey"); + + b.HasIndex("UserId", "RecordedAtUtc"); + + b.ToTable("ReadingActivities"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithOne() + .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs new file mode 100644 index 00000000..48686a89 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable +#pragma warning disable CA1861 + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class AddReadingTimeTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReadingActivities", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + PageKey = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + RecordedAtUtc = table.Column(type: "datetime2", nullable: false), + ActiveSeconds = table.Column(type: "int", nullable: false), + WordsRead = table.Column(type: "int", nullable: false), + Completed = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingActivities", x => x.Id); + table.ForeignKey( + name: "FK_ReadingActivities_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserReadingProfiles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TotalWordsRead = table.Column(type: "bigint", nullable: false), + TotalActiveSeconds = table.Column(type: "bigint", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId); + table.ForeignKey( + name: "FK_UserReadingProfiles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingActivities_UserId_PageKey", + table: "ReadingActivities", + columns: new[] { "UserId", "PageKey" }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingActivities_UserId_RecordedAtUtc", + table: "ReadingActivities", + columns: new[] { "UserId", "RecordedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReadingActivities"); + + migrationBuilder.DropTable( + name: "UserReadingProfiles"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index a7f7c2b2..2d6c64af 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -141,6 +141,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("McpApiTokens"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActiveSeconds") + .HasColumnType("int"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("PageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RecordedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("WordsRead") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PageKey"); + + b.HasIndex("UserId", "RecordedAtUtc"); + + b.ToTable("ReadingActivities"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => { b.Property("Id") @@ -308,6 +367,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithOne() + .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Models/ReadingActivity.cs b/EssentialCSharp.Web/Models/ReadingActivity.cs new file mode 100644 index 00000000..6c02fa2b --- /dev/null +++ b/EssentialCSharp.Web/Models/ReadingActivity.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Models; + +/// +/// Records a single reading interval for a content page. +/// This is an audit/detail trail; the WPM math reads only +/// . +/// +[Index(nameof(UserId), nameof(RecordedAtUtc))] +[Index(nameof(UserId), nameof(PageKey))] +public class ReadingActivity +{ + public int Id { get; set; } + + [Required] + [MaxLength(450)] + public required string UserId { get; set; } + + /// + /// Matches for the page that was read. + /// + [Required] + [MaxLength(256)] + public required string PageKey { get; set; } + + public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow; + + /// Active (non-idle, non-hidden) seconds spent on this page. + public int ActiveSeconds { get; set; } + + /// Prose words credited to this reading interval (may be fractional for partial reads, rounded to int). + public int WordsRead { get; set; } + + /// True when the reader reached the bottom of the page content. + public bool Completed { get; set; } + + public EssentialCSharpWebUser? User { get; set; } +} diff --git a/EssentialCSharp.Web/Models/UserReadingProfile.cs b/EssentialCSharp.Web/Models/UserReadingProfile.cs new file mode 100644 index 00000000..298d4021 --- /dev/null +++ b/EssentialCSharp.Web/Models/UserReadingProfile.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Areas.Identity.Data; + +namespace EssentialCSharp.Web.Models; + +/// +/// Aggregate reading profile for a single user. +/// WPM is always derived as TotalWordsRead / (TotalActiveSeconds / 60.0); +/// it is never stored directly so there is no floating-point drift over time. +/// +public class UserReadingProfile +{ + /// Primary key — also the FK to . + [Required] + [MaxLength(450)] + public required string UserId { get; set; } + + /// + /// Cumulative prose words credited across all accepted, clamped reading intervals. + /// + public long TotalWordsRead { get; set; } + + /// + /// Cumulative active seconds across all accepted, clamped reading intervals. + /// + public long TotalActiveSeconds { get; set; } + + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; + + public EssentialCSharpWebUser? User { get; set; } + + /// + /// Derives the current words-per-minute estimate. Returns null if insufficient data. + /// + public double? DeriveWpm() => + TotalActiveSeconds > 0 ? TotalWordsRead / (TotalActiveSeconds / 60.0) : null; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 24e14289..c64689d4 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -270,6 +270,7 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); // Add AI Chat services using configuration-driven backend selection. diff --git a/EssentialCSharp.Web/Services/IWordCountService.cs b/EssentialCSharp.Web/Services/IWordCountService.cs new file mode 100644 index 00000000..92567a5e --- /dev/null +++ b/EssentialCSharp.Web/Services/IWordCountService.cs @@ -0,0 +1,35 @@ +namespace EssentialCSharp.Web.Services; + +/// +/// Provides pre-computed prose word counts for content pages, enabling +/// Kindle-style reading time estimates. +/// +public interface IWordCountService +{ + /// Gets the prose word count for a specific page key. + int GetPageWordCount(string pageKey); + + /// Gets the total prose word count for a chapter. + int GetChapterWordCount(int chapterNumber); + + /// Gets the total prose word count for the entire book. + int GetBookWordCount(); + + /// + /// Gets the number of prose words before the given page (across the entire book). + /// Used for "words remaining in book" math. + /// + int GetWordsBeforePage(string pageKey); + + /// + /// Gets the number of prose words before the given page within its chapter. + /// Used for "words remaining in chapter" math. + /// + int GetChapterStartWords(string pageKey); + + /// Gets per-chapter word count summary for the book-stats API response. + IReadOnlyList GetChapterWordCounts(); +} + +/// Per-chapter word count summary returned by the book-stats API. +public record ChapterWordCount(int ChapterNumber, int WordCount); diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs new file mode 100644 index 00000000..64400162 --- /dev/null +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -0,0 +1,154 @@ +using HtmlAgilityPack; + +namespace EssentialCSharp.Web.Services; + +/// +/// Singleton service that computes prose-only word counts for all content pages +/// at startup and caches them in memory. Code blocks (<pre>, +/// <code>, <script>, <style>) are excluded +/// because they are read very differently from prose and would skew WPM estimates. +/// +public class WordCountService : IWordCountService +{ + // Tags whose text content is excluded from prose word counts. + private static readonly HashSet ExcludedTags = new(StringComparer.OrdinalIgnoreCase) + { + "pre", "code", "script", "style" + }; + + private readonly Dictionary _pageWordCounts; + private readonly Dictionary _chapterWordCounts; + private readonly int _bookWordCount; + private readonly Dictionary _wordsBeforePage; + private readonly Dictionary _chapterStartWords; + private readonly IReadOnlyList _chapterWordCountList; + + public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironment hostingEnvironment) + { + _pageWordCounts = []; + _chapterWordCounts = []; + _wordsBeforePage = []; + _chapterStartWords = []; + + // Walk mappings in canonical reading order: chapter → page → order-on-page. + IEnumerable orderedMappings = siteMappingService.SiteMappings + .OrderBy(m => m.ChapterNumber) + .ThenBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage); + + int bookWords = 0; + int currentChapter = -1; + int chapterWords = 0; + int chapterBookOffset = 0; // words before first page of current chapter in book + + foreach (SiteMapping mapping in orderedMappings) + { + string? pageKey = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; + if (pageKey is null || _pageWordCounts.ContainsKey(pageKey)) + { + // Multiple anchors on the same page; skip duplicates (already counted). + continue; + } + + // Chapter boundary: flush previous chapter totals. + if (mapping.ChapterNumber != currentChapter) + { + if (currentChapter >= 0) + { + _chapterWordCounts[currentChapter] = chapterWords; + } + currentChapter = mapping.ChapterNumber; + chapterBookOffset = bookWords; + chapterWords = 0; + } + + int words = CountProseWords(hostingEnvironment.ContentRootPath, mapping.PagePath); + _pageWordCounts[pageKey] = words; + _wordsBeforePage[pageKey] = bookWords; + _chapterStartWords[pageKey] = chapterWords; + + bookWords += words; + chapterWords += words; + } + + // Flush the last chapter. + if (currentChapter >= 0) + { + _chapterWordCounts[currentChapter] = chapterWords; + } + + _bookWordCount = bookWords; + _chapterWordCountList = _chapterWordCounts + .OrderBy(kv => kv.Key) + .Select(kv => new ChapterWordCount(kv.Key, kv.Value)) + .ToList() + .AsReadOnly(); + } + + public int GetPageWordCount(string pageKey) => + _pageWordCounts.TryGetValue(pageKey, out int count) ? count : 0; + + public int GetChapterWordCount(int chapterNumber) => + _chapterWordCounts.TryGetValue(chapterNumber, out int count) ? count : 0; + + public int GetBookWordCount() => _bookWordCount; + + public int GetWordsBeforePage(string pageKey) => + _wordsBeforePage.TryGetValue(pageKey, out int count) ? count : 0; + + public int GetChapterStartWords(string pageKey) => + _chapterStartWords.TryGetValue(pageKey, out int count) ? count : 0; + + public IReadOnlyList GetChapterWordCounts() => _chapterWordCountList; + + // --- + + private static int CountProseWords(string contentRoot, string[] pagePath) + { + string filePath = Path.Join(contentRoot, Path.Join(pagePath)); + if (!File.Exists(filePath)) + { + return 0; + } + + HtmlDocument doc = new(); + doc.Load(filePath); + + HtmlNode? body = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode; + + // Remove excluded tags (modifies in-place on a clone isn't available, so remove from live tree). + // XPath: //pre | //code | //script | //style + string xpath = string.Join(" | ", ExcludedTags.Select(t => $"//{t}")); + foreach (HtmlNode node in body.SelectNodes(xpath)?.ToList() ?? []) + { + node.Remove(); + } + + string text = body.InnerText; + return CountWords(text); + } + + private static int CountWords(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return 0; + } + + int count = 0; + bool inWord = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) + { + inWord = false; + } + else if (!inWord) + { + inWord = true; + count++; + } + } + return count; + } +} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index d4968377..43be98ef 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -197,6 +197,12 @@ window.BUILD_LABEL = @Json.Serialize(buildLabel); window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey); + window.CURRENT_PAGE_KEY = @Json.Serialize(ViewBag.CurrentPageKey); + window.PAGE_WORD_COUNT = @Json.Serialize(ViewBag.PageWordCount ?? 0); + window.CHAPTER_WORD_COUNT = @Json.Serialize(ViewBag.ChapterWordCount ?? 0); + window.WORDS_BEFORE_PAGE = @Json.Serialize(ViewBag.WordsBeforePage ?? 0); + window.CHAPTER_START_WORDS = @Json.Serialize(ViewBag.ChapterStartWords ?? 0); + window.BOOK_WORD_COUNT = @Json.Serialize(ViewBag.BookWordCount ?? 0); diff --git a/EssentialCSharp.Web/src/components/HeaderStatus.vue b/EssentialCSharp.Web/src/components/HeaderStatus.vue index 2b15d05c..8fc46f0a 100644 --- a/EssentialCSharp.Web/src/components/HeaderStatus.vue +++ b/EssentialCSharp.Web/src/components/HeaderStatus.vue @@ -1,5 +1,6 @@ @@ -16,5 +17,6 @@ const shell = inject("shell"); + diff --git a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue new file mode 100644 index 00000000..66884d0e --- /dev/null +++ b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.js b/EssentialCSharp.Web/src/composables/useReadingTracker.js new file mode 100644 index 00000000..1e143b89 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.js @@ -0,0 +1,295 @@ +/** + * useReadingTracker — Kindle-style active-reading time tracker. + * + * State machine: READING → IDLE (after IDLE_THRESHOLD s of no input) + * → HIDDEN (tab not visible) + * Only accumulates activeSeconds while in READING state. + * + * Persists a rolling aggregate to: + * - Server (POST /api/reading/session) when authenticated. + * - localStorage key "readingProfile" for anonymous users and as a backup. + * + * On first authenticated page load, uploads the localStorage aggregate to the + * server and clears local storage (one-time sync after login). + */ + +import { ref, onMounted, onBeforeUnmount, readonly } from "vue"; + +// ----- Constants ----- +const IDLE_THRESHOLD_S = 300; // 5 minutes +const TICK_INTERVAL_MS = 1000; // 1 second timer +const DEFAULT_WPM = 200; // cold-start display default +const MOUSEMOVE_THROTTLE_MS = 1000; +const LOCAL_STORAGE_KEY = "readingProfile"; + +// ----- States ----- +const STATE_READING = "READING"; +const STATE_IDLE = "IDLE"; +const STATE_HIDDEN = "HIDDEN"; + +// ----- localStorage helpers ----- + +function loadLocalProfile() { + try { + const raw = localStorage.getItem(LOCAL_STORAGE_KEY); + if (!raw) return { totalWords: 0, totalActiveSeconds: 0 }; + return JSON.parse(raw); + } catch { + return { totalWords: 0, totalActiveSeconds: 0 }; + } +} + +function saveLocalProfile(profile) { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(profile)); + } catch { + // Ignore storage errors (private browsing quota exceeded, etc.) + } +} + +function clearLocalProfile() { + try { + localStorage.removeItem(LOCAL_STORAGE_KEY); + } catch { + // Ignore + } +} + +// ----- API helpers ----- + +async function fetchServerProfile() { + try { + const res = await fetch("/api/reading/profile"); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function postSession(intervals) { + if (!intervals || intervals.length === 0) return null; + try { + const res = await fetch("/api/reading/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(intervals) + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +// ----- Composable ----- + +export function useReadingTracker() { + // Reactive WPM shown in the UI. + const wpm = ref(DEFAULT_WPM); + const activeSeconds = ref(0); + + // Reading state machine. + let state = STATE_READING; + let lastActivityAt = Date.now(); + let tickIntervalId = null; + let lastMousemoveAt = 0; + + // Scroll tracking. + let maxScrollFraction = 0; + + // Page context (from window globals set by _Layout.cshtml). + const pageKey = window.CURRENT_PAGE_KEY ?? null; + const pageWordCount = window.PAGE_WORD_COUNT ?? 0; + const isAuthenticated = Boolean(window.IS_AUTHENTICATED); + + // In-session server profile for reference WPM (loaded on mount). + let serverProfile = null; + + // ----- Scroll fraction ----- + + function getScrollFraction() { + const main = document.querySelector("main") ?? document.documentElement; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollable = main.scrollHeight - main.clientHeight; + if (scrollable <= 0) return 1; + return Math.min(1, Math.max(0, scrollTop / scrollable)); + } + + // ----- State machine ----- + + function markActivity() { + lastActivityAt = Date.now(); + if (state === STATE_IDLE || state === STATE_HIDDEN) { + state = STATE_READING; + } + } + + function onVisibilityChange() { + if (document.visibilityState !== "visible") { + state = STATE_HIDDEN; + } else { + // Tab became visible — READING if recently active, else IDLE. + const idleDuration = (Date.now() - lastActivityAt) / 1000; + state = idleDuration <= IDLE_THRESHOLD_S ? STATE_READING : STATE_IDLE; + } + } + + function onMousemove() { + const now = Date.now(); + if (now - lastMousemoveAt >= MOUSEMOVE_THROTTLE_MS) { + lastMousemoveAt = now; + markActivity(); + } + } + + function onScroll() { + markActivity(); + const fraction = getScrollFraction(); + if (fraction > maxScrollFraction) { + maxScrollFraction = fraction; + } + } + + // ----- Tick ----- + + function tick() { + const now = Date.now(); + const idleDuration = (now - lastActivityAt) / 1000; + + if (state === STATE_READING && idleDuration > IDLE_THRESHOLD_S) { + state = STATE_IDLE; + } + + if (state === STATE_READING) { + activeSeconds.value++; + } + } + + // ----- Flush (send data on navigation away / unmount) ----- + + async function flush() { + if (!pageKey || activeSeconds.value <= 0 || pageWordCount <= 0) return; + + const wordsRead = Math.round(pageWordCount * maxScrollFraction); + const scrollFraction = maxScrollFraction; + // Near-bottom (within 5%) = completed. + const completed = scrollFraction >= 0.95; + + const interval = { + pageKey, + activeSeconds: activeSeconds.value, + wordsRead, + completed + }; + + if (isAuthenticated) { + const updated = await postSession([interval]); + if (updated) { + serverProfile = updated; + if (updated.wpm) { + wpm.value = updated.wpm; + } + } + } else { + // Update localStorage profile. + const local = loadLocalProfile(); + local.totalWords = (local.totalWords || 0) + wordsRead; + local.totalActiveSeconds = (local.totalActiveSeconds || 0) + activeSeconds.value; + saveLocalProfile(local); + + const localWpm = local.totalActiveSeconds > 0 + ? local.totalWords / (local.totalActiveSeconds / 60) + : 0; + if (localWpm > 0) { + wpm.value = localWpm; + } + } + } + + // ----- One-time sync: localStorage → server on first authenticated load ----- + + async function syncLocalToServer() { + if (!isAuthenticated) return; + + const local = loadLocalProfile(); + if (!local || (local.totalWords === 0 && local.totalActiveSeconds === 0)) return; + + // Only upload if server has no data yet. + const profile = await fetchServerProfile(); + if (profile && (profile.totalWordsRead > 0 || profile.totalActiveSeconds > 0)) { + // Server already has data — just clear local. + clearLocalProfile(); + return; + } + + // Upload local aggregate as a synthetic interval. + if (local.totalWords > 0 && local.totalActiveSeconds > 0) { + await postSession([{ + pageKey: "__localStorage_sync__", + activeSeconds: local.totalActiveSeconds, + wordsRead: local.totalWords, + completed: false + }]); + } + clearLocalProfile(); + } + + // ----- Init ----- + + onMounted(async () => { + // Load initial WPM. + if (isAuthenticated) { + // Try to sync localStorage first (one-time, idempotent). + await syncLocalToServer(); + + serverProfile = await fetchServerProfile(); + if (serverProfile?.wpm) { + wpm.value = serverProfile.wpm; + } + } else { + const local = loadLocalProfile(); + const localWpm = local.totalActiveSeconds > 0 + ? local.totalWords / (local.totalActiveSeconds / 60) + : 0; + if (localWpm > 0) { + wpm.value = localWpm; + } + } + + // Start tick. + tickIntervalId = setInterval(tick, TICK_INTERVAL_MS); + + // Activity listeners. + document.addEventListener("visibilitychange", onVisibilityChange); + document.addEventListener("mousemove", onMousemove, { passive: true }); + document.addEventListener("keydown", markActivity); + document.addEventListener("scroll", onScroll, { passive: true }); + document.addEventListener("touchstart", markActivity, { passive: true }); + document.addEventListener("pointerdown", markActivity); + window.addEventListener("focus", markActivity); + + // Flush on page navigation (SPA or classical). + window.addEventListener("beforeunload", flush); + }); + + onBeforeUnmount(async () => { + clearInterval(tickIntervalId); + + document.removeEventListener("visibilitychange", onVisibilityChange); + document.removeEventListener("mousemove", onMousemove); + document.removeEventListener("keydown", markActivity); + document.removeEventListener("scroll", onScroll); + document.removeEventListener("touchstart", markActivity); + document.removeEventListener("pointerdown", markActivity); + window.removeEventListener("focus", markActivity); + window.removeEventListener("beforeunload", flush); + + await flush(); + }); + + return { + wpm: readonly(wpm), + activeSeconds: readonly(activeSeconds) + }; +}