package server import ( "fmt" "net/http" "net/url" "strconv" ) // operatorListPageSize is the fixed page size for every governed operator // list (operator-list-scale: "Governed lists paginate with a true total // count"). One constant so no list can quietly diverge. const operatorListPageSize = 50 // ListParams is the parsed URL state of a governed operator list: the // search term (`q`), one capability-appropriate facet value, and the // 1-based page. It uses exactly the query-parameter shape // operator-panel-navigation reserved ("Browse routes reserve // query-parameter real estate for filters"), so list state is // URL-addressable and a copied URL reproduces the view. type ListParams struct { Q string Facet string Page int // PerPage is the page size; 0 means operatorListPageSize. Embedded // lists default lower (the composite's ledger and Tier changes use 10) // and let the operator pick from perPageOptions via ParsePerPage. PerPage int } // ParseListParams reads q, the named facet parameter, and page from the // request. A malformed or non-positive page clamps to 1 (the // "malformed page values clamp" scenario); values the list does not // implement are the caller's to ignore. facetParam may be "" for lists // with no filter. func ParseListParams(r *http.Request, facetParam string) ListParams { return ParseListParamsNS(r, "", facetParam) } // ParseListParamsNS is ParseListParams with a parameter-name prefix, for // governed lists embedded in a page that hosts more than one (each list // reads and writes q / page so siblings cannot collide). // facetParam is always the full parameter name. func ParseListParamsNS(r *http.Request, prefix, facetParam string) ListParams { p := ListParams{Q: r.URL.Query().Get(prefix + "q"), Page: 1} if facetParam != "" { p.Facet = r.URL.Query().Get(facetParam) } if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "page")); err == nil && n > 1 { p.Page = n } return p } // Limit and Offset are the SQL window for the current page. func (p ListParams) Limit() int32 { if p.PerPage > 0 { return int32(p.PerPage) } return int32(operatorListPageSize) } func (p ListParams) Offset() int32 { return int32(p.Page-1) * p.Limit() } // perPageOptions is the page-size set an operator can pick from on lists // that offer the picker (embedded lists; the composite's ledger and Tier // changes default to embeddedListDefaultPerPage, the smallest). var perPageOptions = []int{10, 25, 50} // embeddedListDefaultPerPage keeps embedded lists short by default // (maintainer 2026-08-24); the picker offers the larger sizes. const embeddedListDefaultPerPage = 10 // ParsePerPage reads per from the request, returning def unless // the value is one of perPageOptions — an unknown size is ignored, like // an unknown facet. func ParsePerPage(r *http.Request, prefix string, def int) int { if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "per")); err == nil { for _, o := range perPageOptions { if n == o { return n } } } return def } // FetchPage runs load for the params' page and clamps back to page 1 when // the requested page is past the end (an offset beyond the last row // returns zero rows, and with them zero knowledge of the total — the // re-run answers with a real page instead of an empty table). load // returns the page's rows plus the true total for the whole filtered set // (`count(*) OVER()` in the paged queries, design D2). func FetchPage[R any](p *ListParams, load func(limit, offset int32) ([]R, int64, error)) ([]R, int64, error) { rows, total, err := load(p.Limit(), p.Offset()) if err != nil { return nil, 0, err } if len(rows) == 0 && p.Page > 1 { p.Page = 1 rows, total, err = load(p.Limit(), 0) } return rows, total, err } // FacetOption is one value of a list's status filter. type FacetOption struct { Value string Label string } // ListNav is the view model for the shared list-controls partial // (operator_list_controls.html: "listControls", "listPager", // "listNoMatch"). Handlers fill the static shape (BasePath, placeholder, // facet vocabulary) and the current state (Q/Facet/Page/Total); the // methods derive everything the template renders, including URLs that // preserve the rest of the state per the URL-addressable requirement. type ListNav struct { BasePath string SearchPlaceholder string FacetParam string FacetOptions []FacetOption Q string Facet string Page int Total int64 // ParamPrefix namespaces this list's q/page parameter names when the // hosting page embeds more than one governed list; "" on standalone // pages. FacetParam is always the full name. ParamPrefix string // Extra is carried verbatim into every URL this nav builds (and the // search form's hidden inputs), so an embedded list's links preserve // its sibling lists' state on the shared URL. Extra url.Values // PerPage is the effective page size (0 → operatorListPageSize); // DefaultPerPage is the list's default (the per parameter is // omitted at that value so canonical views keep canonical URLs); a // non-empty PerPageOptions makes listPager render the size picker. PerPage int DefaultPerPage int PerPageOptions []int // Target is the CSS selector of the panel wrapping an EMBEDDED list. // When set, every control the shared defines emit (tab, pager, // page-size and clear links, and the search form) issues a scoped // htmx request — hx-get with hx-target/hx-select on this selector, // outerHTML swap, URL pushed — so only the panel re-renders and the // operator's scroll position is untouched (maintainer 2026-08-24: // interacting with an embedded box must not jump to the top of the // page). Plain hrefs remain as the no-JS fallback. Empty on // standalone pages, whose controls stay ordinary boosted navigations. Target string // SyncSelect is the hx-select-oob value naming the SIBLING panels to // refresh out-of-band from the same response. Without it a sibling's // links go stale: they carry the URL state of their own last render, // so after this list changes state (say the ledger switches to // History) the sibling's pager would push a URL missing that state. // Refreshing every embedded panel on every interaction keeps all // their links carrying the full current URL state. SyncSelect string } // QParam, PageParam, and PerParam are the list's parameter names, prefix // applied; the search form's input names must match what // ParseListParamsNS / ParsePerPage read. func (n ListNav) QParam() string { return n.ParamPrefix + "q" } func (n ListNav) PageParam() string { return n.ParamPrefix + "page" } func (n ListNav) PerParam() string { return n.ParamPrefix + "per" } // EffectivePerPage is the page size all derived math uses. func (n ListNav) EffectivePerPage() int { if n.PerPage > 0 { return n.PerPage } return operatorListPageSize } // PerPageActive reports whether v is the current page size (for the // picker's non-link rendering of the active option). func (n ListNav) PerPageActive(v int) bool { return n.EffectivePerPage() == v } // PerPageOffDefault reports whether the current size must ride along as a // hidden input on the search form (a GET submit replaces the query // string). func (n ListNav) PerPageOffDefault() bool { def := n.DefaultPerPage if def == 0 { def = operatorListPageSize } return n.EffectivePerPage() != def } // PageSize is the current page size, exposed for templates and tests. func (n ListNav) PageSize() int { return n.EffectivePerPage() } // Pages is the number of pages for the current filtered total (at least 1). func (n ListNav) Pages() int { size := int64(n.EffectivePerPage()) pages := int((n.Total + size - 1) / size) if pages < 1 { pages = 1 } return pages } // From and To are the 1-based display range of the current page // ("Showing From–To of Total"); both 0 when the list is empty. func (n ListNav) From() int { if n.Total == 0 { return 0 } return (n.Page-1)*n.EffectivePerPage() + 1 } func (n ListNav) To() int { to := n.Page * n.EffectivePerPage() if int64(to) > n.Total { to = int(n.Total) } return to } // Filtered reports whether any narrowing state is active — it decides // no-match (search/filter matched nothing) versus the list's true-empty // state, which empty-state-guidance owns. func (n ListNav) Filtered() bool { return n.Q != "" || n.Facet != "" } func (n ListNav) HasPrev() bool { return n.Page > 1 } func (n ListNav) HasNext() bool { return n.Page < n.Pages() } // ownParam reports whether k is one of this list's own parameter names. func (n ListNav) ownParam(k string) bool { return k == n.QParam() || k == n.PageParam() || k == n.PerParam() || (n.FacetParam != "" && k == n.FacetParam) } // url assembles BasePath?q=...&=...&page=N& // per=S, omitting empty and default values so canonical views // have canonical URLs. Extra params ride along unchanged (own keys // excluded, so a stale caller-supplied copy of this list's state cannot // shadow it). func (n ListNav) url(q, facet string, page, per int) string { v := url.Values{} for k, vals := range n.Extra { if n.ownParam(k) { continue } v[k] = vals } if q != "" { v.Set(n.QParam(), q) } if facet != "" && n.FacetParam != "" { v.Set(n.FacetParam, facet) } if page > 1 { v.Set(n.PageParam(), strconv.Itoa(page)) } def := n.DefaultPerPage if def == 0 { def = operatorListPageSize } if per > 0 && per != def { v.Set(n.PerParam(), strconv.Itoa(per)) } if len(v) == 0 { return n.BasePath } return n.BasePath + "?" + v.Encode() } // ExtraInputs is Extra minus this list's own keys, for the search form's // hidden inputs (a GET form replaces the whole query string, so sibling // state must be re-submitted explicitly). func (n ListNav) ExtraInputs() url.Values { v := url.Values{} for k, vals := range n.Extra { if n.ownParam(k) { continue } v[k] = vals } return v } func (n ListNav) PrevURL() string { return n.url(n.Q, n.Facet, n.Page-1, n.PerPage) } func (n ListNav) NextURL() string { return n.url(n.Q, n.Facet, n.Page+1, n.PerPage) } // FacetURL switches to the given facet value (empty clears the filter), // preserving the search and page size and resetting to page 1. func (n ListNav) FacetURL(value string) string { return n.url(n.Q, value, 1, n.PerPage) } // ClearSearchURL drops the search term, preserving the facet and size. func (n ListNav) ClearSearchURL() string { return n.url("", n.Facet, 1, n.PerPage) } // PerPageURL switches the page size, preserving search and facet and // resetting to page 1 (a size change re-windows the whole set). func (n ListNav) PerPageURL(v int) string { return n.url(n.Q, n.Facet, 1, v) } // FacetActive reports whether value is the active filter. func (n ListNav) FacetActive(value string) bool { return n.Facet == value } // ValidFacet returns facet unchanged when it is one of the allowed // values, "" otherwise — a reserved-but-unimplemented or mistyped value // is ignored rather than rejected (operator-panel-navigation: "Unknown // filter params do not error"). func ValidFacet(facet string, options []FacetOption) string { for _, o := range options { if o.Value == facet { return facet } } return "" } // Showing is the "Showing X–Y of N" line, empty when the list is empty // (the no-match or empty state speaks instead). func (n ListNav) Showing() string { if n.Total == 0 { return "" } return fmt.Sprintf("Showing %d–%d of %d", n.From(), n.To(), n.Total) }