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:
1 parent
be10adc24b
commit
7a604c2fec
8 files changed
+399
-20
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -68,13 +68,20 @@ templ Stats(user User) {
|
||||
</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">
|
||||
<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 class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</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">
|
||||
<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">
|
||||
|
||||
+42
-7
@@ -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<void> {
|
||||
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 = `<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;
|
||||
}
|
||||
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 `
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.name)}</span>
|
||||
<span style="color: var(--text-secondary)">${time} · ${row.sessions} sessions</span>
|
||||
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.label)}</span>
|
||||
<span style="color: var(--text-secondary)">${row.sub}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user