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.
268 lines
10 KiB
Go
268 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"bookhoard/internal/database"
|
||
)
|
||
|
||
// Reading-statistics coverage: session CAPTURE at the sync chokepoint,
|
||
// the summary aggregation (tz buckets, streaks, finished detection),
|
||
// range validation, the widget, and the per-book card.
|
||
//
|
||
// All seeded instants anchor to USER-TZ midnights (the test user is UTC)
|
||
// so day buckets are deterministic regardless of when the suite runs.
|
||
|
||
func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} }
|
||
func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} }
|
||
func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} }
|
||
func pgTS(v time.Time) pgtype.Timestamptz {
|
||
return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()}
|
||
}
|
||
|
||
// middayDaysAgo returns noon UTC N calendar days before today — a
|
||
// bucket-stable instant for any wall-clock time the suite runs at.
|
||
func middayDaysAgo(daysAgo int) time.Time {
|
||
midnight := time.Now().UTC()
|
||
midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC)
|
||
return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour)
|
||
}
|
||
|
||
func statsLogin(t *testing.T, setup *TestServerSetup, path string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer func() {
|
||
_ = resp.Body.Close()
|
||
t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit
|
||
}()
|
||
body, err := io.ReadAll(resp.Body)
|
||
require.NoError(t, err)
|
||
return resp, body
|
||
}
|
||
|
||
func TestReadingStatsCapture(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
userID := getTestUserID(t, setup.DB)
|
||
ctx := context.Background()
|
||
|
||
bookID := createTestMediaItemID(t, setup)
|
||
|
||
push := func(body map[string]interface{}) int {
|
||
payload, _ := json.Marshal(body)
|
||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer func() { _ = resp.Body.Close() }()
|
||
return resp.StatusCode
|
||
}
|
||
|
||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||
}))
|
||
|
||
time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span
|
||
|
||
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||
"percentage": 0.15, "current_page": 15, "total_pages": 100,
|
||
}))
|
||
|
||
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||
UserID: pgUser(userID),
|
||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, rows, 2, "each push records one session row")
|
||
|
||
first := rows[0]
|
||
assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time")
|
||
assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001)
|
||
|
||
second := rows[1]
|
||
assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed")
|
||
assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval")
|
||
require.True(t, second.PagesRead.Valid, "page info was carried on both pushes")
|
||
assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10")
|
||
|
||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||
UserID: pgUser(userID),
|
||
})
|
||
require.NoError(t, err)
|
||
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position")
|
||
}
|
||
|
||
func TestReadingStatsSummary(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
userID := getTestUserID(t, setup.DB)
|
||
ctx := context.Context(context.Background())
|
||
|
||
libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks")
|
||
libUUID := uuid.MustParse(libIDStr)
|
||
|
||
var finishedBookID, offRangeBookID string
|
||
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, reuseMediaID ...string) string {
|
||
var media database.MediaItems
|
||
var err error
|
||
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
|
||
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||
require.NoError(t, err, "reuse seeded media item")
|
||
} else {
|
||
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||
Title: title,
|
||
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||
})
|
||
require.NoError(t, err, "seed media item")
|
||
}
|
||
|
||
pushAt := middayDaysAgo(daysAgo)
|
||
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||
UserID: pgUser(userID),
|
||
MediaItemID: media.ID,
|
||
ProgressPercentage: pgFloat(pct),
|
||
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||
ReadingSessionEnd: pgTS(pushAt),
|
||
PagesRead: pgInt4(pages),
|
||
TimeSpentSeconds: pgInt4(minutes * 60),
|
||
DeviceMetadata: []byte(`{"source":"test"}`),
|
||
})
|
||
require.NoError(t, err, "seed session")
|
||
|
||
id, _ := uuid.FromBytes(media.ID.Bytes[0:16])
|
||
return id.String()
|
||
}
|
||
|
||
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book")
|
||
insertSession(1, 45, 20, 0.5, "Stats Streak Book")
|
||
insertSession(2, 60, 25, 0.42, "Stats Back Book")
|
||
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID)
|
||
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book")
|
||
|
||
// Summary -----------------------------------------------------------
|
||
resp, body := statsLogin(t, setup, "/api/stats/summary?range=7")
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var summary struct {
|
||
Range string `json:"range"`
|
||
TotalMinutes int `json:"total_minutes"`
|
||
TotalPages int `json:"total_pages"`
|
||
SessionCount int `json:"session_count"`
|
||
BooksActive int `json:"books_with_activity"`
|
||
BooksFinished int `json:"books_finished"`
|
||
CompletionRate float64 `json:"completion_rate"`
|
||
Daily []struct {
|
||
Date string `json:"date"`
|
||
Minutes int `json:"minutes"`
|
||
Pages int `json:"pages"`
|
||
} `json:"daily"`
|
||
Streak struct {
|
||
Current int `json:"current"`
|
||
Longest int `json:"longest"`
|
||
ActiveToday bool `json:"active_today"`
|
||
} `json:"streak"`
|
||
TopBooks []struct {
|
||
MediaItemID string `json:"media_item_id"`
|
||
Minutes int `json:"minutes"`
|
||
} `json:"top_books"`
|
||
LibraryBreakdown []struct {
|
||
Name string `json:"name"`
|
||
Minutes int `json:"minutes"`
|
||
} `json:"library_breakdown"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(body, &summary))
|
||
|
||
assert.Equal(t, "7", summary.Range)
|
||
require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar")
|
||
assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)")
|
||
assert.Equal(t, 65, summary.TotalPages, "12+20+25+8")
|
||
assert.Equal(t, 4, summary.SessionCount)
|
||
assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions")
|
||
assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%")
|
||
assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001)
|
||
|
||
// Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't.
|
||
t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak)
|
||
t.Logf("DAILY DUMP: %+v", summary.Daily)
|
||
assert.Equal(t, 3, summary.Streak.Current)
|
||
assert.Equal(t, 3, summary.Streak.Longest)
|
||
assert.True(t, summary.Streak.ActiveToday)
|
||
|
||
// Top books: minutes-ordered — the seeded 60-minute back book tops.
|
||
require.NotEmpty(t, summary.TopBooks)
|
||
assert.Equal(t, 60, summary.TopBooks[0].Minutes)
|
||
for i := 1; i < len(summary.TopBooks); i++ {
|
||
assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes")
|
||
}
|
||
|
||
// Library breakdown carries the seeded library.
|
||
found := false
|
||
for _, lib := range summary.LibraryBreakdown {
|
||
if lib.Minutes >= 155 {
|
||
found = true
|
||
}
|
||
}
|
||
assert.True(t, found, "library breakdown aggregates the seeded library")
|
||
|
||
// Range validation ---------------------------------------------------
|
||
badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight")
|
||
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)
|
||
assert.Contains(t, string(badBody), "invalid range")
|
||
|
||
// Auth gate ----------------------------------------------------------
|
||
anonym, err := http.Get(setup.Server.URL + "/api/stats/summary")
|
||
require.NoError(t, err)
|
||
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
|
||
_ = anonym.Body.Close()
|
||
|
||
// Widget -------------------------------------------------------------
|
||
resp, body = statsLogin(t, setup, "/api/stats/this-week")
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
var week struct {
|
||
WeekMinutes int `json:"week_minutes"`
|
||
WeekBooks int `json:"week_books"`
|
||
ActiveToday bool `json:"active_today"`
|
||
CurrentStreak int `json:"current_streak"`
|
||
PrevMinutes int `json:"prev_week_minutes"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(body, &week))
|
||
assert.Equal(t, 155, week.WeekMinutes)
|
||
assert.Equal(t, 3, week.WeekBooks)
|
||
assert.True(t, week.ActiveToday)
|
||
assert.Equal(t, 3, week.CurrentStreak)
|
||
assert.Equal(t, 0, week.PrevMinutes)
|
||
|
||
// Per-book card (lifetime, so the 400-day book still totals) -----------
|
||
resp, body = statsLogin(t, setup, "/api/stats/books/"+offRangeBookID)
|
||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||
var bookStats struct {
|
||
TotalMinutes int `json:"total_minutes"`
|
||
SessionCount int `json:"session_count"`
|
||
MaxProgress float64 `json:"max_progress"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(body, &bookStats))
|
||
assert.Equal(t, 120, bookStats.TotalMinutes)
|
||
assert.Equal(t, 1, bookStats.SessionCount)
|
||
assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001)
|
||
|
||
_ = finishedBookID
|
||
}
|