Skip to content

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

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

feat: add Kindle-style reading time tracker#1267
BenjaminMichaelis wants to merge 2 commits into
mainfrom
bmichaelis-reading-time-tracker-8ef

Conversation

@BenjaminMichaelis

Copy link
Copy Markdown
Member

Summary

Adds a Kindle-style "time left in chapter / book" indicator to the EssentialCSharp.Web header, adapting to each user's actual reading speed over time.

Features

Server-side

  • WordCountService — singleton that walks all SiteMapping entries at startup, strips <pre>/<code>/<script>/<style> via HtmlAgilityPack, and caches per-page/per-chapter/book-wide prose word counts + prefix sums (O(1) lookups at request time)
  • ReadingActivity + UserReadingProfile — EF Core entities (migration AddReadingTimeTracking); profile stores {totalWords, totalActiveSeconds} for O(1) WPM derivation; activity rows capped at 500/user for bounded storage
  • ReadingController — four endpoints:
    • POST /api/reading/session — batch interval ingestion, WPM algorithm (>900 WPM hard-discard; slow outliers clamped at 0.25 × referenceWpm), aggregate update + retention trim
    • GET /api/reading/profile — current WPM for logged-in user
    • GET /api/reading/book-stats — public word counts per chapter + total
    • POST /api/reading/reset — clears aggregate + detail rows

Client-side

  • useReadingTracker.js composable — activity state machine (READING / IDLE / HIDDEN), 5-min idle threshold, instant-pause on visibilitychange, flush-on-navigate, localStorage→server sync on first authenticated page load
  • ReadingTimeRemaining.vue — displays "X min left in chapter · Y min left in book" in the header; recomputes every few seconds from live scroll fraction; only renders on chapter pages; integrated into HeaderStatus.vue
  • window.* globalsCURRENT_PAGE_KEY, PAGE_WORD_COUNT, CHAPTER_WORD_COUNT, WORDS_BEFORE_PAGE, CHAPTER_START_WORDS, BOOK_WORD_COUNT emitted from _Layout.cshtml

UX

  • Reset reading speed button added to Identity → Manage Profile page

Algorithm

Mirrors Kindle's ReadingTimer:

  • Upper outlier: intervals >900 WPM are discarded (page-flip/skim)
  • Lower outlier: intervals slower than 0.25 × referenceWpm are time-clamped (walked away mid-page)
  • Aggregate: totalWords / (totalActiveSeconds / 60) — never stores a running average that needs re-weighting
  • Cold start: 200 WPM display default (not persisted) until first accepted interval

Testing

  • WordCountServiceTests: prose-only counting, <pre>/<code> exclusion, prefix sums, chapter/book totals
  • ReadingControllerTests: auth guards (401 on all mutating endpoints for anonymous users), WPM algorithm constant verification, GET /api/reading/book-stats shape test

BenjaminMichaelis and others added 2 commits August 9, 2026 00:20
- 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>
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.

1 participant