You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: add Kindle-style reading time tracker - #1267
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.
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.
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.
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.
Initialize reading state from current page visibility
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.
Initialize scroll fraction for non-scrollable pages
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.
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.
Apply outlier filtering to anonymous reading sessions
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.
- 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.
…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.
…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
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, migrationAddUserReadingProfile): 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 thanSlowOutlierFactorx the user's reference WPM have their time clamped.ReadingOptionsis 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 theReadingContextview component) carries the page/chapter/book word counts, the user'swpm(or null) and the client tuning values.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.errorsmember is bad data (drop); any other 400 is treated as an antiforgery failure (stop). This is asserted by a server integration test.ReadingTimeRemaining.vueshows chapter/book time left; reset button on Manage Profile, with cross-tab notification viaBroadcastChannel.READING_CONTEXTis missing them.Known, accepted limitations
Follow-ups (out of scope, tracked)
#1604, #1605, #1606, #1607, #1608, #1609, #1610, #1611
Testing
npm test, run in thefrontend-buildCI job) covering the pure units and the tracker flush/retry logic with fake timers and a stubbedfetch.