Files
animaltrack/src/animaltrack/web/templates/registry.py
Petru Paler 8e155080e4 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>
2025-12-30 14:59:13 +00:00

313 lines
9.1 KiB
Python

# 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",
)