feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card
The design parked in the handoff (multi-tier stats off reading_history; bookorbit-style in data, not looks) is in. The real prerequisite work was data: reading_history existed but NOTHING wrote to it — the whole stats feature was sitting on an empty table. - CAPTURE (no client changes, no repo-boundary crossings): every successful progress write through SaveProgress (KOReader + app + web + Kobo + offline queue — the one chokepoint) now records one reading_history session row: span = the interval since the PREVIOUS write instant, pages = positive page delta when both sides know the page index, marker rows for first contact. Spans over 2h are kept as zero-second markers (overnight suspend→resume, offline flushes must not inflate totals) — the honest 'sync cadence, not a stopwatch' caveat stays. device_id only when a REAL device row matches (the web/app progress path historically stuffs the USER id into DeviceID — probe instead of trusting it; FK violation would silently drop every capture). Source preserved in device_metadata. - API: /api/stats/summary?range=7|30|90|year (totals, zero-filled daily arrays, streaks, most-active day/weekday, top books, library breakdown — one call), /api/stats/this-week (widget: rolling 7d vs previous + streak), /api/stats/books/:id (lifetime + recent sessions). All bucketed in the user's timezone; finished ≡ any push ≥0.995; visible-library lens enforced at source. Legacy /api/analytics/* kept untouched. Range windows anchor end-of-day to the START of today (blasting the clock time into tomorrow resolved 'today' a calendar day late — found by the streak test). - /stats page (sidebar Stats between Progress and Devices, previously /analytics was an orphan): range selector, five stat cards, two Chart.js v4 charts (daily minutes bar, pages/day line — chunked via dynamic import so main.js stays untouched), per-library breakdown, most-read list, empty-state explainer. /analytics now 301s to /stats. The orphaned analytics.templ + analytics.ts removed (their containers and the page JSON never matched; the page was astub). - Dashboard 'This Week' widget (server-rendered, suppressed on service failure) and the book detail 'Your Reading Stats' card (lifetime totals of the viewer's own sessions) complete the tiers: per-user default, per-library breakdown inline, per-book on the page, per-system/admin aggregate parked as before. Tests: statistics_test.go (capture semantics at the chokepoint, tz/day buckets, zero-fill, streak math, finished detection, range validation, auth gate, widget, per-book lifetime). Full suite PASS.
This commit is contained in:
1 parent
8de8883acf
commit
6457fbaf72
28 files changed
+3061
-814
No files matched your search
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, errorMessage string) {
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -24,6 +24,9 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
|
||||
</div>
|
||||
if week != nil {
|
||||
@DashboardWeekWidget(*week)
|
||||
}
|
||||
for _, section := range sections {
|
||||
@CollectionCarousel(section)
|
||||
}
|
||||
@@ -247,3 +250,87 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// WeekStatsData is the dashboard's "this week" widget payload
|
||||
// (server-rendered from the statistics service; nil = suppressed).
|
||||
// Times rendered in the user's timezone at the server, per convention.
|
||||
type WeekStatsData struct {
|
||||
WeekMinutes int
|
||||
WeekPages int
|
||||
WeekSessions int
|
||||
WeekBooks int
|
||||
PrevMinutes int
|
||||
ActiveToday bool
|
||||
CurrentStreak int
|
||||
}
|
||||
|
||||
// BookStatsData is the per-book reading-stats card on the book detail
|
||||
// page (nil = the viewer has no sessions on this book → hide it).
|
||||
type BookStatsData struct {
|
||||
TotalMinutes int
|
||||
TotalPages int
|
||||
SessionCount int
|
||||
MaxProgress float64 // 0..1
|
||||
Finished bool
|
||||
LastSession string
|
||||
FirstSession string
|
||||
}
|
||||
|
||||
templ DashboardWeekWidget(week WeekStatsData) {
|
||||
<div class="card p-4 md:p-6 mb-10">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("chart", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">This Week</h2>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">Synced from your reading devices</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/stats" class="text-sm font-medium hover:underline" style="color: var(--accent);">Full stats →</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d min", week.WeekMinutes) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||
{ readingTimeSuffix(week.WeekMinutes, week.PrevMinutes) }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">pages read</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">reading sessions</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">books touched</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs mt-4" style="color: var(--text-secondary)">
|
||||
if week.CurrentStreak > 0 {
|
||||
if week.ActiveToday {
|
||||
{ fmt.Sprintf("🔥 %d-day streak — keep it going today", week.CurrentStreak) }
|
||||
} else {
|
||||
{ fmt.Sprintf("🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
|
||||
}
|
||||
} else {
|
||||
{ "A reading day today starts your streak." }
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
func readingTimeSuffix(minutes, prevMinutes int) string {
|
||||
if prevMinutes <= 0 {
|
||||
if minutes > 0 {
|
||||
return "reading time"
|
||||
}
|
||||
return "no reading yet this week"
|
||||
}
|
||||
percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100
|
||||
return fmt.Sprintf("%+.0f%% vs last week", percent)
|
||||
}
|
||||
Reference in new issue
Block a user