Skip to content

feat: add Kindle-style reading time tracker - #1267

Open
BenjaminMichaelis wants to merge 37 commits into
mainfrom
bmichaelis-reading-time-tracker-8ef
Open

BenjaminMichaelis wants to merge 37 commits into
mainfrom
bmichaelis-reading-time-tracker-8ef

Conversation

@BenjaminMichaelis

@BenjaminMichaelis BenjaminMichaelis commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a Kindle-style "time left in chapter / book" indicator to the header. It adapts to each signed-in user's measured reading speed; anonymous readers see it using a default speed.

How it works

Server

  • WordCountService: counts prose words per page, chapter and book once at startup (code/pre/script/style excluded) and serves O(1) lookups.
  • UserReadingProfile (EF entity, migration AddUserReadingProfile): one aggregate row per user (TotalWordsRead, TotalActiveSeconds), so WPM is derived and never stored as a running average. Upserts use provider-agnostic EF (ExecuteUpdateAsync, insert on miss, one bounded retry on a duplicate-key race).
  • ReadingSpeedPolicy: intervals above the hard cutoff (default 900 WPM) are ignored; intervals slower than SlowOutlierFactor x the user's reference WPM have their time clamped.
  • ReadingOptions is the single source of the tuning values (HardCutoffWpm, SlowOutlierFactor, DefaultWpm, MaxCreditWpm, IdleThresholdSeconds, PeriodicFlushActiveSeconds, MaxActiveSecondsPerInterval, MaxIntervalsPerRequest). It is validated at startup, including cross-field rules that keep the client's batching within the server's limits.
  • ReadingController: POST /api/reading/session, GET /api/reading/profile, GET /api/reading/book-stats (public), POST /api/reading/reset. Rate limited, antiforgery-validated.
  • READING_CONTEXT (emitted by the ReadingContext view component) carries the page/chapter/book word counts, the user's wpm (or null) and the client tuning values.
  • The reading profile is included in the personal data download and removed with the account (cascade).

Client

  • useReadingTracker / useActivityState: READING/IDLE/HIDDEN activity state, flush on an active-time period, on hide and on pagehide (keepalive). Failed batches are kept in a small outbox and retried with backoff. Auth/antiforgery failures stop tracking; bad-data responses are dropped.
  • 400 handling: a 400 problem document with an errors member is bad data (drop); any other 400 is treated as an antiforgery failure (stop). This is asserted by a server integration test.
  • Credit model: words are credited from scroll progress, capped by time and the max credit WPM, and the un-credited remainder carries forward.
  • ReadingTimeRemaining.vue shows chapter/book time left; reset button on Manage Profile, with cross-tab notification via BroadcastChannel.
  • No client copies of the tuning numbers: the tracker is disabled if READING_CONTEXT is missing them.

