# Paging Feature Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add offset-based pagination to the admin post list and the generated static site (posts and category listings). **Architecture:** Add a `posts_per_page` column to the `sites` table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing `db.PagingParams` and `LIMIT/OFFSET` SQL infrastructure is reused. A shared `models.PageInfo` type carries pagination state to templates. **Tech Stack:** Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap --- ### Task 1: Add `posts_per_page` column and regenerate sqlc **Files:** - Create: `sql/schema/05_posts_per_page.up.sql` - Modify: `sql/queries/sites.sql:10-19` (InsertSite query) - Modify: `sql/queries/sites.sql:24-25` (UpdateSite query) - Regenerate: `providers/db/gen/sqlgen/` (sqlc output) - [ ] **Step 1: Create migration file** Create `sql/schema/05_posts_per_page.up.sql`: ```sql ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; ``` - [ ] **Step 2: Update the InsertSite SQL query** In `sql/queries/sites.sql`, update the InsertSite query (lines 10-19) to include `posts_per_page`: ```sql -- name: InsertSite :one INSERT INTO sites ( owner_id, guid, title, tagline, timezone, posts_per_page, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; ``` - [ ] **Step 3: Update the UpdateSite SQL query** In `sql/queries/sites.sql`, update line 24-25: ```sql -- name: UpdateSite :exec UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; ``` - [ ] **Step 4: Regenerate sqlc** Run: `sqlc generate` Expected: `providers/db/gen/sqlgen/` files updated with new `PostsPerPage` field on `Site` struct, updated `InsertSiteParams` and `UpdateSiteParams`. - [ ] **Step 5: Run tests to verify nothing broke** Run: `go test ./...` Expected: All existing tests pass. - [ ] **Step 6: Commit** ```bash git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/ git commit -m "feat: add posts_per_page column to sites table" ``` --- ### Task 2: Update Site model and DB provider for `PostsPerPage` **Files:** - Modify: `models/sites.go:24-33` (Site struct) - Modify: `providers/db/sites.go:42-65` (SaveSite) - Modify: `providers/db/sites.go:102-112` (dbSiteToSite) - [ ] **Step 1: Add `PostsPerPage` to `models.Site`** In `models/sites.go`, add to the `Site` struct (after `Timezone`): ```go PostsPerPage int ``` - [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`** In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field: ```go func dbSiteToSite(row sqlgen.Site) models.Site { return models.Site{ ID: row.ID, OwnerID: row.OwnerID, GUID: row.Guid, Title: row.Title, Timezone: row.Timezone, Tagline: row.Tagline, PostsPerPage: int(row.PostsPerPage), Created: time.Unix(row.CreatedAt, 0).UTC(), } } ``` - [ ] **Step 3: Update `SaveSite` to include `PostsPerPage`** In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`: ```go newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ OwnerID: site.OwnerID, Guid: site.GUID, Title: site.Title, Tagline: site.Tagline, Timezone: site.Timezone, PostsPerPage: int64(site.PostsPerPage), CreatedAt: timeToInt(site.Created), }) ``` Update the `UpdateSite` call (line 59) to include `PostsPerPage`: ```go return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ Title: site.Title, Tagline: site.Tagline, Timezone: site.Timezone, PostsPerPage: int64(site.PostsPerPage), ID: site.ID, }) ``` - [ ] **Step 4: Run tests** Run: `go test ./...` Expected: All tests pass. - [ ] **Step 5: Commit** ```bash git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/ git commit -m "feat: add PostsPerPage to Site model and DB provider" ``` --- ### Task 3: Add `CountPostsOfSite` SQL query and DB method **Files:** - Modify: `sql/queries/posts.sql` (add count query) - Modify: `providers/db/posts.go` (add CountPostsOfSite method) - Modify: `providers/db/provider_test.go` (add test) - Regenerate: `providers/db/gen/sqlgen/` - [ ] **Step 1: Write the failing test** Add to `providers/db/provider_test.go` inside `TestProvider_Posts`: ```go t.Run("count posts of site", func(t *testing.T) { countSite := &models.Site{ OwnerID: user.ID, GUID: models.NewNanoID(), Title: "Count Blog", } require.NoError(t, p.SaveSite(ctx, countSite)) now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) for i := 0; i < 3; i++ { post := &models.Post{ SiteID: countSite.ID, GUID: models.NewNanoID(), Title: fmt.Sprintf("Post %d", i), Body: "body", Slug: fmt.Sprintf("/post-%d", i), CreatedAt: now, } require.NoError(t, p.SavePost(ctx, post)) } count, err := p.CountPostsOfSite(ctx, countSite.ID, false) require.NoError(t, err) assert.Equal(t, int64(3), count) // Soft-delete one post posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) count, err = p.CountPostsOfSite(ctx, countSite.ID, false) require.NoError(t, err) assert.Equal(t, int64(2), count) count, err = p.CountPostsOfSite(ctx, countSite.ID, true) require.NoError(t, err) assert.Equal(t, int64(1), count) }) ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` Expected: FAIL — `CountPostsOfSite` method does not exist. - [ ] **Step 3: Add SQL query** Add to `sql/queries/posts.sql`: ```sql -- name: CountPostsOfSite :one SELECT COUNT(*) FROM posts WHERE site_id = sqlc.arg(site_id) AND ( CASE CAST (sqlc.arg(post_filter) AS TEXT) WHEN 'deleted' THEN deleted_at > 0 ELSE deleted_at = 0 END ); ``` Run: `sqlc generate` - [ ] **Step 4: Add DB provider method** Add to `providers/db/posts.go`: ```go func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { filter := "active" if showDeleted { filter = "deleted" } return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ SiteID: siteID, PostFilter: filter, }) } ``` Note: check the generated `sqlgen.CountPostsOfSiteParams` struct name and fields after `sqlc generate` — adjust if the field names differ. - [ ] **Step 5: Run test to verify it passes** Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` Expected: PASS - [ ] **Step 6: Run all tests** Run: `go test ./...` Expected: All pass. - [ ] **Step 7: Commit** ```bash git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/ git commit -m "feat: add CountPostsOfSite query and DB method" ``` --- ### Task 4: Add `models.PageInfo` type **Files:** - Create: `models/paging.go` - [ ] **Step 1: Create `models/paging.go`** ```go package models // PageInfo carries pagination state for templates. type PageInfo struct { CurrentPage int TotalPages int PostsPerPage int } // HasPrevious returns true if there is a previous page. func (p PageInfo) HasPrevious() bool { return p.CurrentPage > 1 } // HasNext returns true if there is a next page. func (p PageInfo) HasNext() bool { return p.CurrentPage < p.TotalPages } // PreviousPage returns the previous page number. func (p PageInfo) PreviousPage() int { return p.CurrentPage - 1 } // NextPage returns the next page number. func (p PageInfo) NextPage() int { return p.CurrentPage + 1 } ``` - [ ] **Step 2: Run tests** Run: `go test ./...` Expected: All pass (no tests yet for this type, but it should compile). - [ ] **Step 3: Commit** ```bash git add models/paging.go git commit -m "feat: add PageInfo model for pagination" ``` --- ### Task 5: Add pagination to admin post list (service + handler) **Files:** - Modify: `services/posts/list.go:15-38` (ListPosts signature and implementation) - Modify: `handlers/posts.go:18-39` (Index handler) - [ ] **Step 1: Update `ListPosts` to accept paging params and return count** Replace `services/posts/list.go` `ListPosts` method: ```go type ListPostsResult struct { Posts []*PostWithCategories TotalCount int64 } func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { site, ok := models.GetSite(ctx) if !ok { return ListPostsResult{}, models.SiteRequiredError } posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) if err != nil { return ListPostsResult{}, err } count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) if err != nil { return ListPostsResult{}, err } result := make([]*PostWithCategories, len(posts)) for i, post := range posts { cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) if err != nil { return ListPostsResult{}, err } result[i] = &PostWithCategories{Post: post, Categories: cats} } return ListPostsResult{Posts: result, TotalCount: count}, nil } ``` - [ ] **Step 2: Update the admin handler** Replace `handlers/posts.go` `Index` method: ```go func (ph PostsHandler) Index(c fiber.Ctx) error { var req struct { Filter string `query:"filter"` Page int `query:"page"` } if err := c.Bind().Query(&req); err != nil { return fiber.ErrBadRequest } const perPage = 25 if req.Page < 1 { req.Page = 1 } result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ Offset: int64((req.Page - 1) * perPage), Limit: perPage, }) if err != nil { return err } totalPages := int(result.TotalCount+int64(perPage)-1) / perPage if totalPages < 1 { totalPages = 1 } pageInfo := models.PageInfo{ CurrentPage: req.Page, TotalPages: totalPages, PostsPerPage: perPage, } return accepts(c, json(func() any { return result.Posts }), html(func(c fiber.Ctx) error { return c.Render("posts/index", fiber.Map{ "req": req, "posts": result.Posts, "pageInfo": pageInfo, }) })) } ``` Note: add `"lmika.dev/lmika/weiro/providers/db"` and `"lmika.dev/lmika/weiro/models"` to imports in `handlers/posts.go`. - [ ] **Step 3: Verify it compiles** Run: `go build ./...` Expected: Compiles successfully. - [ ] **Step 4: Run tests** Run: `go test ./...` Expected: All pass. - [ ] **Step 5: Commit** ```bash git add services/posts/list.go handlers/posts.go git commit -m "feat: add pagination to admin post list handler and service" ``` --- ### Task 6: Add pagination UI to admin post list template **Files:** - Modify: `views/posts/index.html` - [ ] **Step 1: Add pagination controls to admin template** Add pagination controls after the post list in `views/posts/index.html`. Insert before the closing `` tag: ```html {{ if gt .pageInfo.TotalPages 1 }} {{ end }} ``` - [ ] **Step 2: Add `Pages` method to `PageInfo`** Add to `models/paging.go`: ```go // Pages returns a slice of page numbers for rendering numbered pagination. func (p PageInfo) Pages() []int { pages := make([]int, p.TotalPages) for i := range pages { pages[i] = i + 1 } return pages } ``` - [ ] **Step 3: Verify it compiles and test manually** Run: `go build ./...` Expected: Compiles. - [ ] **Step 4: Commit** ```bash git add views/posts/index.html models/paging.go git commit -m "feat: add pagination controls to admin post list" ``` --- ### Task 7: Add site settings form for `PostsPerPage` **Files:** - Modify: `views/sitesettings/general.html:17-48` (form) - Modify: `services/sites/services.go:131-158` (UpdateSiteSettingsParams and UpdateSiteSettings) - [ ] **Step 1: Add `PostsPerPage` to `UpdateSiteSettingsParams`** In `services/sites/services.go`, update the struct (line 131): ```go type UpdateSiteSettingsParams struct { SiteID int64 `form:"siteID"` Name string `form:"name"` Tagline string `form:"tagline"` Timezone string `form:"timezone"` PostsPerPage int `form:"postsPerPage"` } ``` - [ ] **Step 2: Update `UpdateSiteSettings` to handle `PostsPerPage`** In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field: ```go func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { site, err := s.GetSiteByID(ctx, params.SiteID) if err != nil { return models.Site{}, err } _, err = time.LoadLocation(params.Timezone) if err != nil { return models.Site{}, errors.Wrap(err, "invalid timezone") } postsPerPage := params.PostsPerPage if postsPerPage < 1 { postsPerPage = 1 } else if postsPerPage > 100 { postsPerPage = 100 } site.Title = params.Name site.Tagline = params.Tagline site.Timezone = params.Timezone site.PostsPerPage = postsPerPage if err := s.db.SaveSite(ctx, &site); err != nil { return models.Site{}, err } return site, nil } ``` - [ ] **Step 3: Add form field to settings template** In `views/sitesettings/general.html`, add after the Timezone field (after line 43, before the submit button row): ```html