feat: implement Animal Registry view with filtering and pagination (Step 8.1)

- Add status field to filter DSL parser and resolver
- Create AnimalRepository with list_animals and get_facet_counts
- Implement registry templates with table, facet sidebar, infinite scroll
- Create registry route handler with HTMX partial support
- Default shows only alive animals; status filter overrides

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 14:59:13 +00:00
parent 254466827c
commit 8e155080e4
12 changed files with 1630 additions and 28 deletions

View File

@@ -0,0 +1,355 @@
# ABOUTME: Repository for animal registry queries.
# ABOUTME: Provides list_animals with filtering/pagination and facet counts.
import base64
import json
from dataclasses import dataclass, field
from typing import Any
from animaltrack.selection.parser import parse_filter
@dataclass
class AnimalListItem:
"""Animal data for registry list view."""
animal_id: str
species_code: str
sex: str
life_stage: str
status: str
location_id: str
location_name: str
nickname: str | None
identified: bool
tags: list[str]
last_event_utc: int
@dataclass
class PaginatedResult:
"""Paginated list result with cursor."""
items: list[AnimalListItem]
next_cursor: str | None
total_count: int
@dataclass
class FacetCounts:
"""Facet counts for sidebar."""
by_status: dict[str, int] = field(default_factory=dict)
by_species: dict[str, int] = field(default_factory=dict)
by_sex: dict[str, int] = field(default_factory=dict)
by_life_stage: dict[str, int] = field(default_factory=dict)
by_location: dict[str, int] = field(default_factory=dict)
class AnimalRepository:
"""Repository for animal registry operations."""
PAGE_SIZE = 50
def __init__(self, db: Any) -> None:
"""Initialize repository with database connection.
Args:
db: A fastlite database connection.
"""
self.db = db
def list_animals(
self,
filter_str: str = "",
cursor: str | None = None,
) -> PaginatedResult:
"""List animals with filtering and cursor pagination.
Args:
filter_str: DSL filter string (reuses selection parser).
cursor: Base64-encoded cursor for pagination.
Returns:
PaginatedResult with items and next_cursor.
"""
# Parse filter
filter_ast = parse_filter(filter_str)
# Check if filter has explicit status
has_status_filter = any(f.field == "status" for f in filter_ast.filters)
# Build WHERE clauses from filter
where_clauses = []
params: list[Any] = []
# Default to alive if no status filter
if not has_status_filter:
where_clauses.append("ar.status = 'alive'")
# Apply filter clauses
for field_filter in filter_ast.filters:
clause, filter_params = self._build_where_clause(field_filter)
where_clauses.append(clause)
params.extend(filter_params)
# Decode cursor for pagination
cursor_last_event: int | None = None
cursor_animal_id: str | None = None
if cursor:
try:
decoded = json.loads(base64.b64decode(cursor).decode())
cursor_last_event = decoded["last_event_utc"]
cursor_animal_id = decoded["animal_id"]
except (ValueError, KeyError):
pass # Invalid cursor, ignore
# Build WHERE string
where_str = " AND ".join(where_clauses) if where_clauses else "1=1"
# Get total count (before pagination)
count_query = f"""
SELECT COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
"""
total_count = self.db.execute(count_query, params).fetchone()[0]
# Add cursor pagination
pagination_params = list(params)
if cursor_last_event is not None and cursor_animal_id is not None:
where_str += (
" AND (ar.last_event_utc < ? OR (ar.last_event_utc = ? AND ar.animal_id < ?))"
)
pagination_params.extend([cursor_last_event, cursor_last_event, cursor_animal_id])
# Build main query
query = f"""
SELECT
ar.animal_id,
ar.species_code,
ar.sex,
ar.life_stage,
ar.status,
ar.location_id,
l.name as location_name,
ar.nickname,
ar.identified,
ar.last_event_utc,
COALESCE(
(SELECT json_group_array(tag)
FROM animal_tag_intervals ati
WHERE ati.animal_id = ar.animal_id
AND ati.end_utc IS NULL),
'[]'
) as tags
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
ORDER BY ar.last_event_utc DESC, ar.animal_id DESC
LIMIT ?
"""
pagination_params.append(self.PAGE_SIZE + 1) # Fetch one extra to detect next page
rows = self.db.execute(query, pagination_params).fetchall()
# Build items
items = []
for row in rows[: self.PAGE_SIZE]:
tags_json = row[10]
tags = json.loads(tags_json) if tags_json else []
items.append(
AnimalListItem(
animal_id=row[0],
species_code=row[1],
sex=row[2],
life_stage=row[3],
status=row[4],
location_id=row[5],
location_name=row[6],
nickname=row[7],
identified=bool(row[8]),
last_event_utc=row[9],
tags=tags,
)
)
# Determine next cursor
next_cursor = None
if len(rows) > self.PAGE_SIZE:
last_item = items[-1]
cursor_data = {
"last_event_utc": last_item.last_event_utc,
"animal_id": last_item.animal_id,
}
next_cursor = base64.b64encode(json.dumps(cursor_data).encode()).decode()
return PaginatedResult(
items=items,
next_cursor=next_cursor,
total_count=total_count,
)
def get_facet_counts(
self,
filter_str: str = "",
) -> FacetCounts:
"""Get facet counts for current filter.
Counts are computed for all facets within the filtered set.
Unlike list_animals, facets do NOT default to alive-only so users
can see the full status breakdown.
Args:
filter_str: DSL filter string.
Returns:
FacetCounts with breakdowns by status, species, sex, life_stage, location.
"""
# Parse filter
filter_ast = parse_filter(filter_str)
# Build WHERE clauses from filter
# Note: facets do NOT apply the default alive filter - users should see
# the full breakdown of all statuses
where_clauses = []
params: list[Any] = []
# Apply filter clauses
for field_filter in filter_ast.filters:
clause, filter_params = self._build_where_clause(field_filter)
where_clauses.append(clause)
params.extend(filter_params)
where_str = " AND ".join(where_clauses) if where_clauses else "1=1"
facets = FacetCounts()
# Status counts
status_query = f"""
SELECT status, COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
GROUP BY status
"""
for row in self.db.execute(status_query, params).fetchall():
facets.by_status[row[0]] = row[1]
# Species counts
species_query = f"""
SELECT species_code, COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
GROUP BY species_code
"""
for row in self.db.execute(species_query, params).fetchall():
facets.by_species[row[0]] = row[1]
# Sex counts
sex_query = f"""
SELECT sex, COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
GROUP BY sex
"""
for row in self.db.execute(sex_query, params).fetchall():
facets.by_sex[row[0]] = row[1]
# Life stage counts
life_stage_query = f"""
SELECT life_stage, COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
GROUP BY life_stage
"""
for row in self.db.execute(life_stage_query, params).fetchall():
facets.by_life_stage[row[0]] = row[1]
# Location counts (by location_id, not name)
location_query = f"""
SELECT ar.location_id, COUNT(*)
FROM animal_registry ar
JOIN locations l ON ar.location_id = l.id
WHERE {where_str}
GROUP BY ar.location_id
"""
for row in self.db.execute(location_query, params).fetchall():
facets.by_location[row[0]] = row[1]
return facets
def _build_where_clause(self, field_filter) -> tuple[str, list[Any]]:
"""Build SQL WHERE clause for a single field filter.
Args:
field_filter: The field filter to build clause for.
Returns:
Tuple of (SQL clause string, list of parameters).
"""
field = field_filter.field
values = list(field_filter.values)
negated = field_filter.negated
placeholders = ",".join("?" * len(values))
if field == "species":
op = "NOT IN" if negated else "IN"
return f"ar.species_code {op} ({placeholders})", values
elif field == "status":
op = "NOT IN" if negated else "IN"
return f"ar.status {op} ({placeholders})", values
elif field == "sex":
op = "NOT IN" if negated else "IN"
return f"ar.sex {op} ({placeholders})", values
elif field == "life_stage":
op = "NOT IN" if negated else "IN"
return f"ar.life_stage {op} ({placeholders})", values
elif field == "identified":
int_values = [int(v) for v in values]
op = "NOT IN" if negated else "IN"
placeholders = ",".join("?" * len(int_values))
return f"ar.identified {op} ({placeholders})", int_values
elif field == "location":
# Location by name
op = "NOT IN" if negated else "IN"
return f"l.name {op} ({placeholders})", values
elif field == "tag":
# Tag filter - check animal_tag_intervals
if negated:
return (
f"""
ar.animal_id NOT IN (
SELECT animal_id FROM animal_tag_intervals
WHERE tag IN ({placeholders})
AND end_utc IS NULL
)
""",
values,
)
else:
return (
f"""
ar.animal_id IN (
SELECT animal_id FROM animal_tag_intervals
WHERE tag IN ({placeholders})
AND end_utc IS NULL
)
""",
values,
)
else:
# Unknown field - should not happen if parser validates
return "1=1", []