Known, accepted limitations

  • A retry after a lost response can double-count an interval (documented; data only drives the user's own estimate).
  • Fraction-of-page progress is measured by content element height, not prose words.

Follow-ups (out of scope, tracked)

#1604, #1605, #1606, #1607, #1608, #1609, #1610, #1611

Testing

  • C#: reading controller, speed policy, context rendering, word count, options validator and antiforgery contract tests (requirement-level, explicit options, mutation-checked).
  • JS: Vitest suites (npm test, run in the frontend-build CI job) covering the pure units and the tracker flush/retry logic with fake timers and a stubbed fetch.

Copilot AI commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

One or more custom setup steps configured for this repository failed during this Copilot code review run:

Set up Node.js for frontend development

Setup steps run before each review. If the review above is missing context, or no review was posted at all, the failing step above may be the cause. See the workflow run for failure details, fix your setup steps configuration, and re-request a review.

Note

You can configure setup steps for Copilot code review separately from Copilot cloud agent with a copilot-code-review.yml file. Read the docs for details.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Antiforgery failures, duplicated page counting, incorrect scroll calculations, and concurrency risks prevent reliable operation.

9 open findings
What changed in this PR

Adds personalized chapter/book reading-time estimates backed by prose word counts and persisted reading activity.

Changes:

  • Adds server-side word counting, reading profiles, APIs, and EF migrations.
  • Adds client-side activity tracking and header estimates.
  • Adds profile reset controls and automated tests.
File Description
Views/​Shared/​_Layout.cshtml Exposes reading metadata to JavaScript.
src/​composables/​useReadingTracker.js Tracks activity and synchronizes reading profiles.
src/​components/​ReadingTimeRemaining.vue Displays remaining reading time.
src/​components/​HeaderStatus.vue Integrates the reading-time component.
Services/​WordCountService.cs Computes and caches prose word counts.
Services/​IWordCountService.cs Defines word-count APIs.
Program.cs Registers the word-count service.
Models/​UserReadingProfile.cs Stores aggregate reading metrics.
Models/​ReadingActivity.cs Stores reading-session details.
Migrations/​EssentialCSharpWebContextModelSnapshot.cs Updates the EF model snapshot.
Migrations/​20260809064123_AddReadingTimeTracking.Designer.cs Defines the migration model.
Migrations/​20260809064123_AddReadingTimeTracking.cs Creates reading-tracking tables.
Controllers/​ReadingController.cs Adds reading profile, session, statistics, and reset APIs.
Controllers/​HomeController.cs Supplies page and book word-count metadata.
Areas/​Identity/​Pages/​Account/​Manage/​Index.cshtml Adds reading-speed reset controls.
Areas/​Identity/​Data/​EssentialCSharpWebContext.cs Configures reading entities and relationships.
EssentialCSharp.Web.Tests/​WordCountServiceTests.cs Tests word counting and aggregates.
EssentialCSharp.Web.Tests/​ReadingControllerTests.cs Tests reading API access and response shape.
Files not reviewed (1)
  • EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Concurrent uploads can lose aggregate updates, while tracker initialization can record inaccurate or missing sessions.

2 open findings
9 resolved since last review
Files not reviewed (1)
  • EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs: Generated file
Previously missed (3)

In code that hasn't changed since last review

Medium severity Eagerly initialize word count service at startup

EssentialCSharp.Web/​Program.cs:275

Singleton registration is lazy, so this does not perform the advertised scan at application startup. The first request that activates HomeController or ReadingController instead synchronously parses every mapped HTML file, adding a potentially large cold-request delay. Eagerly resolve IWordCountService during startup after the app is built.

Medium severity Initialize reading state from current page visibility

EssentialCSharp.Web/​src/​composables/​useReadingTracker.js:98

The tracker always starts in READING, even when the page was opened in a background tab. No initial visibilitychange event is guaranteed, so a background page can accrue up to five minutes of false reading time before becoming idle. Initialize the state from the document's current visibility.

Medium severity Initialize scroll fraction for non-scrollable pages

EssentialCSharp.Web/​src/​composables/​useReadingTracker.js:104

maxScrollFraction is updated only by a scroll event. A page that fits in the viewport cannot scroll, so it remains zero and flush submits zero words; the server then discards the entire reading session. Initialize the fraction from the current document so fully visible short pages are credited.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Core word counting and tracking contain accuracy defects, and an unused variable can fail the warnings-as-errors build.

2 open findings
2 resolved since last review
Files not reviewed (1)
  • EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs: Generated file
Previously missed (2)

In code that hasn't changed since last review

Medium severity Validate PageKey length before database persistence

EssentialCSharp.Web/​Controllers/​ReadingController.cs:81

PageKey is persisted into an nvarchar(256) column, but the request DTO has no matching length validation. An authenticated request with a longer key reaches SaveChangesAsync and fails with a database truncation exception/500 instead of the API's normal 400 validation response. Add the model constraint to reject it before persistence.

Medium severity Apply outlier filtering to anonymous reading sessions

EssentialCSharp.Web/​src/​composables/​useReadingTracker.js:195

Anonymous sessions bypass both advertised outlier rules: every interval is added directly, so a one-second skim can set local WPM to thousands and a very slow interval is never clamped. The same history then drives estimates on subsequent anonymous pages. Apply the same >900 discard and 0.25× reference-WPM clamp before mutating the local aggregate, ideally through shared algorithm logic to prevent client/server drift.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generated div-based code listings are currently included in prose totals, producing inaccurate estimates.

1 open finding
2 resolved since last review
Files not reviewed (1)
  • EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs: Generated file

🧠 Review effort: Balanced

BenjaminMichaelis and others added 7 commits November 5, 2026 20:47
- WordCountService: singleton that walks all SiteMappings at startup,
  strips <pre>/<code>/<script>/<style> via HtmlAgilityPack, and caches
  per-page, per-chapter, and book-wide prose word counts + prefix sums

- ReadingActivity + UserReadingProfile EF entities with migration
  AddReadingTimeTracking; retention capped at 500 rows/user

- ReadingController (POST /session, GET /profile, GET /book-stats,
  POST /reset); WPM algorithm: >900 WPM hard-discard, slow outlier
  clamped at 0.25x referenceWpm, aggregate stored as
  {totalWords, totalActiveSeconds}

- useReadingTracker.js composable: activity state machine (READING /
  IDLE / HIDDEN), 5-min idle threshold, visibilitychange instant-pause,
  localStorage for anonymous users, flush-on-navigate, sync local→server
  on first authenticated page load

- ReadingTimeRemaining.vue: displays 'X min left in chapter / Y min left
  in book' in header; live scroll-fraction update; only renders on
  chapter pages; integrated into HeaderStatus.vue

- Reset button in Identity Manage Profile page

- Wired in HomeController (word-count ViewBag) and _Layout.cshtml
  (window.* globals: CURRENT_PAGE_KEY, PAGE_WORD_COUNT, CHAPTER_WORD_COUNT,
  WORDS_BEFORE_PAGE, CHAPTER_START_WORDS, BOOK_WORD_COUNT)

- Tests: WordCountServiceTests (prose-only counting, prefix sums,
  chapter/book totals) and ReadingControllerTests (auth guards, WPM
  algorithm constants, book-stats endpoint)
… deserialization)

