feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).
Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).
Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.
Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
This commit is contained in:
1 parent
79f4d668f1
commit
d1f9ad3b25
10 files changed
+1118
-154
No files matched your search
@@ -5266,21 +5266,59 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
|
||||
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
MAX(created_at) as last_entry_at,
|
||||
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||
MAX(rp.last_read_at) AS last_read_at
|
||||
FROM media_items
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = $1::uuid
|
||||
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
|
||||
GROUP BY library_id, series
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
LIMIT $4 OFFSET $3
|
||||
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||
AND (
|
||||
$6::text IS NULL
|
||||
OR CASE $6::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'progress_desc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'progress_asc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||
library_id, series
|
||||
LIMIT $9 OFFSET $8
|
||||
`
|
||||
|
||||
type GetDistinctSeriesParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||
Status pgtype.Text `db:"status" json:"status"`
|
||||
Sort pgtype.Text `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
@@ -5291,20 +5329,35 @@ type GetDistinctSeriesRow struct {
|
||||
BookCount int64 `db:"book_count" json:"book_count"`
|
||||
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
||||
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
|
||||
FinishedCount int64 `db:"finished_count" json:"finished_count"`
|
||||
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
|
||||
}
|
||||
|
||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// filter enforces per-user library visibility at the source.
|
||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
// MAX(created_at). Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
// keys make the sort total and deterministic.
|
||||
//
|
||||
// Series-page sort/filter contract (extensible registry in
|
||||
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||
// join can't multiply rows: reading_progress is UNIQUE per
|
||||
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series.
|
||||
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
||||
arg.ViewerUserID,
|
||||
arg.LibraryID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.SeriesQuery,
|
||||
arg.AuthorQuery,
|
||||
arg.Status,
|
||||
arg.Sort,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
@@ -5321,6 +5374,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
&i.BookCount,
|
||||
&i.TotalInSeries,
|
||||
&i.LastEntryAt,
|
||||
&i.FinishedCount,
|
||||
&i.LastReadAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5333,22 +5388,54 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
}
|
||||
|
||||
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
FROM media_items
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
SELECT COUNT(*)::int FROM (
|
||||
SELECT library_id, series
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = $1::uuid
|
||||
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
|
||||
GROUP BY library_id, series
|
||||
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||
AND (
|
||||
$6::text IS NULL
|
||||
OR CASE $6::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
) groups
|
||||
`
|
||||
|
||||
type GetDistinctSeriesCountParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||
Status pgtype.Text `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||
// HAVING so the pagination total matches what the page query returns
|
||||
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||
// membership one, so it is absent here by design).
|
||||
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
|
||||
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
|
||||
arg.ViewerUserID,
|
||||
arg.LibraryID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.SeriesQuery,
|
||||
arg.AuthorQuery,
|
||||
arg.Status,
|
||||
)
|
||||
var column_1 int32
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
|
||||
@@ -4071,32 +4071,94 @@ ORDER BY mi.created_at DESC;
|
||||
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
-- filter enforces per-user library visibility at the source.
|
||||
--
|
||||
-- Series-page sort/filter contract (extensible registry in
|
||||
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||
-- join can't multiply rows: reading_progress is UNIQUE per
|
||||
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||
-- transaction share a single NOW() timestamp, leaving many groups tied
|
||||
-- on MAX(created_at), and Postgres does not guarantee tie order between
|
||||
-- executions, so page boundaries could repeat or drop series.
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
MAX(created_at) as last_entry_at,
|
||||
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||
MAX(rp.last_read_at) AS last_read_at
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
|
||||
GROUP BY library_id, series
|
||||
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
-- transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
-- MAX(created_at). Postgres does not guarantee tie order between
|
||||
-- executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
-- keys make the sort total and deterministic.
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||
AND (
|
||||
sqlc.narg('status')::text IS NULL
|
||||
OR CASE sqlc.narg('status')::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||
library_id, series
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
FROM media_items
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != '';
|
||||
-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||
-- HAVING so the pagination total matches what the page query returns
|
||||
-- under the same filters (Sort is a page-ORDER concern, not a set
|
||||
-- membership one, so it is absent here by design).
|
||||
SELECT COUNT(*)::int FROM (
|
||||
SELECT library_id, series
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
|
||||
GROUP BY library_id, series
|
||||
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||
AND (
|
||||
sqlc.narg('status')::text IS NULL
|
||||
OR CASE sqlc.narg('status')::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
) groups;
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
|
||||
@@ -64,7 +64,19 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
||||
// Sort/filter params (same contract as the SSR /series page):
|
||||
// unknown values resolve to defaults inside the service.
|
||||
opts := services.SeriesPageOptions{
|
||||
Sort: c.QueryParam("sort"),
|
||||
Query: c.QueryParam("q"),
|
||||
Author: c.QueryParam("author"),
|
||||
Status: c.QueryParam("status"),
|
||||
}
|
||||
if u, ok := c.Get("user").(database.Users); ok {
|
||||
opts.ViewerUserID = u.ID
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
@@ -76,6 +88,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
LastEntryAt string `json:"last_entry_at"`
|
||||
ReadCount int64 `json:"read_count"`
|
||||
LastReadAt string `json:"last_read_at"`
|
||||
}
|
||||
|
||||
response := make([]SeriesResponse, 0, len(seriesList))
|
||||
@@ -87,6 +101,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
LastEntryAt: s.LastEntryAt,
|
||||
ReadCount: s.ReadCount,
|
||||
LastReadAt: s.LastReadAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +166,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
|
||||
}
|
||||
@@ -149,6 +149,25 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
offset := (page - 1) * perSeriesPage
|
||||
|
||||
// Series-page state (URL is the source of truth — plain GET form,
|
||||
// no htmx on this page). Unknown values resolve to defaults in the
|
||||
// service's registries, so hand-edited URLs stay safe.
|
||||
state := templates.SeriesPageState{
|
||||
Sort: c.QueryParam("sort"),
|
||||
Query: c.QueryParam("q"),
|
||||
Author: c.QueryParam("author"),
|
||||
Status: c.QueryParam("status"),
|
||||
}
|
||||
opts := services.SeriesPageOptions{
|
||||
Sort: state.Sort,
|
||||
Query: state.Query,
|
||||
Author: state.Author,
|
||||
Status: state.Status,
|
||||
}
|
||||
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
|
||||
opts.ViewerUserID = uuidToPGType(viewerID)
|
||||
}
|
||||
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
@@ -162,7 +181,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} else {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
@@ -183,6 +202,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
ReadCount: s.ReadCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,7 +214,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,69 @@ type SeriesInfo struct {
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
LastEntryAt string
|
||||
// Viewer-scoped progress aggregates (requesting user): books
|
||||
// finished (percentage >= 0.999) and the series' most recent read.
|
||||
ReadCount int64
|
||||
LastReadAt string
|
||||
}
|
||||
|
||||
// SeriesPageOptions carries the series page's sort/filter state. Zero
|
||||
// values mean defaults (recent, no filters). Extensible by design:
|
||||
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
|
||||
// queries.sql (GetDistinctSeries) + one <option> in templates/
|
||||
// series.templ; adding a filter = a param on this struct threaded into
|
||||
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
|
||||
// stale URL or hand-rolled API call can never reach the SQL with an
|
||||
// unhandled key.
|
||||
type SeriesPageOptions struct {
|
||||
Sort string // registry key, resolved by ResolveSeriesSort
|
||||
Query string // series-name substring
|
||||
Author string // matches when ANY book's author matches
|
||||
Status string // "" | unstarted | reading | finished
|
||||
ViewerUserID pgtype.UUID
|
||||
}
|
||||
|
||||
// seriesSortKeys is the registry of sort= values the series page
|
||||
// understands.
|
||||
var seriesSortKeys = map[string]bool{
|
||||
"recent": true, // default: newest series first
|
||||
"recent_asc": true,
|
||||
"name_asc": true,
|
||||
"name_desc": true,
|
||||
"author_asc": true,
|
||||
"author_desc": true,
|
||||
"books_asc": true,
|
||||
"books_desc": true,
|
||||
"progress_asc": true,
|
||||
"progress_desc": true,
|
||||
"last_read_asc": true,
|
||||
"last_read_desc": true,
|
||||
}
|
||||
|
||||
// seriesStatusKeys is the registry of status= values. The three states
|
||||
// are mutually exclusive and exhaustive: a series is finished when
|
||||
// every owned book is, reading when any book sits between 0 and the
|
||||
// finish threshold, unstarted when no book has progress > 0.
|
||||
var seriesStatusKeys = map[string]bool{
|
||||
"unstarted": true,
|
||||
"reading": true,
|
||||
"finished": true,
|
||||
}
|
||||
|
||||
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
|
||||
// the single source of truth backing both the SSR page and the JSON
|
||||
// API — the SQL CASE cascade must contain an arm for every key here.
|
||||
func ResolveSeriesSort(sort string) string {
|
||||
if seriesSortKeys[sort] {
|
||||
return sort
|
||||
}
|
||||
return "recent"
|
||||
}
|
||||
|
||||
// nargText makes an optional text param: empty string → SQL NULL so
|
||||
// the query's IS NULL guard skips the filter.
|
||||
func nargText(v string) pgtype.Text {
|
||||
return pgtype.Text{String: v, Valid: v != ""}
|
||||
}
|
||||
|
||||
type SeriesService struct {
|
||||
@@ -31,18 +94,34 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||
// the handler validates any explicit library_id against the visible set.
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
|
||||
sort := nargText(ResolveSeriesSort(opts.Sort))
|
||||
status := nargText("")
|
||||
if seriesStatusKeys[opts.Status] {
|
||||
status = nargText(opts.Status)
|
||||
}
|
||||
|
||||
countParams := database.GetDistinctSeriesCountParams{
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
})
|
||||
SeriesQuery: nargText(opts.Query),
|
||||
AuthorQuery: nargText(opts.Author),
|
||||
Status: status,
|
||||
}
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
SeriesQuery: countParams.SeriesQuery,
|
||||
AuthorQuery: countParams.AuthorQuery,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
})
|
||||
@@ -76,6 +155,18 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
}
|
||||
}
|
||||
|
||||
lastRead := ""
|
||||
if row.LastReadAt != nil {
|
||||
switch v := row.LastReadAt.(type) {
|
||||
case pgtype.Timestamptz:
|
||||
if v.Valid {
|
||||
lastRead = v.Time.String()
|
||||
}
|
||||
case string:
|
||||
lastRead = v
|
||||
}
|
||||
}
|
||||
|
||||
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
|
||||
totalInSeries = int(row.BookCount)
|
||||
}
|
||||
@@ -87,6 +178,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
TotalInSeries: totalInSeries,
|
||||
CoverPaths: coverPaths,
|
||||
LastEntryAt: lastEntry,
|
||||
ReadCount: row.FinishedCount,
|
||||
LastReadAt: lastRead,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user