Skip to content

feat(reader): chapter navigation, appearance system, and reading stats#440

Open
RXWatcher wants to merge 67 commits into
Silo-Server:mainfrom
RXWatcher:feat/reader-stats
Open

feat(reader): chapter navigation, appearance system, and reading stats#440
RXWatcher wants to merge 67 commits into
Silo-Server:mainfrom
RXWatcher:feat/reader-stats

Conversation

@RXWatcher

@RXWatcher RXWatcher commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Three stacked reader improvements, modeled on the strongest ideas from comparable ebook apps (BookOrbit et al.) and adapted to silo's architecture. Each was spec'd, planned, TDD-implemented, and adversarially reviewed; all three are running on a production install.

1. Page navigation

  • Chapter-aware footer: progress slider with the current chapter's extent as a band, chapter title, percentage — replacing the bare header slider.
  • Tap zones (left/right page turn, middle toggles chrome) with events bridged across foliate's content iframe (pointer events don't bubble out of it; the bridge follows the existing selection-listener pattern), auto-hiding chrome, selection-safe.
  • Full keyboard map (←/→, Home/End chapter jumps, t/s/b/f, ? shortcuts overlay, Esc overlay→panel→chrome precedence) with an editable-target guard.

2. Appearance

  • Ten paired-palette themes (Default, Sepia, Gray, Dawnlight, Ember, Aurora, Ocean, Meadow, Rosewood, AMOLED) with a reader dark-mode toggle independent of the app theme; legacy theme values migrate and keep being written for one release.
  • Per-profile custom font uploads: reader_fonts table + four additive /api/v1/ebooks/reader-fonts endpoints (magic-byte validation, 5 MB / 10-per-profile caps, SILO_READER_FONTS_DIR), fonts delivered into the reader via authenticated blob fetch → object URL (CSS @font-face can't carry auth headers; no API URLs ever enter content CSS). Profile path components are strictly validated before any filesystem use.
  • Columns 1–4 + gap control (subsumes spread), justification toggle, settings panel regrouped Theme/Typography/Layout.

3. Reading stats (foundation)

  • Heartbeat sessions: 30s activity-gated beats (visible + interaction within 60s), server coalescing (120s gap, 90s per-beat credit cap), server clock only, comics inert.
  • Pace + time-remaining endpoints; the footer's reserved slot now shows "· 2h 10m left" once ~10 minutes of pace data exists (14-day window, per-book with profile-wide fallback).
  • /reading-stats page: 12-month heatmap (client-densified daily rollups), today/week/month/all-time totals, top books, session timeline. All rollups computed by query; UTC day boundaries.

Migrations: 20260720190000_reader_fonts.sql, 20260720210000_reading_sessions.sql.
Specs and plans included under docs/superpowers/{specs,plans}/2026-07-20-*.

Review-hardened items worth reviewer attention

  • Path traversal via X-Profile-Id reaching filepath.Join — caught in review, fixed with strict component validation + regression tests on all four font handlers.
  • @font-face auth-unreachability — caught in whole-branch review, fixed via blob URLs.
  • Sparse-day heatmap misalignment, absent-fraction heartbeat acceptance, removed-book link suppression, ruler-drag page turns — all caught and fixed with pinned tests.

API surface

Additive only: new endpoints under /api/v1/ebooks (reader-fonts CRUD/file, reading-heartbeat, reading-stats × 2); task/result contracts untouched.

Tests

  • Web: full vitest suite green (only the 7 pre-existing upstream failures — QueryClientProvider/ResizeObserver in unrelated suites — remain); pnpm build, lint, format clean.
  • Go: go test ./internal/api/handlers, go vet, gofmt, make migrate-validate, make verify-local-paths clean.
  • Live-verified on a production install (navigation/appearance/stats all exercised).

AI-use disclosure

Implemented and reviewed with Claude Code (spec → plan → TDD subagents with per-task and whole-branch adversarial review).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added enhanced reader appearance: palette-based light/dark themes, multi-column layout controls (including column gap), text justification, and per-profile custom font upload/management.
    • Introduced chapter-aware reader footer with progress scrubber, chapter band, keyboard shortcuts, and a shortcuts overlay.
    • Added reading stats dashboard, including reading activity heatmap, pace/time-left estimates, and reading motivation with editable goals.
  • Documentation
    • Published updated reader appearance, navigation, reading stats, and motivation design/specification plans.
  • Tests
    • Added/expanded unit and integration test coverage for the above reader, navigation, stats, heatmap, and fonts behaviors.
  • Chores
    • Made reader-fonts storage location configurable at startup via environment setting.

RXWatcher and others added 30 commits July 20, 2026 14:54
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chapter label should only render when extent is non-null,
ensuring that extent:null hides both the chapter band and label by
construction. Updated test to verify the label is absent when
extent is null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Header shrank from two rows to one when the progress slider moved into
ReaderFooter, but the page wrapper (min-h-screen) and <main>
(h-[calc(100vh-6rem)]) still assumed the old 6rem of chrome. Comics
(header + main, no footer) showed a blank strip; prose (header + main
+ footer) overflowed the viewport. Switch to a flex column (h-screen
flex-col, main flex-1 min-h-0) so header/footer keep natural heights
and main absorbs the remainder in both layouts, without a hardcoded
constant that has to be re-tuned whenever chrome changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chapterExtent indexed sectionFractions[i], sectionFractions[i+1], and
sectionFractions[1] without checking for undefined, which
noUncheckedIndexedAccess flags as number | undefined and fails
tsc --noEmit -p tsconfig.app.json. Hoist the boundary read into a
guarded local and continue when it's undefined; end already falls
back with ??. No behavior change, existing tests unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 5 attached tap-zone handling as onPointerUp on the outer
<section data-reader-surface> and chrome-reappear as a window mousemove
listener, but foliate renders prose in a same-origin iframe that fills
the surface and its events never bubble out. Extend the same
content-doc listener bridge FoliateBookReader already uses for
selection (attachSelectionListeners) to also forward pointerup/
mousemove from each content doc, translated into outer viewport
coordinates via the frame element's getBoundingClientRect(). EbookReader
now shares its tap-zone and edge-reveal logic between the margin path
and this new content-iframe path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reading-ruler grip inside [data-reader-surface] never called
event.stopPropagation() on pointerup/pointercancel, so releasing a
drag bubbled to the section's tap handler. The grip sits at right-2,
so xRatio≈1 resolved to a next-page tap on every ruler release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReaderFooter prefers locationInfo.fraction over readerProgress, but
handleProgressScrub only updated readerProgress. If the last relocate
reported a different fraction, the bar could revert to it until the
next relocate event landed. Scrubbing now also patches locationInfo
optimistically, preserving its sectionIndex/tocLabel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Escape handler only chained shortcuts-overlay then chrome, so
pressing Escape while the side panel (Contents/Search/Notes/Settings)
was open skipped straight past it and toggled chrome instead. Escape
now closes the topmost open layer: overlay, then panel, then chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReaderShortcutsOverlay autofocused its Close button but never trapped
Tab/Shift+Tab, so focus could escape the modal into the page behind
it. Adds a generic focus trap (mirrors the pattern already used by
SubtitleSearchModal) over whatever is focusable inside the dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listed shortcuts described "b" as "Toggle bookmark", but the
header bookmark button (and the bound handler) only ever creates one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
typeof detail.fraction === "number" is true for NaN, letting a
non-finite fraction reach onLocationChange (and the Math.min/max
clamp, which then propagates NaN). Layers Number.isFinite on top of
the typeof narrowing needed for the Math calls.

Also declares getSectionFractions on FoliateViewElement directly and
drops the inline cast at the imperative handle that stood in for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
profile_id comes straight from the client-supplied X-Profile-Id
header (RequireProfile only checks non-empty) and reached
filepath.Join/filepath.Glob unvalidated in the reader-fonts
upload/serve/delete handlers, allowing path traversal via a header
like `X-Profile-Id: ../../../../tmp/evil`. Add
safeReaderFontPathComponent and call it before any store or
filesystem operation in every handler. Also: fix the /ebooks route
group so reader-fonts routes no longer disappear when the ebook
reader handler is nil, best-effort remove a partially written blob
on upload failure, and tighten filename sanitization to reject the
same unsafe characters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Relaxed sanitizeReaderFontFilename to preserve spaces, parentheses, and
  non-ASCII characters in user-uploaded font display names
- Now only strips NUL and control characters (0x00-0x1F, 0x7F), trims
  whitespace, and caps at 255 runes
- Font blobs remain content-addressed as <id>.<format>, so the strict
  charset restriction is unnecessary for security
- TDD: Added test cases for "Literata Bold (2).ttf", "Times New Roman.ttf",
  and Cyrillic filenames to verify names/filenames are preserved
- Also added HandleList coverage to path-traversal regression test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RXWatcher and others added 5 commits July 20, 2026 19:52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix TS2532 build error in ReadingHeatmap.heatmapBuckets: destructure
days[0] before use so noUncheckedIndexedAccess doesn't flag it, since
a length check alone doesn't narrow indexed array access.
DailyRollup only returns days with sessions, but heatmapBuckets padded
leading blanks from the first day's weekday and then laid days into
consecutive grid cells, silently compressing any gap day and shifting
every later day's weekday row.

Add an exported densifyDays(days, from, to) helper that fills every
missing date in the range with a zero-second entry before bucketing.
ReadingStats now computes the same default range the server uses
(today UTC, minus 365 days) and passes it explicitly to both the
history query and the heatmap, so client and server always agree on
the range being densified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isComicFormat derives from the loaded item's file versions, so while
useCatalogItemDetail is still pending it defaults to false — the same
value a prose book would have. The heartbeat hook's contentId and
useBookReadingStats were both gated on isComicFormat alone, so a
keypress during that loading window could fire a stats GET and create
a spurious reading session for a book that turns out to be a comic.

Add readerKindKnown = Boolean(item) and require it alongside
!isComicFormat before either tracker sees a contentId.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@RXWatcher, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 521d5a2c-0a8b-416f-b175-4fef015ad289

📥 Commits

Reviewing files that changed from the base of the PR and between 9188dc0 and ac87d5a.

📒 Files selected for processing (2)
  • docs/superpowers/plans/2026-07-20-reading-motivation.md
  • internal/api/handlers/reading_motivation_test.go
📝 Walkthrough

Walkthrough

Adds reader appearance customization, chapter-aware navigation, reading-session analytics, motivation aggregates, and a reading-stats dashboard. The change includes custom font APIs and storage, theme and layout migration, reader footer and shortcuts, timezone-aware statistics, goals, achievements, and related tests.

Changes

Reader appearance and custom fonts

Layer / File(s) Summary
Appearance contracts and settings
docs/superpowers/specs/*appearance*, web/src/reader/readerThemes.ts, web/src/reader/FoliateBookReader.tsx
Adds palette-pair themes, legacy migration, columns, justification, and custom font settings.
Font persistence and API
internal/api/handlers/reader_fonts.go, internal/api/router.go, migrations/sql/*reader_fonts.sql, cmd/silo/main.go
Adds profile-scoped font upload, validation, filesystem storage, deletion, serving, routing, migration, and configurable storage location.
Reader integration
web/src/pages/EbookReader.tsx, web/src/reader/readerFontsApi.ts
Adds palette controls, uploaded-font management, authenticated blob URLs, column controls, justification, and custom-font CSS injection.

Reader page navigation

Layer / File(s) Summary
Location and interaction bridge
web/src/reader/readerNavigation.ts, web/src/reader/FoliateBookReader.tsx, web/src/pages/EbookReader.tsx
Adds chapter extent mapping, location metadata, iframe pointer forwarding, tap-zone actions, chrome visibility, and chapter-bound keyboard navigation.
Footer and shortcuts UI
web/src/reader/ReaderFooter.tsx, web/src/reader/ReaderShortcutsOverlay.tsx
Replaces the header progress control with a conditional footer and adds a focus-trapped shortcuts overlay.

Reading statistics and motivation

Layer / File(s) Summary
Backend ingestion and aggregation
internal/api/handlers/reading_sessions.go, internal/api/handlers/reading_motivation.go, internal/api/router.go, migrations/sql/*reading*
Adds heartbeat coalescing, pace/history APIs, timezone-aware motivation calculations, goals, achievements, DNA aggregates, and authenticated routes.
Client hooks and reader estimates
web/src/hooks/useReadingHeartbeat.ts, web/src/hooks/queries/readingStats.ts, web/src/pages/EbookReader.tsx, web/src/reader/ebookReaderApi.ts
Sends activity-gated heartbeats, fetches statistics and motivation data, saves goals, and renders formatted time-left data.
Reading-stats dashboard
web/src/pages/ReadingStats.tsx, web/src/components/stats/*, web/src/App.tsx, web/src/components/AppSidebar.tsx
Adds totals, heatmap activity, top books, recent sessions, motivation sections, routing, and sidebar navigation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested labels: v1

Sequence Diagram(s)

sequenceDiagram
  participant EbookReader
  participant HeartbeatHook
  participant ReadingSessionsHandler
  participant ReadingMotivationHandler
  participant ReadingStatsPage
  EbookReader->>HeartbeatHook: record activity and fraction
  HeartbeatHook->>ReadingSessionsHandler: send heartbeat
  ReadingSessionsHandler-->>ReadingStatsPage: provide history aggregates
  ReadingMotivationHandler-->>ReadingStatsPage: provide motivation aggregates
  ReadingStatsPage->>ReadingMotivationHandler: save reading goals
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the three main reader-focused additions in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the v1 Silo v1 scope - auto-adds to the Silo v1 project label Jul 20, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handlers/reading_sessions.go`:
- Around line 625-631: Update the reading-stats aggregation query in the handler
containing the rows Query call so date_trunc operates on started_at converted
with AT TIME ZONE 'UTC'. Keep the existing grouping, ordering, filters, and
duration sum unchanged while ensuring daily buckets are UTC-based.

In `@web/src/pages/EbookReader.tsx`:
- Around line 557-573: Update dispatchSurfaceTap to derive hasSelection from the
current live iframe selection at tap time rather than the potentially stale
React selection state. Reuse the existing selection source or helper used by the
iframe selection update, and preserve the tapZoneAction behavior while ensuring
pointer release after a new selection is treated as selected.

In `@web/src/reader/readerNavigation.ts`:
- Line 43: Update the shortcut entry for key "b" in the reader navigation
shortcuts so its description indicates toggling the bookmark rather than only
adding one, while leaving the key binding unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3074dbc-e1cc-46da-859e-26a1edc24e71

📥 Commits

Reviewing files that changed from the base of the PR and between c90e736 and 7adf82a.

📒 Files selected for processing (42)
  • cmd/silo/main.go
  • docs/superpowers/plans/2026-07-20-reader-appearance.md
  • docs/superpowers/plans/2026-07-20-reader-page-navigation.md
  • docs/superpowers/plans/2026-07-20-reading-stats-foundation.md
  • docs/superpowers/specs/2026-07-20-reader-appearance-design.md
  • docs/superpowers/specs/2026-07-20-reader-page-navigation-design.md
  • docs/superpowers/specs/2026-07-20-reading-stats-foundation-design.md
  • internal/api/handlers/reader_fonts.go
  • internal/api/handlers/reader_fonts_test.go
  • internal/api/handlers/reading_sessions.go
  • internal/api/handlers/reading_sessions_test.go
  • internal/api/router.go
  • migrations/sql/20260720190000_reader_fonts.sql
  • migrations/sql/20260720210000_reading_sessions.sql
  • web/src/App.tsx
  • web/src/components/AppSidebar.tsx
  • web/src/components/stats/ReadingHeatmap.test.tsx
  • web/src/components/stats/ReadingHeatmap.tsx
  • web/src/hooks/queries/keys.ts
  • web/src/hooks/queries/readingStats.ts
  • web/src/hooks/useReadingHeartbeat.test.ts
  • web/src/hooks/useReadingHeartbeat.ts
  • web/src/lib/formatDuration.test.ts
  • web/src/lib/formatDuration.ts
  • web/src/pages/EbookReader.test.tsx
  • web/src/pages/EbookReader.tsx
  • web/src/pages/ReadingStats.test.tsx
  • web/src/pages/ReadingStats.tsx
  • web/src/reader/FoliateBookReader.component.test.tsx
  • web/src/reader/FoliateBookReader.test.ts
  • web/src/reader/FoliateBookReader.tsx
  • web/src/reader/ReaderFooter.test.tsx
  • web/src/reader/ReaderFooter.tsx
  • web/src/reader/ReaderShortcutsOverlay.test.tsx
  • web/src/reader/ReaderShortcutsOverlay.tsx
  • web/src/reader/ebookReaderApi.ts
  • web/src/reader/readerFontsApi.test.ts
  • web/src/reader/readerFontsApi.ts
  • web/src/reader/readerNavigation.test.ts
  • web/src/reader/readerNavigation.ts
  • web/src/reader/readerThemes.test.ts
  • web/src/reader/readerThemes.ts

Comment thread internal/api/handlers/reading_sessions.go Outdated
Comment thread web/src/pages/EbookReader.tsx
Comment thread web/src/reader/readerNavigation.ts Outdated
RXWatcher and others added 19 commits July 20, 2026 23:02
DailyRollup's date_trunc('day', started_at) truncated in whatever
timezone the DB session happened to be in, which the pool never sets
explicitly, making the day boundary nondeterministic. Wrap the column
in AT TIME ZONE 'UTC' first so bucketing is pinned to UTC regardless
of session config. TotalsSince's since boundaries are already
computed in Go as UTC time.Time and compared directly (no
date_trunc), so they were already stable and needed no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Finishing a text selection could still page-turn: FoliateBookReader
deferred its onSelectionChange update with setTimeout(0), so the
pointerup ending a selection dispatched to EbookReader's
dispatchSurfaceTap while React's `selection` state still read stale
(often null). Add a synchronous hasLiveSelectionRef inside
FoliateBookReader, updated in the selectionchange/pointerup/keyup
listeners ahead of that deferral, and expose it via
hasLiveSelection() on the imperative handle. dispatchSurfaceTap now
ORs the React state with this live signal before deciding whether a
tap should page-turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `b` shortcut and header bookmark button always created a new
bookmark annotation, even when one already existed at the current
location, contradicting the "Toggle bookmark" shortcut description.
Rename handleCreateBookmark to handleToggleBookmark: it now looks for
an existing kind="bookmark" annotation whose location exactly matches
the one it would create, and deletes it (same path the Notes panel
uses) instead of creating a duplicate. Restore the READER_SHORTCUTS
"b" description and the header button's aria-label/title to "Toggle
bookmark" to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imezone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Goal saves now go through useSaveReadingGoals (useMutation +
invalidateQueries on ebookKeys.readingMotivation()), only advance the
saved-value ref after a successful PUT, and surface an inline error
(cleared on the next successful save) instead of failing silently.
Motivation sections now render off useReadingMotivation's own data,
independent of the reading-history query's loading/error gate. The
motivation query key also includes tz, matching readingHistory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HandleHistory derived "today" from h.Now()'s UTC-clock Year/Month/Day
fields before applying the requester's timezone, so today/week/month
totals and the default `to` boundary silently shifted by a day for
non-UTC users during their local 00:00-02:00 window (e.g. UTC+2).
Mirror the motivation handler's now.In(loc) pattern instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The night-owl accumulation reused hourBucket's DNA "night" bucket
(22-04), but the badge's own spec window is midnight-5am. Sessions
between 22:00 and midnight were wrongly credited toward the badge
while sessions between 04:00 and 05:00 were wrongly excluded. Accumulate
against a dedicated nightOwlEndHour bound instead; hourBucket itself
(used for the DNA night bucket) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reading-motivation response surfaced every genre individually,
which sprawls for wide-genre libraries. Collapse the display list to
the top 8 by seconds plus a trailing "other" bucket summing the rest,
in the handler after diversity_score/genre-hopper's distinct-genre
count are already computed from the full, uncollapsed list so neither
is understated by the collapse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReadingStats.tsx derived its default history range from a fixed
todayUTC(), disagreeing with the server-side fix in the previous
commit whenever the viewer's local calendar day differs from UTC's.
Derive `to` from the viewer's IANA timezone instead (via a small
en-CA-locale Intl.DateTimeFormat trick), so the client's default range
and the server's now agree on which day is "today".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reorder the reading-stats page to the spec order (totals row, then
streak/goals/achievements/DNA, then the activity/books/sessions
detail), while keeping the motivation sections' independence from the
history query's loading/error state — they now render unconditionally
rather than merely being unaffected by a gate they sat above. Add a
Flame icon to the streak card's day count, matching today_qualified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (2)
web/src/components/stats/MotivationSections.tsx (1)

241-268: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Associate error text with inputs via aria-describedby.

aria-invalid is set, but the error <p> isn't programmatically linked to the field, so screen readers announce the invalid state without the reason. Give each error paragraph an id and reference it from the input.

♿ Proposed association
             <Input
               id="goal-books-per-year"
               inputMode="numeric"
               value={booksInput}
               aria-invalid={booksError != null}
+              aria-describedby={booksError ? "goal-books-per-year-error" : undefined}
               onChange={(e) => setBooksInput(e.target.value)}
               onBlur={() => void commitBooks()}
             />
-            {booksError ? <p className="text-destructive text-xs">{booksError}</p> : null}
+            {booksError ? (
+              <p id="goal-books-per-year-error" className="text-destructive text-xs">
+                {booksError}
+              </p>
+            ) : null}

Apply the same pattern to the hours field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/stats/MotivationSections.tsx` around lines 241 - 268,
Associate each validation message with its corresponding input: add a unique id
to the booksError and hoursError paragraphs, then set the matching
aria-describedby on the goal-books-per-year and goal-hours-per-year Input
elements. Keep the associations aligned with their respective fields and
preserve the existing conditional error rendering.
internal/api/handlers/reading_motivation.go (1)

616-620: 🚀 Performance & Scalability | 🔵 Trivial

All-time session load per motivation request may not scale.

SessionsSince(ctx, userID, profileID, time.Time{}) pulls every session a profile has ever recorded on each GET /reading-motivation, then recomputes streaks/DNA/achievements in memory. For long-term heavy readers this grows unbounded in row count and per-request memory/CPU. Consider precomputed daily/monthly rollups (the reading_sessions day-bucket totals you already derive) or a server-side cache keyed by profile+tz+day, so the hot path reads a bounded aggregate rather than the full history.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handlers/reading_motivation.go` around lines 616 - 620, Replace
the unbounded all-time SessionsSince call in the motivation request handler with
a bounded aggregate source, such as precomputed daily/monthly reading-session
rollups or a cache keyed by profile, timezone, and day. Update the downstream
streak, DNA, and achievement calculations to consume that aggregate while
preserving their existing behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-20-reading-motivation.md`:
- Line 35: Update the ReadingMotivationStore method signatures and
PGReadingMotivationStore implementation to use the repository’s identity types
consistently: context.Context, numeric userID, and string profileID for every
method, including GetGoals, PutGoals, AchievedAt, PersistAchievement,
SessionsSince, FinishedBooksInRange, GenreSeconds, and AuthorSeconds. Ensure all
declarations reflect the same typed contract.
- Around line 226-237: The dashboard handler must not load all session history
since the epoch for each request. Update the session aggregation design around
the handler and the GenreSeconds/AuthorSeconds queries to compute lifetime
totals and required aggregates in SQL or dedicated aggregate queries, fetching
raw sessions only for calculations that genuinely require them while preserving
the existing dashboard outputs.
- Around line 170-174: Extend TestEvaluateAchievements to cover the finisher
eligibility boundary where FinishedWithHighRead is derived: verify 94.99%
completion is rejected and exactly 95.00% is accepted, preserving the specified
inclusive >=95% behavior.
- Line 109: Update the Goals request-shape example to use a fenced Go block
instead of an inline code span, and show a valid putGoalsRequest struct with
pointer fields and correctly formatted JSON tags for books_per_year and
hours_per_year.

In `@docs/superpowers/specs/2026-07-20-reading-motivation-design.md`:
- Around line 61-65: Make reading-motivation reads side-effect-free by removing
GET-time achievement persistence from the motivation endpoint. In
docs/superpowers/specs/2026-07-20-reading-motivation-design.md lines 61-65,
revise the achievement flow so persistence occurs through a separate mutation
flow rather than during the motivation endpoint call. In
docs/superpowers/plans/2026-07-20-reading-motivation.md lines 201-203, remove
PersistAchievement writes from GET handling and update the endpoint contract and
tests accordingly.

---

Nitpick comments:
In `@internal/api/handlers/reading_motivation.go`:
- Around line 616-620: Replace the unbounded all-time SessionsSince call in the
motivation request handler with a bounded aggregate source, such as precomputed
daily/monthly reading-session rollups or a cache keyed by profile, timezone, and
day. Update the downstream streak, DNA, and achievement calculations to consume
that aggregate while preserving their existing behavior.

In `@web/src/components/stats/MotivationSections.tsx`:
- Around line 241-268: Associate each validation message with its corresponding
input: add a unique id to the booksError and hoursError paragraphs, then set the
matching aria-describedby on the goal-books-per-year and goal-hours-per-year
Input elements. Keep the associations aligned with their respective fields and
preserve the existing conditional error rendering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b49bce4-d812-4976-8199-c223bce55eb7

📥 Commits

Reviewing files that changed from the base of the PR and between 7adf82a and 9188dc0.

📒 Files selected for processing (21)
  • docs/superpowers/plans/2026-07-20-reading-motivation.md
  • docs/superpowers/specs/2026-07-20-reading-motivation-design.md
  • internal/api/handlers/reading_motivation.go
  • internal/api/handlers/reading_motivation_test.go
  • internal/api/handlers/reading_sessions.go
  • internal/api/handlers/reading_sessions_test.go
  • internal/api/router.go
  • migrations/sql/20260720230000_reading_motivation.sql
  • web/src/components/stats/MotivationSections.test.tsx
  • web/src/components/stats/MotivationSections.tsx
  • web/src/hooks/queries/keys.ts
  • web/src/hooks/queries/readingStats.test.ts
  • web/src/hooks/queries/readingStats.ts
  • web/src/pages/EbookReader.test.tsx
  • web/src/pages/EbookReader.tsx
  • web/src/pages/ReadingStats.test.tsx
  • web/src/pages/ReadingStats.tsx
  • web/src/reader/FoliateBookReader.component.test.tsx
  • web/src/reader/FoliateBookReader.tsx
  • web/src/reader/readerNavigation.test.ts
  • web/src/reader/readerNavigation.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • web/src/reader/readerNavigation.ts
  • web/src/reader/readerNavigation.test.ts
  • web/src/pages/ReadingStats.tsx
  • internal/api/router.go
  • web/src/reader/FoliateBookReader.component.test.tsx
  • internal/api/handlers/reading_sessions_test.go
  • internal/api/handlers/reading_sessions.go
  • web/src/pages/EbookReader.tsx
  • web/src/reader/FoliateBookReader.tsx
  • web/src/pages/EbookReader.test.tsx

Comment thread docs/superpowers/plans/2026-07-20-reading-motivation.md Outdated
Comment thread docs/superpowers/plans/2026-07-20-reading-motivation.md Outdated
Comment on lines +170 to +174
func TestEvaluateAchievements(t *testing.T)
// each of the 18 at its boundary: satisfied at exactly the threshold, unsatisfied just below
// (3600s→first-hour, 7200s single session→marathon, streak 3/7/30/100, books 1/10/50,
// 36000s night→night-owl, 36000s early→early-bird, 72000s weekend→weekender,
// 5 genres→genre-hopper, 36000s one book→deep-diver, FinishedWithHighRead→finisher).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test the finisher’s 95% boundary directly.

The specification requires >= 95%, but these tests only pass a precomputed FinishedWithHighRead boolean. Add cases proving 94.99% is rejected and 95.00% is accepted where that boolean is derived.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-20-reading-motivation.md` around lines 170 -
174, Extend TestEvaluateAchievements to cover the finisher eligibility boundary
where FinishedWithHighRead is derived: verify 94.99% completion is rejected and
exactly 95.00% is accepted, preserving the specified inclusive >=95% behavior.

Comment thread docs/superpowers/plans/2026-07-20-reading-motivation.md
Comment thread docs/superpowers/specs/2026-07-20-reading-motivation-design.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Silo v1 scope - auto-adds to the Silo v1 project

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant