Files
budget/.plans/19-pagination.md
Spencer Twaddle 087fbdd176 Split into Budget.Core / Budget.Infrastructure / Budget.Api projects
Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps)
Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService
Budget.Api: controllers, middleware, Program.cs — references both projects

EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer,
HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated
to copy all three project directories before publishing. Migration namespaces
updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model
type strings updated to Budget.Core.Models.* in the snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:30:31 -05:00

5.4 KiB

Plan: Add PagedResult Pagination

Goal

Make all list endpoints return PagedResult<T> with page / pageSize query params, bounded by a config-driven max, matching the stwaddle stack API convention. Update the frontend to consume paginated responses.

Prerequisites:

  • Plan #10 (project split) — DTOs and request types will live in Budget.Core.
  • Plan #12 (TanStack Query) — query hooks need to be updated to pass page params and handle paginated responses. Doing this before #12 means rewriting the hooks twice.

Scope

Paginate all collection endpoints:

  • GET /api/budgets
  • GET /api/budgets/{id}/incomes
  • GET /api/budgets/{id}/outgos
  • GET /api/budgets/{id}/shares

GET /api/budgets/{id}/summary is a single-object endpoint — not paginated. GET /api/budgets/{id}/outgos/categories and /payment-sources return small distinct-value lists — not paginated.

Steps

Phase 1 — Backend: shared types and config

If plan #10 is done, add these to Budget.Core. Otherwise add to Budget.Api/DTOs/.

  1. Add PagedResult<T> record:
    public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount)
    {
        public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
    }
    
  2. Add pagination config to appsettings.json:
    "Pagination": {
      "DefaultPageSize": 25,
      "MaxPageSize": 100
    }
    
  3. Register IOptions<PaginationOptions> in Program.cs:
    builder.Services.Configure<PaginationOptions>(builder.Configuration.GetSection("Pagination"));
    
    with a PaginationOptions record/class containing DefaultPageSize and MaxPageSize.

Phase 2 — Backend: update list endpoints

  1. Inject IOptions<PaginationOptions> into each controller that has a list endpoint, or pass it via a helper.
  2. Add int page = 1, int pageSize = 0 parameters to each list action. If pageSize is 0 or not provided, use DefaultPageSize. Clamp pageSize to MaxPageSize.
  3. Update each list query to use .Skip((page - 1) * pageSize).Take(pageSize) and return PagedResult<T>:
    var total = await query.CountAsync();
    var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
    return Ok(new PagedResult<BudgetDto>(items, page, pageSize, total));
    
  4. Affected actions: BudgetsController.List, IncomesController.List, OutgosController.List, SharesController.List.

Phase 3 — Frontend: update types

  1. Add PagedResult<T> to src/Budget.Client/src/types/index.ts:
    export interface PagedResult<T> {
      items: T[];
      page: number;
      pageSize: number;
      totalCount: number;
      totalPages: number;
    }
    

Phase 4 — Frontend: update query hooks

If plan #12 is done, update the hook files. Otherwise update the useEffect fetch calls.

  1. Update each list hook to accept a page param (default 1) and return PagedResult<T>:
    export function useBudgets(page = 1) {
      return useQuery({
        queryKey: ['budgets', page],
        queryFn: () => api.get<PagedResult<BudgetDto>>(`/api/budgets?page=${page}`),
      });
    }
    

Phase 5 — Frontend: add pagination UI

  1. Create a reusable Paginator component:
    interface PaginatorProps { page: number; totalPages: number; onPageChange: (p: number) => void; }
    
    Renders Prev / page number / Next buttons; disables Prev on page 1, Next on last page.
  2. Add const [page, setPage] = useState(1) to each list page.
  3. Wire <Paginator> below each list, passing data.totalPages and setPage.
  4. Reset to page 1 when a new item is created or deleted (in the mutation onSuccess).

Phase 6 — Build and verify

  1. dotnet build (backend) — zero errors.
  2. npm run build (frontend) — zero TypeScript errors.
  3. Verify with more than DefaultPageSize items that next/prev navigation works.

Key decisions

  • page is 1-indexed throughout (both API and UI), consistent with the stack doc.
  • pageSize can be omitted from the query string; the server uses DefaultPageSize. No client-side page size picker in this plan.
  • The Incomes and Outgos lists are ordered by SortOrder — pagination must preserve this ordering. The Skip/Take applies after the OrderBy, so this is automatic.
  • Reorder endpoints (PUT .../order) send the full ordered ID list — they are unaffected by pagination since they operate on the whole set. If lists grow very large this becomes a concern, but it is out of scope here.

Files affected

Backend:

  • New: Budget.Core/DTOs/PagedResult.cs (or Budget.Api/DTOs/)
  • New: Budget.Infrastructure/Options/PaginationOptions.cs (or Budget.Api/)
  • appsettings.json
  • Program.cs
  • Budget.Api/Controllers/BudgetsController.cs
  • Budget.Api/Controllers/IncomesController.cs
  • Budget.Api/Controllers/OutgosController.cs
  • Budget.Api/Controllers/SharesController.cs

Frontend:

  • src/Budget.Client/src/types/index.ts
  • src/Budget.Client/src/api/budgets.ts (or BudgetsPage.tsx if #12 not done)
  • src/Budget.Client/src/api/incomes.ts
  • src/Budget.Client/src/api/outgos.ts
  • src/Budget.Client/src/api/shares.ts
  • New: src/Budget.Client/src/components/Paginator.tsx
  • src/Budget.Client/src/pages/BudgetsPage.tsx
  • src/Budget.Client/src/pages/IncomePage.tsx
  • src/Budget.Client/src/pages/OutgoPage.tsx
  • src/Budget.Client/src/pages/SettingsPage.tsx