SiteMapping.PrimaryKey is not serialized in sitemap.json; after deserialization
it is null, causing ArgumentNullException in WordCountService constructor and
HomeController when calling Dictionary.ContainsKey(null).

Use mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey consistently, matching
the pattern already used in BookToolQueryService.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove unused chapter offset, credit only scroll progress made during the session, validate PageKey length, and apply outlier rules to anonymous sessions.
@BenjaminMichaelis
BenjaminMichaelis force-pushed the bmichaelis-reading-time-tracker-8ef branch from 7570a23 to 00c2448 Compare November 6, 2026 04:49
…r with page-lifecycle flushing

Split the tracker into activity-state, readingApi and orchestrator units,
measure progress against the content element, cap credited words by active
time, remove localStorage and the duplicated outlier rules, and improve the
header widget accessibility.
…dingActivity

Replace the activity audit table with a single UserReadingProfile migration,
move business rules out of the controller into IReadingProfileService and
ReadingSpeedPolicy bound to ReadingOptions, use an atomic upsert, validate
requests, and add per-user rate limiting.
…age stats

Single immutable PageWordStats snapshot (FrozenDictionary), built in
IHostedLifecycleService.StartingAsync; logs and isolates bad content files,
decodes HTML entities, counts inline code but not code blocks. Tests rewritten
around requirements.
Removes the reading ViewBag entries and window globals; csrf-token meta is
only rendered for signed-in users.
BenjaminMichaelis and others added 21 commits November 5, 2026 22:07
…t, personal data export, regenerated migration

- ReadingOptions: DefaultWpm/MaxCreditWpm/IdleThresholdSeconds/PeriodicFlushActiveSeconds/MaxActiveSecondsPerInterval with validation (ValidateOnStart + cross-field validator)
- drop unused Completed from the interval DTO; per-interval active-seconds cap from options
- replace MERGE/ON CONFLICT with ExecuteUpdate + insert-on-miss + duplicate-key retry
- READING_CONTEXT carries wpm and client constants
- include UserReadingProfile in personal data download
- restore Program.cs CRLF and empty OnModelCreating; regenerate migration
… rate limit, context parsing, personal data

Co-Authored-By: Claude <noreply@anthropic.com>
…details

Data-driven speed policy and options tests, HtmlAgilityPack-based context parsing, endpoint-metadata request size check, structured log assertions, CI-failing SQL Server gating, migration lookup by name, FK-violation and UpdatedAtUtc tests.
Neither endpoint is called by the client (it reads wpm from the session
response and READING_CONTEXT). Removes the dead response types,
IWordCountService.GetChapterWordCounts, the ReadingPublic rate-limit
policy and the tests that only covered the removed endpoints.
…d error keys

Move the interval-count and active-seconds limits into IValidatableObject
on the request DTOs (reading ReadingOptions from the validation service
provider) so MVC reports keys such as Intervals[1].ActiveSeconds. The
unknown-page-key error now carries the indexed key too. The controller no
longer needs ReadingOptions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants