feat(progress): implement progress visualization page with sync source tracking (Phase 9-3)

Add progress list page showing reading progress across all devices:
- GetAllProgress API endpoint: Returns all user progress with media details
- GetAllProgressData helper: Fetches progress data for SSR rendering
- ProgressWithMedia struct: Combines progress with book metadata
- Progress page template: Displays progress bars, device icons, sync sources

Features:
- Visual progress bars with percentage
- Device-specific icons (Kobo, KOReader, Web, Mobile)
- Last sync timestamp and device attribution
- EPUB CFI location display
- Cover image support with fallback
- Responsive grid layout

This gives users a unified view of their reading progress across all synced devices.
This commit is contained in:
2026-02-01 00:25:15 -05:00
parent 4a19699e8f
commit 7828371c9c
3 changed files with 480 additions and 0 deletions
+136
View File
@@ -6,6 +6,7 @@ import (
"context"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -226,3 +227,138 @@ func (h *Handler) GetProgressHistory(c echo.Context) error {
"count": len(history),
})
}
type ProgressWithMedia struct {
MediaItemID uuid.UUID
Title string
Author string
CoverImagePath string
Percentage float64
CurrentPage int32
TotalPages int32
LastReadAt time.Time
Epubcfi string
LastSyncDevice string
}
// GetAllProgress retrieves all progress for a user with sync source info
func (h *Handler) GetAllProgress(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
progressList := []ProgressWithMedia{}
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 1000, Offset: 0,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media items")
}
for _, mediaItem := range mediaItems {
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: mediaItem.ID,
UserID: user.ID,
})
if err != nil {
continue
}
coverPath := ""
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
author := ""
if mediaItem.Author.Valid {
author = mediaItem.Author.String
}
epubcfi := ""
if progress.Epubcfi.Valid {
epubcfi = progress.Epubcfi.String
}
deviceName := ""
if progress.LastSyncDevice.Valid {
deviceName = progress.LastSyncDevice.String
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"progress": progressList,
"total": len(progressList),
})
}
func (h *Handler) GetAllProgressData(c echo.Context) ([]ProgressWithMedia, error) {
user := MustGetAuthenticatedUser(c)
progressList := []ProgressWithMedia{}
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return nil, err
}
for _, mediaItem := range mediaItems {
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: mediaItem.ID,
UserID: user.ID,
})
if err != nil {
continue
}
coverPath := ""
if mediaItem.CoverImagePath.Valid {
coverPath = mediaItem.CoverImagePath.String
}
author := ""
if mediaItem.Author.Valid {
author = mediaItem.Author.String
}
epubcfi := ""
if progress.Epubcfi.Valid {
epubcfi = progress.Epubcfi.String
}
deviceName := ""
if progress.LastSyncDevice.Valid {
deviceName = progress.LastSyncDevice.String
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
})
}
return progressList, nil
}