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