feat(stats): per-device reading breakdown + device attribution on progress syncs

- 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.
This commit is contained in:
John O'Keefe committed 2026-10-14 13:04:34 -04:00
1 parent be10adc24b
commit 7a604c2fec
8 files changed
+399 -20

No files matched your search

+172 -6
View File
@@ -108,7 +108,115 @@ func TestReadingStatsCapture(t *testing.T) {
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position") 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) { func TestReadingStatsSummary(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
userID := getTestUserID(t, setup.DB) userID := getTestUserID(t, setup.DB)
ctx := context.Context(context.Background()) ctx := context.Context(context.Background())
@@ -117,7 +225,24 @@ func TestReadingStatsSummary(t *testing.T) {
libUUID := uuid.MustParse(libIDStr) libUUID := uuid.MustParse(libIDStr)
var finishedBookID, offRangeBookID string 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 media database.MediaItems
var err error var err error
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" { if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
@@ -134,10 +259,16 @@ func TestReadingStatsSummary(t *testing.T) {
require.NoError(t, err, "seed media item") require.NoError(t, err, "seed media item")
} }
deviceIDArg := pgtype.UUID{}
if attributed {
deviceIDArg = deviceID
}
pushAt := middayDaysAgo(daysAgo) pushAt := middayDaysAgo(daysAgo)
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ _, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: pgUser(userID), UserID: pgUser(userID),
MediaItemID: media.ID, MediaItemID: media.ID,
DeviceID: deviceIDArg,
ProgressPercentage: pgFloat(pct), ProgressPercentage: pgFloat(pct),
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)), ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
ReadingSessionEnd: pgTS(pushAt), ReadingSessionEnd: pgTS(pushAt),
@@ -151,11 +282,12 @@ func TestReadingStatsSummary(t *testing.T) {
return id.String() return id.String()
} }
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book") // Device-split scenario: attributed (80 min) + unattributed (75 min).
insertSession(1, 45, 20, 0.5, "Stats Streak Book") finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false)
insertSession(2, 60, 25, 0.42, "Stats Back Book") insertSession(1, 45, 20, 0.5, "Stats Streak Book", false)
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID) insertSession(2, 60, 25, 0.42, "Stats Back Book", true)
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book") 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 ----------------------------------------------------------- // Summary -----------------------------------------------------------
resp, body := statsLogin(t, setup, "/api/stats/summary?range=7") resp, body := statsLogin(t, setup, "/api/stats/summary?range=7")
@@ -187,6 +319,13 @@ func TestReadingStatsSummary(t *testing.T) {
Name string `json:"name"` Name string `json:"name"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
} `json:"library_breakdown"` } `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)) 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") 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 --------------------------------------------------- // Range validation ---------------------------------------------------
badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight") badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight")
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode) assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)
+4
View File
@@ -359,6 +359,10 @@ type Querier interface {
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, 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 // Get user device usage statistics
GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error) GetUserDeviceUsage(ctx context.Context, userID pgtype.UUID) ([]GetUserDeviceUsageRow, error)
GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error) GetUserForLogin(ctx context.Context, email string) (GetUserForLoginRow, error)
+73
View File
@@ -8264,6 +8264,79 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
return items, nil 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 const GetUserDeviceUsage = `-- name: GetUserDeviceUsage :many
SELECT SELECT
d.id, d.id,
+23
View File
@@ -1563,6 +1563,29 @@ SELECT EXISTS(
WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id') WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id')
) AS has_device; ) 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 -- DEVICE MANAGEMENT & AUTH
+29 -4
View File
@@ -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"}) 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 { var req struct {
CurrentPage *int32 `json:"current_page"` CurrentPage *int32 `json:"current_page"`
TotalPages *int32 `json:"total_pages"` TotalPages *int32 `json:"total_pages"`
@@ -1130,8 +1155,10 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
saveReq := wsync.SaveProgressRequest{ saveReq := wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Source: "web", Source: deviceSource,
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true}, DeviceID: deviceID,
DeviceType: deviceType,
DeviceName: deviceLabel,
Percentage: req.Percentage, Percentage: req.Percentage,
Epubcfi: req.Epubcfi, Epubcfi: req.Epubcfi,
ContextText: req.ContextText, ContextText: req.ContextText,
@@ -1144,8 +1171,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
ScrollX: req.ScrollX, ScrollX: req.ScrollX,
ScrollY: req.ScrollY, ScrollY: req.ScrollY,
ReadingMode: req.ReadingMode, ReadingMode: req.ReadingMode,
DeviceType: "web",
DeviceName: "Web",
Broadcast: true, Broadcast: true,
} }
if req.CurrentPage != nil { if req.CurrentPage != nil {
+48 -2
View File
@@ -114,6 +114,18 @@ type LibraryStats struct {
Books int `json:"books"` 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 { type UserSummaryStats struct {
Range string `json:"range"` Range string `json:"range"`
Days int `json:"days"` Days int `json:"days"`
@@ -131,8 +143,9 @@ type UserSummaryStats struct {
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
MostActiveDay string `json:"most_active_day"` // date with the most minutes MostActiveDay string `json:"most_active_day"` // date with the most minutes
MostActiveMinutes int `json:"most_active_day_minutes"` MostActiveMinutes int `json:"most_active_day_minutes"`
TopBooks []TopBookStats `json:"top_books"` TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"` LibraryBreakdown []LibraryStats `json:"library_breakdown"`
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
} }
type WeekSummaryStats struct { type WeekSummaryStats struct {
@@ -225,6 +238,7 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
Daily: make([]DailyStats, 0, statsRange.Days), Daily: make([]DailyStats, 0, statsRange.Days),
TopBooks: []TopBookStats{}, TopBooks: []TopBookStats{},
LibraryBreakdown: []LibraryStats{}, LibraryBreakdown: []LibraryStats{},
DeviceBreakdown: []DeviceStats{},
} }
// Zero-filled calendar (inclusive of today). // 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 return summary, nil
} }
+8 -1
View File
@@ -68,13 +68,20 @@ templ Stats(user User) {
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div class="card p-6"> <div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3> <h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
<div id="stats-library-breakdown" class="space-y-3"> <div id="stats-library-breakdown" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div> <div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div> </div>
</div> </div>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Device</h3>
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, or "Web / unsynced" when the push carried no device credential.</p>
<div id="stats-device-breakdown" class="space-y-3">
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
</div>
</div>
<div class="card p-6"> <div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3> <h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
<div id="stats-top-books" class="space-y-3"> <div id="stats-top-books" class="space-y-3">
+42 -7
View File
@@ -39,6 +39,16 @@ interface LibraryRow {
books: number; books: number;
} }
interface DeviceRow {
device_id: string;
name: string;
type: string;
minutes: number;
pages: number;
sessions: number;
books: number;
}
interface Summary { interface Summary {
range: string; range: string;
days: number; days: number;
@@ -58,6 +68,7 @@ interface Summary {
most_active_day_minutes: number; most_active_day_minutes: number;
top_books: TopBook[]; top_books: TopBook[];
library_breakdown: LibraryRow[]; library_breakdown: LibraryRow[];
device_breakdown?: DeviceRow[];
} }
interface ChartLike { interface ChartLike {
@@ -170,6 +181,7 @@ function renderSummary(summary: Summary): void {
void renderDailyCharts(summary.daily); void renderDailyCharts(summary.daily);
renderLibraries(summary.library_breakdown); renderLibraries(summary.library_breakdown);
renderDevices(summary.device_breakdown || []);
renderTopBooks(summary.top_books); renderTopBooks(summary.top_books);
} }
@@ -231,23 +243,46 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
function renderLibraries(rows: LibraryRow[]): void { function renderLibraries(rows: LibraryRow[]): void {
const container = document.getElementById("stats-library-breakdown"); const container = document.getElementById("stats-library-breakdown");
if (!container) return; 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) { if (rows.length === 0) {
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No library activity in this range.</p>`; container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing recorded in this range yet.</p>`;
return; return;
} }
const max = Math.max(...rows.map((r) => r.minutes), 1); const max = Math.max(...rows.map((r) => r.minutes), 1);
container.innerHTML = rows container.innerHTML = rows
.map((row) => { .map((row) => {
const pct = Math.round((row.minutes / max) * 100); 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 ` return `
<div> <div>
<div class="flex justify-between text-sm mb-1"> <div class="flex justify-between text-sm mb-1">
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.name)}</span> <span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.label)}</span>
<span style="color: var(--text-secondary)">${time} · ${row.sessions} sessions</span> <span style="color: var(--text-secondary)">${row.sub}</span>
</div> </div>
<div class="h-2 rounded-full overflow-hidden" style="background-color: var(--bg-primary)"> <div class="h-2 rounded-full overflow-hidden" style="background-color: var(--bg-primary)">
<div class="h-full rounded-full" style="width: ${pct}%; background-color: var(--accent)"></div> <div class="h-full rounded-full" style="width: ${pct}%; background-color: var(--accent)"></div>