From 153eaca676be13920ea21586b6b5f2d49909ce04 Mon Sep 17 00:00:00 2001 From: juancwu Date: Mon, 9 Feb 2026 12:40:02 +0000 Subject: [PATCH] feat: paginate expenses history --- internal/handler/space.go | 41 +++-- internal/repository/expense.go | 18 ++ internal/service/expense.go | 45 +++++ internal/ui/components/expense/expense.templ | 175 ++++++++++--------- internal/ui/pages/app_space_expenses.templ | 58 +++++- 5 files changed, 237 insertions(+), 100 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 9a58f4c..10342db 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -382,7 +382,12 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) + page := 1 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page) if err != nil { slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -410,7 +415,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, listsWithItems)) + ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, listsWithItems, page, totalPages)) if r.URL.Query().Get("created") == "true" { ui.Render(w, r, toast.Toast(toast.Props{ @@ -525,7 +530,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { ItemIDs: itemIDs, } - newExpense, err := h.expenseService.CreateExpense(dto) + _, err = h.expenseService.CreateExpense(dto) if err != nil { slog.Error("failed to create expense", "error", err) http.Error(w, "Failed to create expense.", http.StatusInternalServerError) @@ -556,14 +561,23 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { return } - // Build tags for the newly created expense - tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID}) - newExpenseWithTags := &model.ExpenseWithTags{ - Expense: *newExpense, - Tags: tagsMap[newExpense.ID], + // Return the full paginated list for page 1 so the new expense appears + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, 1) + if err != nil { + slog.Error("failed to get paginated expenses after create", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } - ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance)) + ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, 1, totalPages)) + + // OOB-swap the item selector with fresh data (items may have been deleted/checked) + listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID) + if err != nil { + slog.Error("failed to refresh lists with items after create", "error", err, "space_id", spaceID) + return + } + ui.Render(w, r, expense.ItemSelectorSection(listsWithItems, true)) } func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { @@ -774,14 +788,19 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) + page := 1 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page) if err != nil { slog.Error("failed to get expenses", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses)) + ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, page, totalPages)) } func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 18588eb..eefc50a 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -17,6 +17,8 @@ type ExpenseRepository interface { Create(expense *model.Expense, tagIDs []string, itemIDs []string) error GetByID(id string) (*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error) + GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) + CountBySpaceID(spaceID string) (int, error) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) Update(expense *model.Expense, tagIDs []string) error @@ -91,6 +93,22 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro return expenses, nil } +func (r *expenseRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) { + var expenses []*model.Expense + query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;` + err := r.db.Select(&expenses, query, spaceID, limit, offset) + if err != nil { + return nil, err + } + return expenses, nil +} + +func (r *expenseRepository) CountBySpaceID(spaceID string) (int, error) { + var count int + err := r.db.Get(&count, `SELECT COUNT(*) FROM expenses WHERE space_id = $1;`, spaceID) + return count, err +} + func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { var summaries []*model.TagExpenseSummary query := ` diff --git a/internal/service/expense.go b/internal/service/expense.go index a7678d1..98ea0ae 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -30,6 +30,8 @@ type UpdateExpenseDTO struct { TagIDs []string } +const ExpensesPerPage = 25 + type ExpenseService struct { expenseRepo repository.ExpenseRepository } @@ -121,6 +123,49 @@ func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.E return result, nil } +func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTags, int, error) { + total, err := s.expenseRepo.CountBySpaceID(spaceID) + if err != nil { + return nil, 0, err + } + + totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage + if totalPages < 1 { + totalPages = 1 + } + if page < 1 { + page = 1 + } + if page > totalPages { + page = totalPages + } + + offset := (page - 1) * ExpensesPerPage + expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset) + if err != nil { + return nil, 0, err + } + + ids := make([]string, len(expenses)) + for i, e := range expenses { + ids[i] = e.ID + } + + tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids) + if err != nil { + return nil, 0, err + } + + result := make([]*model.ExpenseWithTags, len(expenses)) + for i, e := range expenses { + result[i] = &model.ExpenseWithTags{ + Expense: *e, + Tags: tagsMap[e.ID], + } + } + return result, totalPages, nil +} + func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) { return s.expenseRepo.GetByID(id) } diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 97ea942..8766938 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -35,8 +35,8 @@ func (p AddExpenseFormProps) formAttrs() templ.Attributes { } return templ.Attributes{ "hx-post": "/app/spaces/" + p.Space.ID + "/expenses", - "hx-target": "#expenses-list", - "hx-swap": "afterbegin", + "hx-target": "#expenses-list-wrapper", + "hx-swap": "innerHTML", "_": closeScript, } } @@ -143,87 +143,7 @@ templ AddExpenseForm(props AddExpenseFormProps) { }) // Shopping list items selector -
- @label.Label(label.Props{}) { - Link Shopping List Items - } - if len(props.ListsWithItems) == 0 { -

No unchecked items available.

- } else { -
- for i, lwi := range props.ListsWithItems { - {{ toggleID := "toggle-list-" + lwi.List.ID }} - {{ itemsID := "items-" + lwi.List.ID }} -
-
- @checkbox.Checkbox(checkbox.Props{ - ID: "select-all-" + lwi.List.ID, - Attributes: templ.Attributes{ - "_": "on change repeat for cb in in #" + itemsID + " set cb.checked to my.checked end", - }, - }) - -
- -
- if i < len(props.ListsWithItems) - 1 { -
- } - } -
- // Post-action radio group -
-

After linking items:

-
-
- @radio.Radio(radio.Props{ - ID: "item-action-check", - Name: "item_action", - Value: "check", - Checked: true, - }) - @label.Label(label.Props{For: "item-action-check"}) { - Mark as checked - } -
-
- @radio.Radio(radio.Props{ - ID: "item-action-delete", - Name: "item_action", - Value: "delete", - }) - @label.Label(label.Props{For: "item-action-delete"}) { - Delete from list - } -
-
-
- } -
+ @ItemSelectorSection(props.ListsWithItems, false)
@button.Button(button.Props{Type: button.TypeSubmit}) { Save @@ -332,6 +252,95 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) { } +templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) { +
+ @label.Label(label.Props{}) { + Link Shopping List Items + } + if len(listsWithItems) == 0 { +

No unchecked items available.

+ } else { +
+ for i, lwi := range listsWithItems { + {{ toggleID := "toggle-list-" + lwi.List.ID }} + {{ itemsID := "items-" + lwi.List.ID }} +
+
+ @checkbox.Checkbox(checkbox.Props{ + ID: "select-all-" + lwi.List.ID, + Attributes: templ.Attributes{ + "_": "on change repeat for cb in in #" + itemsID + " set cb.checked to my.checked end", + }, + }) + +
+ +
+ if i < len(listsWithItems) - 1 { +
+ } + } +
+ // Post-action radio group +
+

After linking items:

+
+
+ @radio.Radio(radio.Props{ + ID: "item-action-check", + Name: "item_action", + Value: "check", + Checked: true, + }) + @label.Label(label.Props{For: "item-action-check"}) { + Mark as checked + } +
+
+ @radio.Radio(radio.Props{ + ID: "item-action-delete", + Name: "item_action", + Value: "delete", + }) + @label.Label(label.Props{For: "item-action-delete"}) { + Delete from list + } +
+
+
+ } +
+} + templ BalanceCard(spaceID string, balance int, oob bool) {
@@ -44,13 +46,15 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b @expense.BalanceCard(space.ID, balance, false) // List of expenses
- @ExpensesListContent(space.ID, expenses) +
+ @ExpensesListContent(space.ID, expenses, currentPage, totalPages) +
} } -templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) { +templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags, currentPage, totalPages int) {

History

if len(expenses) == 0 { @@ -60,6 +64,48 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) { @ExpenseListItem(spaceID, exp) }
+ if totalPages > 1 { +
+ @pagination.Pagination(pagination.Props{Class: "justify-center"}) { + @pagination.Content() { + @pagination.Item() { + @pagination.Previous(pagination.PreviousProps{ + Disabled: currentPage <= 1, + Attributes: templ.Attributes{ + "hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage-1), + "hx-target": "#expenses-list-wrapper", + "hx-swap": "innerHTML", + }, + }) + } + for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages { + @pagination.Item() { + @pagination.Link(pagination.LinkProps{ + IsActive: pg == currentPage, + Attributes: templ.Attributes{ + "hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, pg), + "hx-target": "#expenses-list-wrapper", + "hx-swap": "innerHTML", + }, + }) { + { strconv.Itoa(pg) } + } + } + } + @pagination.Item() { + @pagination.Next(pagination.NextProps{ + Disabled: currentPage >= totalPages, + Attributes: templ.Attributes{ + "hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage+1), + "hx-target": "#expenses-list-wrapper", + "hx-swap": "innerHTML", + }, + }) + } + } + } +
+ } } templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) { @@ -145,9 +191,9 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
} -templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) { - @ExpenseListItem(spaceID, newExpense) - @expense.BalanceCard(newExpense.SpaceID, balance, true) +templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTags, balance int, currentPage, totalPages int) { + @ExpensesListContent(spaceID, expenses, currentPage, totalPages) + @expense.BalanceCard(spaceID, balance, true) } templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) {