From 7828371c9cb0770c99fc880bceda361de1274ec3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 00:25:15 -0500 Subject: [PATCH] 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. --- internal/handlers/progress.go | 136 ++++++++++++++++++++ templates/progress.templ | 111 ++++++++++++++++ templates/progress_templ.go | 233 ++++++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 templates/progress.templ create mode 100644 templates/progress_templ.go diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index 3d9dfe7..a9b4c00 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -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 +} + diff --git a/templates/progress.templ b/templates/progress.templ new file mode 100644 index 0000000..999cdda --- /dev/null +++ b/templates/progress.templ @@ -0,0 +1,111 @@ +package templates + +templ Progress(user User, progressData []ProgressItemData) { + + + + + + Reading Progress - Bookmann + + + + + + + @Header(user, "/progress") + +
+
+

Reading Progress

+

Track your reading progress across all devices

+
+ +
+ if len(progressData) == 0 { +
+
📖
+

No Progress Yet

+

Start reading a book to track your progress

+
+ } + + for _, item := range progressData { +
+
+
+ Cover +
+
+
+
+

{ item.Title }

+ if item.Author != "" { +

by { item.Author }

+ } +
+
+ { item.ProgressPercentage }% +
+
+ +
+
+
+
+
+ +
+
+

Current Position

+

+ { item.CurrentPage } / { item.TotalPages } +

+
+
+

Last Updated

+

{ item.LastUpdated }

+
+
+

Synced From

+
+ { item.DeviceIcon } + { item.DeviceName } +
+
+
+ + if item.DeviceIcon != "" { +
+
+ 🔄 + Last sync from { item.DeviceName } + ({ item.DeviceType }) +
+
+ } + + if item.EpubCFI != "" { +
+ 📍 + CFI: { item.EpubCFI } +
+ } +
+
+
+ } +
+
+ + + + +} diff --git a/templates/progress_templ.go b/templates/progress_templ.go new file mode 100644 index 0000000..b2d73df --- /dev/null +++ b/templates/progress_templ.go @@ -0,0 +1,233 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Progress(user User, progressData []ProgressItemData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Reading Progress - Bookmann") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/progress").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Reading Progress

Track your reading progress across all devices

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(progressData) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
📖

No Progress Yet

Start reading a book to track your progress

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, item := range progressData { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
\"Cover\"

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 44, Col: 121} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.Author != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

by ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 46, Col: 116} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.ProgressPercentage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 50, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "%

Current Position

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.CurrentPage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 64, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.TotalPages) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 64, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Last Updated

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 69, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

Synced From

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 74, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 75, Col: 102} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.DeviceIcon != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
🔄 Last sync from ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 84, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 85, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if item.EpubCFI != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
📍 CFI: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 93, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate