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

+4
View File
@@ -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)
+73
View File
@@ -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,
+23
View File
@@ -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
+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"})
}
// 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 {
+48 -2
View File
@@ -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
}