feat(t-paliad-134): B1 surface — render concept cards beneath decision tree

Pathway B B1 mode previously rendered an empty result area on every
state — the runB1Search() output target was #fristen-search-results,
which lives inside the B2 panel. When B2 is hidden (B1 active), the
results were written into a hidden subtree and never seen.

Changes:
- TSX: add #fristen-b1-results inside #fristen-b1-panel, below the
  cascade button row.
- frontend/fristenrechner.ts: extract renderSearchResultsInto() and
  wirePillClicks(); runB1Search now writes to fristen-b1-results,
  fetches /api/.../search?browse=all when no slug is picked yet (full
  landscape on entry), and applies CSS-driven loading dim with a seq
  guard against out-of-order responses. Hoisted loadAndRenderB1() so
  showBMode("tree") can trigger the tree load on Pathway B entry
  (radio.checked = true does not fire change events).
- backend: SearchOptions.BrowseAll, allMappedConceptIDs() returning
  the union of every concept reachable from any leaf via
  paliad.event_category_concepts, lifted limit ceiling for browse
  modes (default 200, max 500). Handler exposes ?browse=all.
- CSS: shared loading-state styling for fristen-b1-results.
This commit is contained in:
m
2026-05-05 11:39:30 +02:00
parent ff36528148
commit b32cfed37d
5 changed files with 179 additions and 64 deletions

View File

@@ -22,11 +22,16 @@ import (
// from this taxonomy node and its descendants
// appear. Empty q is allowed when this is set
// (browse mode).
// browse - "all" enables v3 B1 entry mode: returns
// every concept mapped to any leaf of the
// decision tree (no narrowing, no query).
// Ignored when q is non-empty.
// forum - comma-separated v3 forum-bucket slugs
// (upc_cfi, upc_coa, de_lg, de_olg, de_bgh,
// de_bpatg, epa_grant, epa_opp, epa_appeal,
// dpma). Trigger pills bypass this filter.
// limit - max cards (default 12, max 30)
// limit - max cards (default 12, max 30; in browse
// modes default 200, max 500)
//
// Returns an empty cards array (not 400) when q is empty — that lets
// the frontend boot the search input without a server round-trip.
@@ -44,6 +49,7 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
Source: r.URL.Query().Get("source"),
EventCategorySlug: r.URL.Query().Get("event_category_slug"),
Forums: parseCSV(r.URL.Query().Get("forum")),
BrowseAll: r.URL.Query().Get("browse") == "all",
Limit: parseLimit(r.URL.Query().Get("limit")),
}
resp, err := dbSvc.deadlineSearch.Search(r.Context(), q, opts)

View File

@@ -83,6 +83,12 @@ type SearchOptions struct {
// v3 (t-paliad-133):
EventCategorySlug string // drives B1 decision-tree narrowing
Forums []string // multi-select forum buckets (UNION within)
// v3 (t-paliad-134): explicit "browse everything" mode for B1 entry,
// before the user has picked any tree node. Returns every concept
// that is mapped to any leaf via paliad.event_category_concepts —
// i.e. the full landscape of B1-reachable concepts. q must be empty
// when BrowseAll is true; ignored otherwise.
BrowseAll bool
Limit int
MaxLimit int
}
@@ -201,12 +207,24 @@ type pillRow struct {
// decision-tree cascade in Pathway B).
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
limit := opts.Limit
if limit <= 0 {
limit = 12
}
maxLimit := opts.MaxLimit
if maxLimit <= 0 {
maxLimit = 30
// Browse mode (B1: slug-driven or all-mapped) returns the entire
// reachable concept set, which exceeds the trigram-search defaults.
// Lift the ceiling so the user sees the full landscape on entry.
if opts.BrowseAll || opts.EventCategorySlug != "" {
if limit <= 0 {
limit = 200
}
if maxLimit <= 0 {
maxLimit = 500
}
} else {
if limit <= 0 {
limit = 12
}
if maxLimit <= 0 {
maxLimit = 30
}
}
if limit > maxLimit {
limit = maxLimit
@@ -219,9 +237,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
}
qNorm := normalizeQuery(q)
browseMode := qNorm == "" && opts.EventCategorySlug != ""
browseMode := qNorm == "" && (opts.EventCategorySlug != "" || opts.BrowseAll)
// v3: resolve the event-category slug to a concept_id allow-list.
// When BrowseAll is set without a slug, the allow-list is the union
// of every concept reachable from any leaf — i.e. all rows of
// paliad.event_category_concepts.
var allowConceptIDs []string
if opts.EventCategorySlug != "" && s.eventCategory != nil {
ids, err := s.eventCategory.ConceptIDsForSlug(ctx, opts.EventCategorySlug)
@@ -234,6 +255,15 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
return resp, nil
}
allowConceptIDs = ids
} else if opts.BrowseAll {
ids, err := s.allMappedConceptIDs(ctx)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return resp, nil
}
allowConceptIDs = ids
}
// v3: translate forum slugs to proceeding_code allow-list.
@@ -279,6 +309,19 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
return resp, nil
}
// allMappedConceptIDs returns every distinct concept_id that has at
// least one row in paliad.event_category_concepts — the universe of
// concepts reachable from any leaf of the v3 decision tree. Drives B1
// browse-all mode (no slug picked yet, show the full landscape).
func (s *DeadlineSearchService) allMappedConceptIDs(ctx context.Context) ([]string, error) {
const sqlText = `SELECT DISTINCT concept_id::text FROM paliad.event_category_concepts`
var ids []string
if err := s.db.SelectContext(ctx, &ids, sqlText); err != nil {
return nil, fmt.Errorf("all mapped concept ids: %w", err)
}
return ids, nil
}
// translateForums maps a list of forum slugs to the union of their
// proceeding_type_codes via ForumToProceedingCodes. Unknown slugs are
// silently dropped.