diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py
index 9931d88..e20ffab 100644
--- a/src/animaltrack/web/routes/actions.py
+++ b/src/animaltrack/web/routes/actions.py
@@ -538,6 +538,9 @@ def tag_add_index(request: Request):
roster_hash = ""
animals = []
+ # Get animal repo for both resolution and facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -546,9 +549,16 @@ def tag_add_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
- animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
+ # Get facet counts for alive animals
+ facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get locations and species for facet name lookup
+ locations = LocationRepository(db).list_active()
+ species_list = SpeciesRepository(db).list_all()
+
return render_page(
request,
tag_add_form(
@@ -558,6 +568,9 @@ def tag_add_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
+ facets=facets,
+ locations=locations,
+ species_list=species_list,
),
title="Add Tag - AnimalTrack",
active_nav=None,
@@ -787,6 +800,9 @@ def tag_end_index(request: Request):
active_tags: list[str] = []
animals = []
+ # Get animal repo for both resolution and facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -796,9 +812,16 @@ def tag_end_index(request: Request):
roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids)
# Fetch animal details for checkbox display
- animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
+ # Get facet counts for alive animals
+ facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get locations and species for facet name lookup
+ locations = LocationRepository(db).list_active()
+ species_list = SpeciesRepository(db).list_all()
+
return render_page(
request,
tag_end_form(
@@ -809,6 +832,9 @@ def tag_end_index(request: Request):
resolved_count=len(resolved_ids),
active_tags=active_tags,
animals=animals,
+ facets=facets,
+ locations=locations,
+ species_list=species_list,
),
title="End Tag - AnimalTrack",
active_nav=None,
@@ -1012,6 +1038,9 @@ def attrs_index(request: Request):
roster_hash = ""
animals = []
+ # Get animal repo for both resolution and facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1020,9 +1049,16 @@ def attrs_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
- animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
+ # Get facet counts for alive animals
+ facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get locations and species for facet name lookup
+ locations = LocationRepository(db).list_active()
+ species_list = SpeciesRepository(db).list_all()
+
return render_page(
request,
attrs_form(
@@ -1032,6 +1068,9 @@ def attrs_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
+ facets=facets,
+ locations=locations,
+ species_list=species_list,
),
title="Update Attributes - AnimalTrack",
active_nav=None,
@@ -1247,6 +1286,9 @@ def outcome_index(request: Request):
roster_hash = ""
animals = []
+ # Get animal repo for both resolution and facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1255,13 +1297,20 @@ def outcome_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
- animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get active products for yield items dropdown
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
+ # Get facet counts for alive animals
+ facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get locations and species for facet name lookup
+ locations = LocationRepository(db).list_active()
+ species_list = SpeciesRepository(db).list_all()
+
return render_page(
request,
outcome_form(
@@ -1272,6 +1321,9 @@ def outcome_index(request: Request):
resolved_count=len(resolved_ids),
products=products,
animals=animals,
+ facets=facets,
+ locations=locations,
+ species_list=species_list,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
@@ -1544,6 +1596,9 @@ async def status_correct_index(req: Request):
resolved_ids: list[str] = []
roster_hash = ""
+ # Get animal repo for facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1552,6 +1607,13 @@ async def status_correct_index(req: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
+ # Get facet counts (show all statuses for admin correction form)
+ facets = animal_repo.get_facet_counts(filter_str)
+
+ # Get locations and species for facet name lookup
+ locations = LocationRepository(db).list_active()
+ species_list = SpeciesRepository(db).list_all()
+
return render_page(
req,
status_correct_form(
@@ -1560,6 +1622,9 @@ async def status_correct_index(req: Request):
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
+ facets=facets,
+ locations=locations,
+ species_list=species_list,
),
title="Correct Status - AnimalTrack",
active_nav=None,
diff --git a/src/animaltrack/web/routes/api.py b/src/animaltrack/web/routes/api.py
index e00266a..3265b14 100644
--- a/src/animaltrack/web/routes/api.py
+++ b/src/animaltrack/web/routes/api.py
@@ -1,17 +1,20 @@
# ABOUTME: API routes for HTMX partial updates.
-# ABOUTME: Provides endpoints for selection preview and hash computation.
+# ABOUTME: Provides endpoints for selection preview, hash computation, and dynamic facets.
from __future__ import annotations
import time
-from fasthtml.common import APIRouter
+from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from animaltrack.repositories.animals import AnimalRepository
+from animaltrack.repositories.locations import LocationRepository
+from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.web.templates.animal_select import animal_checkbox_list
+from animaltrack.web.templates.dsl_facets import dsl_facet_pills
# APIRouter for multi-file route organization
ar = APIRouter()
@@ -97,3 +100,49 @@ def selection_preview(request: Request):
# Render checkbox list for multiple animals
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))
+
+
+@ar("/api/facets")
+def facets(request: Request):
+ """GET /api/facets - Get facet pills HTML for current filter.
+
+ Query params:
+ - filter: DSL filter string (optional)
+ - include_status: "true" to include status facet (for registry)
+
+ Returns HTML partial with facet pills for HTMX outerHTML swap.
+ The returned HTML has id="dsl-facet-pills" for proper swap targeting.
+ """
+ db = request.app.state.db
+ filter_str = request.query_params.get("filter", "")
+ include_status = request.query_params.get("include_status", "").lower() == "true"
+
+ # Get facet counts based on current filter
+ animal_repo = AnimalRepository(db)
+
+ if include_status:
+ # Registry mode: show all statuses, no implicit alive filter
+ facet_filter = filter_str
+ else:
+ # Action form mode: filter to alive animals
+ if filter_str:
+ # If filter already includes status, use it as-is
+ # Otherwise, implicitly filter to alive animals
+ if "status:" in filter_str:
+ facet_filter = filter_str
+ else:
+ facet_filter = f"status:alive {filter_str}".strip()
+ else:
+ facet_filter = "status:alive"
+
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get locations and species for name mapping
+ location_repo = LocationRepository(db)
+ species_repo = SpeciesRepository(db)
+ locations = location_repo.list_all()
+ species_list = species_repo.list_all()
+
+ # Render facet pills - filter input ID is "filter" by convention
+ result = dsl_facet_pills(facets, "filter", locations, species_list, include_status)
+ return HTMLResponse(content=to_xml(result))
diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py
index 2817d6d..03985b9 100644
--- a/src/animaltrack/web/routes/move.py
+++ b/src/animaltrack/web/routes/move.py
@@ -20,6 +20,7 @@ from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
+from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
@@ -192,6 +193,9 @@ def move_index(request: Request):
from_location_name = None
animals = []
+ # Get animal repo for both filter resolution and facet counts
+ animal_repo = AnimalRepository(db)
+
if filter_str or not request.query_params:
# If no filter, default to empty (show all alive animals)
filter_ast = parse_filter(filter_str)
@@ -202,9 +206,15 @@ def move_index(request: Request):
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
# Fetch animal details for checkbox display
- animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
+ # Get facet counts for alive animals (action forms filter to alive by default)
+ facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
+ facets = animal_repo.get_facet_counts(facet_filter)
+
+ # Get species list for facet name lookup
+ species_list = SpeciesRepository(db).list_all()
+
# Get recent events and stats
display_data = _get_move_display_data(db, locations)
@@ -221,6 +231,8 @@ def move_index(request: Request):
from_location_name=from_location_name,
action=animal_move,
animals=animals,
+ facets=facets,
+ species_list=species_list,
**display_data,
),
title="Move - AnimalTrack",
diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py
index 6e3be01..acf81ea 100644
--- a/src/animaltrack/web/templates/actions.py
+++ b/src/animaltrack/web/templates/actions.py
@@ -18,8 +18,10 @@ from ulid import ULID
from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species
+from animaltrack.repositories.animals import FacetCounts
from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.action_bar import ActionBar
+from animaltrack.web.templates.dsl_facets import dsl_facet_pills, dsl_facet_pills_script
# =============================================================================
# Selection Diff Confirmation Panel
@@ -622,7 +624,10 @@ def tag_add_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-add",
animals: list | None = None,
-) -> Form:
+ facets: FacetCounts | None = None,
+ locations: list[Location] | None = None,
+ species_list: list[Species] | None = None,
+) -> Div:
"""Create the Add Tag form.
Args:
@@ -634,9 +639,12 @@ def tag_add_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
+ facets: Optional FacetCounts for facet pills display.
+ locations: Optional list of Locations for facet name lookup.
+ species_list: Optional list of Species for facet name lookup.
Returns:
- Form component for adding tags to animals.
+ Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -686,10 +694,19 @@ def tag_add_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
- return Form(
+ # Build facet pills component if facets provided
+ facet_pills_component = None
+ facet_script = None
+ if facets:
+ facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
+ facet_script = dsl_facet_pills_script("filter")
+
+ form = Form(
H2("Add Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
+ # Facet pills for easy filter composition (tap to add filter terms)
+ facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -735,6 +752,8 @@ def tag_add_form(
cls="space-y-4",
)
+ return Div(facet_script, form)
+
def tag_add_diff_panel(
diff: SelectionDiff,
@@ -788,7 +807,10 @@ def tag_end_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-end",
animals: list | None = None,
-) -> Form:
+ facets: FacetCounts | None = None,
+ locations: list[Location] | None = None,
+ species_list: list[Species] | None = None,
+) -> Div:
"""Create the End Tag form.
Args:
@@ -801,9 +823,12 @@ def tag_end_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
+ facets: Optional FacetCounts for facet pills display.
+ locations: Optional list of Locations for facet name lookup.
+ species_list: Optional list of Species for facet name lookup.
Returns:
- Form component for ending tags on animals.
+ Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -860,10 +885,19 @@ def tag_end_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
- return Form(
+ # Build facet pills component if facets provided
+ facet_pills_component = None
+ facet_script = None
+ if facets:
+ facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
+ facet_script = dsl_facet_pills_script("filter")
+
+ form = Form(
H2("End Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
+ # Facet pills for easy filter composition (tap to add filter terms)
+ facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -919,6 +953,8 @@ def tag_end_form(
cls="space-y-4",
)
+ return Div(facet_script, form)
+
def tag_end_diff_panel(
diff: SelectionDiff,
@@ -971,7 +1007,10 @@ def attrs_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-attrs",
animals: list | None = None,
-) -> Form:
+ facets: FacetCounts | None = None,
+ locations: list[Location] | None = None,
+ species_list: list[Species] | None = None,
+) -> Div:
"""Create the Update Attributes form.
Args:
@@ -983,9 +1022,12 @@ def attrs_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
+ facets: Optional FacetCounts for facet pills display.
+ locations: Optional list of Locations for facet name lookup.
+ species_list: Optional list of Species for facet name lookup.
Returns:
- Form component for updating animal attributes.
+ Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -1063,10 +1105,19 @@ def attrs_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
- return Form(
+ # Build facet pills component if facets provided
+ facet_pills_component = None
+ facet_script = None
+ if facets:
+ facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
+ facet_script = dsl_facet_pills_script("filter")
+
+ form = Form(
H2("Update Attributes", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
+ # Facet pills for easy filter composition (tap to add filter terms)
+ facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -1121,6 +1172,8 @@ def attrs_form(
cls="space-y-4",
)
+ return Div(facet_script, form)
+
def attrs_diff_panel(
diff: SelectionDiff,
@@ -1182,7 +1235,10 @@ def outcome_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-outcome",
animals: list | None = None,
-) -> Form:
+ facets: FacetCounts | None = None,
+ locations: list[Location] | None = None,
+ species_list: list[Species] | None = None,
+) -> Div:
"""Create the Record Outcome form.
Args:
@@ -1195,9 +1251,12 @@ def outcome_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
+ facets: Optional FacetCounts for facet pills display.
+ locations: Optional list of Locations for facet name lookup.
+ species_list: Optional list of Species for facet name lookup.
Returns:
- Form component for recording animal outcomes.
+ Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -1320,9 +1379,18 @@ def outcome_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md space-y-3",
)
- return Form(
+ # Build facet pills component if facets provided
+ facet_pills_component = None
+ facet_script = None
+ if facets:
+ facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
+ facet_script = dsl_facet_pills_script("filter")
+
+ form = Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component,
+ # Facet pills for easy filter composition (tap to add filter terms)
+ facet_pills_component,
# Filter field with HTMX to fetch selection preview
LabelInput(
label="Filter (DSL)",
@@ -1379,6 +1447,8 @@ def outcome_form(
cls="space-y-4",
)
+ return Div(facet_script, form)
+
def outcome_diff_panel(
diff: SelectionDiff,
@@ -1448,7 +1518,10 @@ def status_correct_form(
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-status-correct",
-) -> Form:
+ facets: FacetCounts | None = None,
+ locations: list[Location] | None = None,
+ species_list: list[Species] | None = None,
+) -> Div:
"""Create the Correct Status form (admin-only).
Args:
@@ -1459,9 +1532,12 @@ def status_correct_form(
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
+ facets: Optional FacetCounts for facet pills display.
+ locations: Optional list of Locations for facet name lookup.
+ species_list: Optional list of Species for facet name lookup.
Returns:
- Form component for correcting animal status.
+ Div component containing facet script and form.
"""
if resolved_ids is None:
resolved_ids = []
@@ -1508,11 +1584,19 @@ def status_correct_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
- return Form(
+ # Build facet pills component if facets provided
+ facet_pills_component = None
+ facet_script = None
+ if facets:
+ facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
+ facet_script = dsl_facet_pills_script("filter")
+
+ form = Form(
H2("Correct Animal Status", cls="text-xl font-bold mb-4"),
admin_warning,
error_component,
- selection_preview,
+ # Facet pills for easy filter composition (tap to add filter terms)
+ facet_pills_component,
# Filter field
LabelInput(
label="Filter (DSL)",
@@ -1521,6 +1605,7 @@ def status_correct_form(
value=filter_str,
placeholder="e.g., species:duck location:Coop1",
),
+ selection_preview,
# New status selection - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("New Status", _for="new_status"),
@@ -1564,6 +1649,8 @@ def status_correct_form(
cls="space-y-4",
)
+ return Div(facet_script, form)
+
def status_correct_diff_panel(
diff: SelectionDiff,
diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py
index 2b93917..e6fa8a0 100644
--- a/src/animaltrack/web/templates/base.py
+++ b/src/animaltrack/web/templates/base.py
@@ -39,6 +39,12 @@ def SelectStyles(): # noqa: N802
color: #e5e5e5 !important;
-webkit-text-fill-color: #e5e5e5 !important;
}
+ /* Tell browser to use native dark mode for select dropdown options.
+ This makes