- 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>
313 lines
9.1 KiB
Python
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",
|
|
)
|