From 7a604c2fec081ed2e9c22e0ce693014106aa41c9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 14 Oct 2026 13:04:34 -0400 Subject: [PATCH] feat(stats): per-device reading breakdown + device attribution on progress syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Attribution: registered clients may now send their device token in X-Device-Token on PUT /api/media-items/:id/progress (the user JWT stays primary). The token must map to a device OWNED BY the caller — foreign/invalid/absent tokens soft-fail to plain web attribution so a stats decoration can never break a sync. Attributed pushes capture device_id (FK-safe), device_metadata.source = the registered device name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show that device. Side effect worth having: app-vs-web pushes used to be LWW-invisible (both sources read 'web'); they now surface as legitimate cross-source progress conflicts when they diverge. - /stats gains a By Device panel alongside By Library (grid 3-wide): reading minutes + sessions + books per registered device, plus one Web / unsynced bucket for device-less pushes so the panel always reconciles with the headline totals. Range-aware, family-lens scoped; renderBars shared between panels in stats.ts. - The device identity IS the registered platform: devices rows already carry the name/type the client registered (app sends Build.MODEL + 'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new identity plumbing on the server. Tests: capture attributed to the device (row device_id + source metadata + last_sync_source), foreign/nonexistent tokens soft-fail, device buckets reconcile (80 attributed + 75 unsynced = 155 total). Full suite PASS. --- cmd/server/tests/statistics_test.go | 178 +++++++++++++++++++++++- internal/database/querier.go | 4 + internal/database/queries.sql.go | 73 ++++++++++ internal/database/queries/queries.sql | 23 +++ internal/handlers/media.go | 33 ++++- internal/services/statistics_service.go | 50 ++++++- templates/stats.templ | 9 +- web/src/stats.ts | 49 ++++++- 8 files changed, 399 insertions(+), 20 deletions(-) diff --git a/cmd/server/tests/statistics_test.go b/cmd/server/tests/statistics_test.go index 803932e4..1e2f07b9 100644 --- a/cmd/server/tests/statistics_test.go +++ b/cmd/server/tests/statistics_test.go @@ -108,7 +108,115 @@ func TestReadingStatsCapture(t *testing.T) { assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position") } +func TestReadingStatsDeviceAttribution(t *testing.T) { + setup := setupTestServer(t) + userID := getTestUserID(t, setup.DB) + ctx := context.Background() + + bookID := createTestMediaItemID(t, setup) + + // A registered device owned by the test user, plus one owned by a + // DIFFERENT user (the foreign token must be ignored, never sent). + device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ + UserID: pgUser(userID), + DeviceName: "Pixel 9 Test", + DeviceType: "mobile", + DeviceIdentifier: "stats-test-android-" + uuid.NewString(), + AuthToken: "dev_test_token_" + uuid.NewString(), + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) }) + + otherUser := getRegularUserID(t, setup.DB) + foreign, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ + UserID: pgUser(otherUser), + DeviceName: "Foreign Device", + DeviceType: "mobile", + DeviceIdentifier: "stats-test-foreign-" + uuid.NewString(), + AuthToken: "dev_foreign_token_" + uuid.NewString(), + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, foreign.ID) }) + + push := func(body map[string]interface{}, deviceToken string) 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) + if deviceToken != "" { + req.Header.Set("X-Device-Token", deviceToken) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode + } + + // Registered device push → attributed to the device with its name. + require.Equal(t, http.StatusOK, push(map[string]interface{}{ + "percentage": 0.10, "current_page": 10, "total_pages": 100, + }, device.AuthToken)) + + rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ + UserID: pgUser(userID), + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + }) + require.NoError(t, err) + require.Len(t, rows, 1) + require.True(t, rows[0].DeviceID.Valid) + assert.Equal(t, uuid.UUID(device.ID.Bytes).String(), uuid.UUID(rows[0].DeviceID.Bytes).String()) + var meta struct { + Source string `json:"source"` + } + require.NoError(t, json.Unmarshal(rows[0].DeviceMetadata, &meta)) + assert.Equal(t, "Pixel 9 Test", meta.Source, "capture metadata carries the registered device name") + + progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + UserID: pgUser(userID), + }) + require.NoError(t, err) + assert.Equal(t, "Pixel 9 Test", progress.LastSyncSource.String, "last-sync surface shows the device name") + + // Foreign device token → soft-fails: unattributed, plain web source. + require.Equal(t, http.StatusOK, push(map[string]interface{}{ + "percentage": 0.20, "current_page": 20, "total_pages": 100, + }, "dev_foreign_token_"+uuid.NewString() /* nonexistent token */)) + + // A REAL foreign token must also be ignored, not adopted. + require.Equal(t, http.StatusOK, push(map[string]interface{}{ + "percentage": 0.25, "current_page": 25, "total_pages": 100, + }, foreign.AuthToken)) + + rows, err = setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ + UserID: pgUser(userID), + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + }) + require.NoError(t, err) + require.Len(t, rows, 3) + for _, row := range rows[1:] { + assert.False(t, row.DeviceID.Valid, "invalid/foreign tokens never attribute a device") + require.NoError(t, json.Unmarshal(row.DeviceMetadata, &meta)) + assert.Equal(t, "web", meta.Source) + } + progress, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ + MediaItemID: uuidToPGType(uuid.MustParse(bookID)), + UserID: pgUser(userID), + }) + require.NoError(t, err) + assert.Equal(t, "web", progress.LastSyncSource.String) +} + func TestReadingStatsSummary(t *testing.T) { + setup := setupTestServer(t) userID := getTestUserID(t, setup.DB) ctx := context.Context(context.Background()) @@ -117,7 +225,24 @@ func TestReadingStatsSummary(t *testing.T) { libUUID := uuid.MustParse(libIDStr) var finishedBookID, offRangeBookID string - insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, reuseMediaID ...string) string { + // A real registered device: attributed sessions carry its id (the + // rest fall into the "Web / unsynced" bucket). + seedDevice, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ + UserID: pgUser(userID), + DeviceName: "Kindle Papered Test", + DeviceType: "koreader", + DeviceIdentifier: "stats-test-kindle-" + uuid.NewString(), + AuthToken: "dev_kindle_token_" + uuid.NewString(), + SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, + AutoSync: pgtype.Bool{Bool: true, Valid: true}, + SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, + DeviceMetadata: []byte("{}"), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, seedDevice.ID) }) + deviceID := seedDevice.ID + + insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, attributed bool, reuseMediaID ...string) string { var media database.MediaItems var err error if len(reuseMediaID) > 0 && reuseMediaID[0] != "" { @@ -134,10 +259,16 @@ func TestReadingStatsSummary(t *testing.T) { require.NoError(t, err, "seed media item") } + deviceIDArg := pgtype.UUID{} + if attributed { + deviceIDArg = deviceID + } + pushAt := middayDaysAgo(daysAgo) _, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ UserID: pgUser(userID), MediaItemID: media.ID, + DeviceID: deviceIDArg, ProgressPercentage: pgFloat(pct), ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)), ReadingSessionEnd: pgTS(pushAt), @@ -151,11 +282,12 @@ func TestReadingStatsSummary(t *testing.T) { 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") + // Device-split scenario: attributed (80 min) + unattributed (75 min). + finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false) + insertSession(1, 45, 20, 0.5, "Stats Streak Book", false) + insertSession(2, 60, 25, 0.42, "Stats Back Book", true) + insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", true, finishedBookID) + offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book", false) // Summary ----------------------------------------------------------- resp, body := statsLogin(t, setup, "/api/stats/summary?range=7") @@ -187,6 +319,13 @@ func TestReadingStatsSummary(t *testing.T) { Name string `json:"name"` Minutes int `json:"minutes"` } `json:"library_breakdown"` + DeviceBreakdown []struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Type string `json:"type"` + Minutes int `json:"minutes"` + Sessions int `json:"sessions"` + } `json:"device_breakdown"` } require.NoError(t, json.Unmarshal(body, &summary)) @@ -222,6 +361,33 @@ func TestReadingStatsSummary(t *testing.T) { } assert.True(t, found, "library breakdown aggregates the seeded library") + // Device breakdown: attributed (80) + unattributed (75) reconcile + // against the headline total; both buckets present. + require.Len(t, summary.DeviceBreakdown, 2, "one device bucket + one unattributed bucket") + var kindleFound, unattributedFound bool + for _, dev := range summary.DeviceBreakdown { + if dev.Name == "Kindle Papered Test" { + kindleFound = true + assert.Equal(t, 80, dev.Minutes, "60 + 20 attributed sessions") + assert.Equal(t, 2, dev.Sessions) + assert.NotEqual(t, "", dev.DeviceID, "device bucket carries its id") + assert.Equal(t, "koreader", dev.Type) + } + if dev.Name == "Web / unsynced" { + unattributedFound = true + assert.Equal(t, 75, dev.Minutes, "30 + 45 unattributed sessions") + assert.Equal(t, 2, dev.Sessions) + assert.Equal(t, "", dev.DeviceID) + } + } + assert.True(t, kindleFound, "the registered device has its own bucket") + assert.True(t, unattributedFound, "device-less pushes aggregate into 'Web / unsynced'") + deviceMinutes := 0 + for _, dev := range summary.DeviceBreakdown { + deviceMinutes += dev.Minutes + } + assert.Equal(t, 155, deviceMinutes, "device buckets reconcile with the total") + // Range validation --------------------------------------------------- badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight") assert.Equal(t, http.StatusBadRequest, badResp.StatusCode) diff --git a/internal/database/querier.go b/internal/database/querier.go index 4882247b..cc7685b9 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -359,6 +359,10 @@ type Querier interface { GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) + // Per-device reading breakdown for a range. LEFT JOIN so pushes that + // carry no device (web reader until it identifies) still appear as one + // "unattributed" bucket — device minutes must reconcile with the totals. + GetUserDeviceBreakdown(ctx context.Context, arg GetUserDeviceBreakdownParams) ([]GetUserDeviceBreakdownRow, error) // Get user device usage statistics GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 97f31e24..16c76587 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -8264,6 +8264,79 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt return items, nil } +const GetUserDeviceBreakdown = `-- name: GetUserDeviceBreakdown :many +SELECT + d.id, + d.device_name, + d.device_type, + COUNT(*)::int AS session_count, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(DISTINCT rh.media_item_id)::int AS book_count +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +LEFT JOIN devices d ON d.id = rh.device_id +WHERE rh.user_id = $1 + AND COALESCE(rh.reading_session_end, rh.created_at) >= $2 + AND COALESCE(rh.reading_session_end, rh.created_at) <= $3 + AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[])) +GROUP BY d.id, d.device_name, d.device_type +ORDER BY total_seconds DESC +` + +type GetUserDeviceBreakdownParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"` + EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +type GetUserDeviceBreakdownRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceName pgtype.Text `db:"device_name" json:"device_name"` + DeviceType pgtype.Text `db:"device_type" json:"device_type"` + SessionCount int32 `db:"session_count" json:"session_count"` + TotalSeconds int32 `db:"total_seconds" json:"total_seconds"` + TotalPages int32 `db:"total_pages" json:"total_pages"` + BookCount int32 `db:"book_count" json:"book_count"` +} + +// Per-device reading breakdown for a range. LEFT JOIN so pushes that +// carry no device (web reader until it identifies) still appear as one +// "unattributed" bucket — device minutes must reconcile with the totals. +func (q *Queries) GetUserDeviceBreakdown(ctx context.Context, arg GetUserDeviceBreakdownParams) ([]GetUserDeviceBreakdownRow, error) { + rows, err := q.db.Query(ctx, GetUserDeviceBreakdown, + arg.UserID, + arg.StartTime, + arg.EndTime, + arg.VisibleLibraryIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserDeviceBreakdownRow{} + for rows.Next() { + var i GetUserDeviceBreakdownRow + if err := rows.Scan( + &i.ID, + &i.DeviceName, + &i.DeviceType, + &i.SessionCount, + &i.TotalSeconds, + &i.TotalPages, + &i.BookCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUserDeviceUsage = `-- name: GetUserDeviceUsage :many SELECT d.id, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 7b576bf7..9ad857ec 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1563,6 +1563,29 @@ SELECT EXISTS( WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id') ) AS has_device; +-- Per-device reading breakdown for a range. LEFT JOIN so pushes that +-- carry no device (web reader until it identifies) still appear as one +-- "unattributed" bucket — device minutes must reconcile with the totals. +-- name: GetUserDeviceBreakdown :many +SELECT + d.id, + d.device_name, + d.device_type, + COUNT(*)::int AS session_count, + SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds, + SUM(COALESCE(rh.pages_read, 0))::int AS total_pages, + COUNT(DISTINCT rh.media_item_id)::int AS book_count +FROM reading_history rh +JOIN media_items mi ON mi.id = rh.media_item_id +LEFT JOIN devices d ON d.id = rh.device_id +WHERE rh.user_id = sqlc.arg('user_id') + AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time') + AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time') + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) +GROUP BY d.id, d.device_name, d.device_type +ORDER BY total_seconds DESC; + + -- ============================================ -- DEVICE MANAGEMENT & AUTH diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 84a4a1b6..14f8abfb 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1108,6 +1108,31 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } + // Reading-stats attribution: registered clients may send their device + // token in X-Device-Token (the user JWT stays the primary auth). A + // token that maps to a device owned by THIS user attaches the push — + // and everything the reading history records about it — to that + // device's registered name/type ("Pixel 9" · mobile, "Kindle… " · + // koreader). Absent/invalid/foreign tokens soft-fail to the plain + // web attribution; a stats decoration must never fail a sync. + deviceID := pgtype.UUID{} + deviceSource := "web" + deviceLabel := "" + deviceType := "" + if token := c.Request().Header.Get("X-Device-Token"); token != "" { + device, devErr := mh.db.GetDeviceByAuthToken(c.Request().Context(), token) + if devErr == nil && device.UserID.Valid && uuid.UUID(device.UserID.Bytes) == userUUID { + deviceID = device.ID + deviceLabel = device.DeviceName + deviceType = device.DeviceType + deviceSource = device.DeviceName + if strings.TrimSpace(deviceSource) == "" { + deviceSource = device.DeviceType + } + } + } + + var req struct { CurrentPage *int32 `json:"current_page"` TotalPages *int32 `json:"total_pages"` @@ -1130,8 +1155,10 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error { saveReq := wsync.SaveProgressRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, - Source: "web", - DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true}, + Source: deviceSource, + DeviceID: deviceID, + DeviceType: deviceType, + DeviceName: deviceLabel, Percentage: req.Percentage, Epubcfi: req.Epubcfi, ContextText: req.ContextText, @@ -1144,8 +1171,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error { ScrollX: req.ScrollX, ScrollY: req.ScrollY, ReadingMode: req.ReadingMode, - DeviceType: "web", - DeviceName: "Web", Broadcast: true, } if req.CurrentPage != nil { diff --git a/internal/services/statistics_service.go b/internal/services/statistics_service.go index e82d4c0e..14998e79 100644 --- a/internal/services/statistics_service.go +++ b/internal/services/statistics_service.go @@ -114,6 +114,18 @@ type LibraryStats struct { Books int `json:"books"` } +type DeviceStats struct { + // DeviceID is empty for the unattributed bucket (web reader pushes + // that carry no device credential) — Name then reads "Web / unsynced". + DeviceID string `json:"device_id"` + Name string `json:"name"` + Type string `json:"type"` + Minutes int `json:"minutes"` + Pages int `json:"pages"` + Sessions int `json:"sessions"` + Books int `json:"books"` +} + type UserSummaryStats struct { Range string `json:"range"` Days int `json:"days"` @@ -131,8 +143,9 @@ type UserSummaryStats struct { MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing MostActiveDay string `json:"most_active_day"` // date with the most minutes MostActiveMinutes int `json:"most_active_day_minutes"` - TopBooks []TopBookStats `json:"top_books"` - LibraryBreakdown []LibraryStats `json:"library_breakdown"` + TopBooks []TopBookStats `json:"top_books"` + LibraryBreakdown []LibraryStats `json:"library_breakdown"` + DeviceBreakdown []DeviceStats `json:"device_breakdown"` } type WeekSummaryStats struct { @@ -225,6 +238,7 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, Daily: make([]DailyStats, 0, statsRange.Days), TopBooks: []TopBookStats{}, LibraryBreakdown: []LibraryStats{}, + DeviceBreakdown: []DeviceStats{}, } // Zero-filled calendar (inclusive of today). @@ -380,6 +394,38 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, } } + // Per-device breakdown in the range (LEFT JOIN devices: NULL-device + // pushes aggregate into one unattributed row so the panel reconciles + // with the totals). + deviceRows, err := s.db.GetUserDeviceBreakdown(ctx, database.GetUserDeviceBreakdownParams{ + UserID: userID, + StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true}, + EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true}, + VisibleLibraryIds: visibleOrNil(visibleIDs), + }) + if err == nil { + for _, row := range deviceRows { + entry := DeviceStats{ + Minutes: int(row.TotalSeconds) / 60, + Pages: int(row.TotalPages), + Sessions: int(row.SessionCount), + Books: int(row.BookCount), + Name: "Web / unsynced", + Type: "unattributed", + } + if row.DeviceName.Valid && row.DeviceName.String != "" { + entry.Name = row.DeviceName.String + } + if row.DeviceType.Valid && row.DeviceType.String != "" { + entry.Type = row.DeviceType.String + } + if row.ID.Valid { + entry.DeviceID = uuid.UUID(row.ID.Bytes).String() + } + summary.DeviceBreakdown = append(summary.DeviceBreakdown, entry) + } + } + return summary, nil } diff --git a/templates/stats.templ b/templates/stats.templ index 08b65f9b..c2f5ab55 100644 --- a/templates/stats.templ +++ b/templates/stats.templ @@ -68,13 +68,20 @@ templ Stats(user User) { -
+