View File

@@ -6,7 +6,9 @@ from collections.abc import Iterator
from animaltrack.selection.ast import FieldFilter, FilterAST
# Supported filter fields
VALID_FIELDS = frozenset({"location", "species", "sex", "life_stage", "identified", "tag"})
VALID_FIELDS = frozenset(
{"location", "species", "sex", "life_stage", "identified", "tag", "status"}
)
# Fields that can be used as flags (without :value)
FLAG_FIELDS = frozenset({"identified"})

View File

@@ -62,6 +62,11 @@ def resolve_selection(
return resolved_ids
def _has_status_filter(filter_ast: FilterAST) -> bool:
"""Check if the filter AST contains an explicit status filter."""
return any(f.field == "status" for f in filter_ast.filters)
def resolve_filter(
db: Any,
filter_ast: FilterAST,
@@ -80,23 +85,37 @@ def resolve_filter(
Returns:
SelectionResult with sorted animal_ids and roster_hash.
"""
# Check if explicit status filter is provided
has_status = _has_status_filter(filter_ast)
# Build base query - all animals with location interval at ts_utc
# and status='alive' at ts_utc
base_query = """
SELECT DISTINCT ali.animal_id
FROM animal_location_intervals ali
WHERE ali.start_utc <= ?
AND (ali.end_utc IS NULL OR ali.end_utc > ?)
AND EXISTS (
SELECT 1 FROM animal_attr_intervals aai
WHERE aai.animal_id = ali.animal_id
AND aai.attr = 'status'
AND aai.value = 'alive'
AND aai.start_utc <= ?
AND (aai.end_utc IS NULL OR aai.end_utc > ?)
)
"""
params: list[Any] = [ts_utc, ts_utc, ts_utc, ts_utc]
# If no explicit status filter, default to status='alive'
if has_status:
# When status filter is explicit, don't apply default alive filter
base_query = """
SELECT DISTINCT ali.animal_id
FROM animal_location_intervals ali
WHERE ali.start_utc <= ?
AND (ali.end_utc IS NULL OR ali.end_utc > ?)
"""
params: list[Any] = [ts_utc, ts_utc]
else:
# Default: only alive animals
base_query = """
SELECT DISTINCT ali.animal_id
FROM animal_location_intervals ali
WHERE ali.start_utc <= ?
AND (ali.end_utc IS NULL OR ali.end_utc > ?)
AND EXISTS (
SELECT 1 FROM animal_attr_intervals aai
WHERE aai.animal_id = ali.animal_id
AND aai.attr = 'status'
AND aai.value = 'alive'
AND aai.start_utc <= ?
AND (aai.end_utc IS NULL OR aai.end_utc > ?)
)
"""
params = [ts_utc, ts_utc, ts_utc, ts_utc]
# Apply each filter
for field_filter in filter_ast.filters:
@@ -188,6 +207,19 @@ def _build_filter_clause(field_filter: FieldFilter, ts_utc: int) -> tuple[str, l
params = values + [ts_utc, ts_utc]
return query, params
elif field == "status":
# Historical status from animal_attr_intervals
placeholders = ",".join("?" * len(values))
query = f"""
SELECT animal_id FROM animal_attr_intervals
WHERE attr = 'status'
AND value IN ({placeholders})
AND start_utc <= ?
AND (end_utc IS NULL OR end_utc > ?)
"""
params = values + [ts_utc, ts_utc]
return query, params
else:
# Unknown field - should not happen if parser validates
msg = f"Unknown filter field: {field}"

View File

@@ -22,6 +22,7 @@ from animaltrack.web.routes import (
register_feed_routes,
register_health_routes,
register_move_routes,
register_registry_routes,
)
# Default static directory relative to this module
@@ -133,5 +134,6 @@ def create_app(
register_egg_routes(rt, app)
register_feed_routes(rt, app)
register_move_routes(rt, app)
register_registry_routes(rt, app)
return app, rt

View File

@@ -5,10 +5,12 @@ from animaltrack.web.routes.eggs import register_egg_routes
from animaltrack.web.routes.feed import register_feed_routes
from animaltrack.web.routes.health import register_health_routes
from animaltrack.web.routes.move import register_move_routes
from animaltrack.web.routes.registry import register_registry_routes
__all__ = [
"register_egg_routes",
"register_feed_routes",
"register_health_routes",
"register_move_routes",
"register_registry_routes",
]

View File

@@ -0,0 +1,76 @@
# ABOUTME: Routes for Animal Registry view.
# ABOUTME: Handles GET /registry with filters, pagination, and facets.
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.web.templates.base import page
from animaltrack.web.templates.registry import (
animal_table_rows,
registry_page,
)
def registry_index(request: Request):
"""GET /registry - Animal Registry with filtering and pagination.
Query params:
filter: DSL filter string (e.g., "species:duck status:alive")
cursor: Base64-encoded pagination cursor
Returns:
Full page on initial load, or just table rows for HTMX requests with cursor.
"""
db = request.app.state.db
# Extract query params
filter_str = request.query_params.get("filter", "")
cursor = request.query_params.get("cursor")
# Get animal data
animal_repo = AnimalRepository(db)
result = animal_repo.list_animals(filter_str=filter_str, cursor=cursor)
facets = animal_repo.get_facet_counts(filter_str=filter_str)
# Get reference data for facet labels
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
# If this is an HTMX request for more rows (has cursor), return just the rows
if request.headers.get("HX-Request") and cursor:
from fasthtml.common import to_xml
rows = animal_table_rows(
result.items,
next_cursor=result.next_cursor,
filter_str=filter_str,
)
return HTMLResponse(content=to_xml(tuple(rows)))
# Full page render
return page(
registry_page(
animals=result.items,
facets=facets,
filter_str=filter_str,
next_cursor=result.next_cursor,
total_count=result.total_count,
locations=locations,
species_list=species_list,
),
title="Registry - AnimalTrack",
active_nav="registry",
)
def register_registry_routes(rt, app):
"""Register registry routes.
Args:
rt: FastHTML route decorator.
app: FastHTML application instance.
"""
rt("/registry")(registry_index)

View File

@@ -0,0 +1,312 @@
# ABOUTME: Templates for Animal Registry view.
# ABOUTME: Table with infinite scroll, facet sidebar, and filter input.
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr
from monsterui.all import Button, ButtonT, Card, Grid, LabelInput
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
def registry_page(
animals: list[AnimalListItem],
facets: FacetCounts,
filter_str: str = "",
next_cursor: str | None = None,
total_count: int = 0,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Grid:
"""Full registry page with sidebar and table.
Args:
animals: List of animals for the current page.
facets: Facet counts for the sidebar.
filter_str: Current filter string.
next_cursor: Cursor for the next page (if more exist).
total_count: Total number of animals matching the filter.
locations: List of locations for facet labels.
species_list: List of species for facet labels.
Returns:
Grid component with sidebar and main content.
"""
return Grid(
# Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list),
# Main content
Div(
# Header with filter
registry_header(filter_str, total_count),
# Animal table
animal_table(animals, next_cursor, filter_str),
cls="col-span-3",
),
cols_sm=1,
cols_md=4,
cls="gap-4 p-4",
)
def registry_header(filter_str: str, total_count: int) -> Div:
"""Header with title and filter input.
Args:
filter_str: Current filter string.
total_count: Total number of matching animals.
Returns:
Div with header and filter form.
"""
return Div(
Div(
H2("Animal Registry", cls="text-xl font-bold"),
Span(f"{total_count} animals", cls="text-sm text-stone-400"),
cls="flex items-center justify-between",
),
# Filter form
Form(
Div(
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
placeholder='e.g., species:duck status:alive location:"Strip 1"',
cls="flex-1",
),
Button("Apply", type="submit", cls=ButtonT.primary),
cls="flex gap-2 items-end",
),
action="/registry",
method="get",
cls="mt-4",
),
cls="mb-4",
)
def facet_sidebar(
facets: FacetCounts,
filter_str: str,
locations: list[Location] | None,
species_list: list[Species] | None,
) -> Div:
"""Sidebar with clickable facet counts.
Args:
facets: Facet counts for display.
filter_str: Current filter string.
locations: List of locations for name lookup.
species_list: List of species for name lookup.
Returns:
Div containing facet sections.
"""
location_map = {loc.id: loc.name for loc in (locations or [])}
species_map = {s.code: s.name for s in (species_list or [])}
return Div(
facet_section("Status", facets.by_status, filter_str, "status"),
facet_section("Species", facets.by_species, filter_str, "species", species_map),
facet_section("Sex", facets.by_sex, filter_str, "sex"),
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
facet_section("Location", facets.by_location, filter_str, "location", location_map),
cls="space-y-4",
)
def facet_section(
title: str,
counts: dict[str, int],
filter_str: str,
field: str,
label_map: dict[str, str] | None = None,
) -> Any:
"""Single facet section with clickable items.
Args:
title: Section title.
counts: Dictionary of value -> count.
filter_str: Current filter string.
field: Field name for building filter links.
label_map: Optional mapping from value to display label.
Returns:
Card component with facet items, or None if no counts.
"""
if not counts:
return None
items = []
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
# Build filter link - append to current filter
if filter_str:
new_filter = f"{filter_str} {field}:{value}"
else:
new_filter = f"{field}:{value}"
href = f"/registry?{urlencode({'filter': new_filter})}"
items.append(
A(
Div(
Span(label, cls="text-sm"),
Span(str(count), cls="text-xs text-stone-400 ml-auto"),
cls="flex justify-between items-center",
),
href=href,
cls="block p-2 hover:bg-slate-800 rounded",
)
)
return Card(
P(title, cls="font-bold text-sm mb-2"),
*items,
)
def animal_table(
animals: list[AnimalListItem],
next_cursor: str | None,
filter_str: str,
) -> Table:
"""Animal table with infinite scroll.
Args:
animals: List of animals for the current page.
next_cursor: Cursor for the next page (if more exist).
filter_str: Current filter string.
Returns:
Table component with header and body.
"""
return Table(
Thead(
Tr(
Th("ID", shrink=True),
Th("Species"),
Th("Sex"),
Th("Life Stage"),
Th("Location"),
Th("Tags"),
Th("Last Event"),
Th("Status"),
)
),
Tbody(
*[animal_row(a) for a in animals],
# Sentinel for infinite scroll
load_more_sentinel(next_cursor, filter_str) if next_cursor else None,
id="animal-tbody",
),
cls="uk-table uk-table-sm uk-table-divider uk-table-hover",
)
def animal_row(animal: AnimalListItem) -> Tr:
"""Single table row for an animal.
Args:
animal: Animal data for the row.
Returns:
Tr component for the animal.
"""
# Format last event timestamp
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
# Display ID (truncated or nickname)
display_id = animal.nickname or animal.animal_id[:8] + "..."
# Status badge styling
status_cls = {
"alive": "bg-green-900/50 text-green-300",
"dead": "bg-red-900/50 text-red-300",
"harvested": "bg-amber-900/50 text-amber-300",
"sold": "bg-blue-900/50 text-blue-300",
"merged_into": "bg-slate-700 text-slate-300",
}.get(animal.status, "bg-slate-700 text-slate-300")
# Format tags (show first 3)
tags_str = ", ".join(animal.tags[:3])
if len(animal.tags) > 3:
tags_str += "..."
return Tr(
Td(
A(
display_id,
href=f"/animals/{animal.animal_id}",
cls="text-amber-500 hover:underline",
),
title=animal.animal_id,
),
Td(animal.species_code),
Td(animal.sex),
Td(animal.life_stage.replace("_", " ")),
Td(animal.location_name),
Td(tags_str, cls="text-xs text-stone-400"),
Td(last_event_str, cls="text-xs text-stone-400"),
Td(
Span(
animal.status,
cls=f"text-xs px-2 py-0.5 rounded {status_cls}",
)
),
)
def animal_table_rows(
animals: list[AnimalListItem],
next_cursor: str | None,
filter_str: str,
) -> list:
"""Just table rows for HTMX append (no wrapper).
Args:
animals: List of animals for the current page.
next_cursor: Cursor for the next page (if more exist).
filter_str: Current filter string.
Returns:
List of Tr components.
"""
rows = [animal_row(a) for a in animals]
if next_cursor:
rows.append(load_more_sentinel(next_cursor, filter_str))
return rows
def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
"""Sentinel row that triggers infinite scroll.
Args:
cursor: Cursor for the next page.
filter_str: Current filter string.
Returns:
Tr component with HTMX attributes for lazy loading.
"""
params = {"cursor": cursor}
if filter_str:
params["filter"] = filter_str
url = f"/registry?{urlencode(params)}"
return Tr(
Td(
Div(
"Loading more...",
cls="text-center text-stone-400 py-4",
),
colspan="8",
),
hx_get=url,
hx_trigger="revealed",
hx_swap="outerHTML",
id="load-more-sentinel",
)