By Library

Loading…
+
+

By Device

+

Where the sync pushes came from — registered device names, or "Web / unsynced" when the push carried no device credential.

+
+
Loading…
+
+

Most Read

diff --git a/web/src/stats.ts b/web/src/stats.ts index 4a568d74..43c1c798 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -39,6 +39,16 @@ interface LibraryRow { books: number; } +interface DeviceRow { + device_id: string; + name: string; + type: string; + minutes: number; + pages: number; + sessions: number; + books: number; +} + interface Summary { range: string; days: number; @@ -58,6 +68,7 @@ interface Summary { most_active_day_minutes: number; top_books: TopBook[]; library_breakdown: LibraryRow[]; + device_breakdown?: DeviceRow[]; } interface ChartLike { @@ -170,6 +181,7 @@ function renderSummary(summary: Summary): void { void renderDailyCharts(summary.daily); renderLibraries(summary.library_breakdown); + renderDevices(summary.device_breakdown || []); renderTopBooks(summary.top_books); } @@ -231,23 +243,46 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise { function renderLibraries(rows: LibraryRow[]): void { const container = document.getElementById("stats-library-breakdown"); if (!container) return; + renderBars(container, rows.map((r) => ({ + label: r.name, + sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, + minutes: r.minutes, + }))); +} + +function renderDevices(rows: DeviceRow[]): void { + const container = document.getElementById("stats-device-breakdown"); + if (!container) return; + renderBars(container, rows.map((r) => ({ + label: r.name || "Web / unsynced", + sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, + minutes: r.minutes, + }))); +} + +function fmtMinutes(minutes: number): string { + return minutes >= 60 + ? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${(minutes % 60)}m` : ""}` + : `${minutes} min`; +} + +function renderBars( + container: HTMLElement, + rows: Array<{ label: string; sub: string; minutes: number }>, +): void { if (rows.length === 0) { - container.innerHTML = `

No library activity in this range.

`; + container.innerHTML = `

Nothing recorded in this range yet.

`; return; } const max = Math.max(...rows.map((r) => r.minutes), 1); container.innerHTML = rows .map((row) => { const pct = Math.round((row.minutes / max) * 100); - const time = - row.minutes >= 60 - ? `${Math.floor(row.minutes / 60)}h${row.minutes % 60 ? ` ${(row.minutes % 60)}m` : ""}` - : `${row.minutes} min`; return `
- ${escapeHtml(row.name)} - ${time} · ${row.sessions} sessions + ${escapeHtml(row.label)} + ${row.sub}