feat: add event detail slide-over, fix toasts, and checkbox selection

Three major features implemented:

1. Event Detail Slide-Over Panel
   - Click timeline events to view details in slide-over
   - New /events/{event_id} route and event_detail.py template
   - Type-specific payload rendering for all event types

2. Toast System Refactor
   - Switch from custom addEventListener to FastHTML's add_toast()
   - Replace HX-Trigger headers with session-based toasts
   - Add event links in toast messages
   - Replace addEventListener with hx_on_* in templates

3. Checkbox Selection for Animal Subsets
   - New animal_select.py component with checkbox list
   - New /api/compute-hash and /api/selection-preview endpoints
   - Add subset_mode support to SelectionContext validation
   - Update 5 forms: outcome, move, tag-add, tag-end, attrs
   - Users can select specific animals from filtered results

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 19:10:57 +00:00
parent 25a91c3322
commit 3937d675ba
19 changed files with 1420 additions and 360 deletions

View File

@@ -107,6 +107,68 @@ class AnimalRepository:
last_event_utc=row[13],
)
def get_by_ids(self, animal_ids: list[str]) -> list[AnimalListItem]:
"""Get multiple animals by ID with display info.
Args:
animal_ids: List of animal IDs to look up.
Returns:
List of AnimalListItem with display info. Order matches input IDs
for animals that exist.
"""
if not animal_ids:
return []
placeholders = ",".join("?" * len(animal_ids))
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 ar.animal_id IN ({placeholders})
"""
rows = self.db.execute(query, animal_ids).fetchall()
# Build lookup dict
items_by_id = {}
for row in rows:
tags_json = row[10]
tags = json.loads(tags_json) if tags_json else []
items_by_id[row[0]] = 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,
)
# Return in original order, filtering out non-existent IDs
return [items_by_id[aid] for aid in animal_ids if aid in items_by_id]
def list_animals(
self,
filter_str: str = "",

View File

@@ -23,6 +23,8 @@ class SelectionContext:
from_location_id: str | None # For move operations (included in hash)
confirmed: bool = False # Override on mismatch
resolver_version: str = "v1" # Fixed version string
subset_mode: bool = False # True when user selected a subset via checkboxes
selected_ids: list[str] | None = None # Subset of IDs selected by user
@dataclass
@@ -101,6 +103,10 @@ def validate_selection(
confirmed=True. Returns valid=False with diff if mismatch and not
confirmed.
In subset_mode, validates that selected_ids are a valid subset of
the filter resolution. Hash is computed from selected_ids, not the
full resolution.
Args:
db: Database connection.
context: SelectionContext with client's filter, IDs, and hash.
@@ -112,6 +118,11 @@ def validate_selection(
filter_ast = parse_filter(context.filter)
resolution = resolve_filter(db, filter_ast, context.ts_utc)
if context.subset_mode and context.selected_ids is not None:
# Subset mode: validate that all selected IDs are in the resolved set
return _validate_subset(db, context, resolution.animal_ids)
# Standard mode: compare full resolution hashes
# Compute server's hash (including from_location_id if provided)
server_hash = compute_roster_hash(
resolution.animal_ids,
@@ -147,3 +158,71 @@ def validate_selection(
roster_hash=server_hash,
diff=diff,
)
def _validate_subset(
db: Any,
context: SelectionContext,
resolved_ids: list[str],
) -> SelectionValidationResult:
"""Validate subset selection against filter resolution.
Checks that all selected IDs are in the resolved set (still match filter).
IDs that no longer match are reported in the diff.
Args:
db: Database connection.
context: SelectionContext with subset_mode=True and selected_ids.
resolved_ids: IDs from resolving the filter at ts_utc.
Returns:
SelectionValidationResult with validation status.
"""
selected_ids = context.selected_ids or []
resolved_set = set(resolved_ids)
selected_set = set(selected_ids)
# Find selected IDs that no longer match the filter
invalid_ids = selected_set - resolved_set
if not invalid_ids:
# All selected IDs are valid - compute hash from selected IDs
subset_hash = compute_roster_hash(selected_ids, context.from_location_id)
# Verify hash matches what client sent
if subset_hash == context.roster_hash:
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
diff=None,
)
# Some selected IDs are no longer valid, or hash mismatch
# Compute diff: removed = invalid_ids, added = none
diff = SelectionDiff(
added=[],
removed=sorted(invalid_ids),
server_count=len(resolved_ids),
client_count=len(selected_ids),
)
if context.confirmed and not invalid_ids:
# Client confirmed, and all IDs are still valid
return SelectionValidationResult(
valid=True,
resolved_ids=selected_ids,
roster_hash=context.roster_hash,
diff=diff,
)
# Invalid - return with valid selected IDs (those that still match)
valid_selected = [sid for sid in selected_ids if sid in resolved_set]
new_hash = compute_roster_hash(valid_selected, context.from_location_id)
return SelectionValidationResult(
valid=False,
resolved_ids=valid_selected,
roster_hash=new_hash,
diff=diff,
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from fasthtml.common import Beforeware, Meta, fast_app
from fasthtml.common import Beforeware, Meta, fast_app, setup_toasts
from monsterui.all import Theme
from starlette.middleware import Middleware
from starlette.requests import Request
@@ -22,6 +22,7 @@ from animaltrack.web.middleware import (
from animaltrack.web.routes import (
actions_router,
animals_router,
api_router,
eggs_router,
events_router,
feed_router,
@@ -143,6 +144,9 @@ def create_app(
app.state.settings = settings
app.state.db = db
# Setup toast notifications with 5 second duration
setup_toasts(app, duration=5000)
# Register exception handlers for auth errors
async def authentication_error_handler(request, exc):
return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
@@ -157,6 +161,7 @@ def create_app(
health_router.to_app(app)
actions_router.to_app(app)
animals_router.to_app(app)
api_router.to_app(app)
eggs_router.to_app(app)
events_router.to_app(app)
feed_router.to_app(app)

View File

@@ -3,6 +3,7 @@
from animaltrack.web.routes.actions import ar as actions_router
from animaltrack.web.routes.animals import ar as animals_router
from animaltrack.web.routes.api import ar as api_router
from animaltrack.web.routes.eggs import ar as eggs_router
from animaltrack.web.routes.events import ar as events_router
from animaltrack.web.routes.feed import ar as feed_router
@@ -15,6 +16,7 @@ from animaltrack.web.routes.registry import ar as registry_router
__all__ = [
"actions_router",
"animals_router",
"api_router",
"eggs_router",
"events_router",
"feed_router",

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import APIRouter, to_xml
from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -119,7 +118,7 @@ def cohort_index(request: Request):
@ar("/actions/animal-cohort", methods=["POST"])
async def animal_cohort(request: Request):
async def animal_cohort(request: Request, session):
"""POST /actions/animal-cohort - Create a new animal cohort."""
db = request.app.state.db
form = await request.form()
@@ -198,8 +197,16 @@ async def animal_cohort(request: Request):
except ValidationError as e:
return _render_cohort_error(request, locations, species_list, str(e), form)
# Add success toast with link to event
animal_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Created {animal_count} {species}(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -210,19 +217,6 @@ async def animal_cohort(request: Request):
),
)
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Created {animal_count} {species}(s)",
"type": "success",
}
}
)
return response
def _render_cohort_error(
request: Request,
@@ -280,7 +274,7 @@ def hatch_index(request: Request):
@ar("/actions/hatch-recorded", methods=["POST"])
async def hatch_recorded(request: Request):
async def hatch_recorded(request: Request, session):
"""POST /actions/hatch-recorded - Record a hatch event."""
db = request.app.state.db
form = await request.form()
@@ -346,8 +340,16 @@ async def hatch_recorded(request: Request):
except ValidationError as e:
return _render_hatch_error(request, locations, species_list, str(e), form)
# Add success toast with link to event
animal_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Recorded {animal_count} hatchling(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -358,19 +360,6 @@ async def hatch_recorded(request: Request):
),
)
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {animal_count} hatchling(s)",
"type": "success",
}
}
)
return response
def _render_hatch_error(
request: Request,
@@ -547,6 +536,7 @@ def tag_add_index(request: Request):
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
animals = []
if filter_str:
filter_ast = parse_filter(filter_str)
@@ -555,6 +545,9 @@ 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)
return render_page(
request,
@@ -564,6 +557,7 @@ def tag_add_index(request: Request):
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
),
title="Add Tag - AnimalTrack",
active_nav=None,
@@ -571,7 +565,7 @@ def tag_add_index(request: Request):
@ar("/actions/animal-tag-add", methods=["POST"])
async def animal_tag_add(request: Request):
async def animal_tag_add(request: Request, session):
"""POST /actions/animal-tag-add - Add tag to animals."""
db = request.app.state.db
form = await request.form()
@@ -589,12 +583,22 @@ async def animal_tag_add(request: Request):
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to tag
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: tag required
if not tag:
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
# Validation: must have animals
if not resolved_ids:
if not ids_for_validation:
return _render_tag_add_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
@@ -605,6 +609,8 @@ async def animal_tag_add(request: Request):
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
)
# Validate selection (check for concurrent changes)
@@ -631,14 +637,26 @@ async def animal_tag_add(request: Request):
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
# Determine which IDs to use for the update
if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_tag = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_tag = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_tag = current_resolution.animal_ids
else:
ids_to_tag = resolved_ids
ids_to_tag = list(resolved_ids)
# Check we still have animals
if not ids_to_tag:
@@ -667,8 +685,16 @@ async def animal_tag_add(request: Request):
except ValidationError as e:
return _render_tag_add_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
actually_tagged = event.entity_refs.get("actually_tagged", [])
add_toast(
session,
f"Tagged {len(actually_tagged)} animal(s) as '{tag}'. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -679,19 +705,6 @@ async def animal_tag_add(request: Request):
),
)
# Add toast trigger header
actually_tagged = event.entity_refs.get("actually_tagged", [])
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Tagged {len(actually_tagged)} animal(s) as '{tag}'",
"type": "success",
}
}
)
return response
def _render_tag_add_error_form(request, db, filter_str, error_message):
"""Render tag add form with error message."""
@@ -774,6 +787,7 @@ def tag_end_index(request: Request):
resolved_ids: list[str] = []
roster_hash = ""
active_tags: list[str] = []
animals = []
if filter_str:
filter_ast = parse_filter(filter_str)
@@ -783,6 +797,9 @@ def tag_end_index(request: Request):
if resolved_ids:
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)
return render_page(
request,
@@ -793,6 +810,7 @@ def tag_end_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
active_tags=active_tags,
animals=animals,
),
title="End Tag - AnimalTrack",
active_nav=None,
@@ -800,7 +818,7 @@ def tag_end_index(request: Request):
@ar("/actions/animal-tag-end", methods=["POST"])
async def animal_tag_end(request: Request):
async def animal_tag_end(request: Request, session):
"""POST /actions/animal-tag-end - End tag on animals."""
db = request.app.state.db
form = await request.form()
@@ -818,12 +836,22 @@ async def animal_tag_end(request: Request):
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to untag
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: tag required
if not tag:
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
# Validation: must have animals
if not resolved_ids:
if not ids_for_validation:
return _render_tag_end_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
@@ -834,6 +862,8 @@ async def animal_tag_end(request: Request):
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
)
# Validate selection (check for concurrent changes)
@@ -860,14 +890,26 @@ async def animal_tag_end(request: Request):
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
# Determine which IDs to use for the update
if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_untag = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_untag = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_untag = current_resolution.animal_ids
else:
ids_to_untag = resolved_ids
ids_to_untag = list(resolved_ids)
# Check we still have animals
if not ids_to_untag:
@@ -896,8 +938,16 @@ async def animal_tag_end(request: Request):
except ValidationError as e:
return _render_tag_end_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
actually_ended = event.entity_refs.get("actually_ended", [])
add_toast(
session,
f"Ended tag '{tag}' on {len(actually_ended)} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -908,19 +958,6 @@ async def animal_tag_end(request: Request):
),
)
# Add toast trigger header
actually_ended = event.entity_refs.get("actually_ended", [])
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Ended tag '{tag}' on {len(actually_ended)} animal(s)",
"type": "success",
}
}
)
return response
def _render_tag_end_error_form(request, db, filter_str, error_message):
"""Render tag end form with error message."""
@@ -977,6 +1014,7 @@ def attrs_index(request: Request):
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
animals = []
if filter_str:
filter_ast = parse_filter(filter_str)
@@ -985,6 +1023,9 @@ 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)
return render_page(
request,
@@ -994,6 +1035,7 @@ def attrs_index(request: Request):
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
),
title="Update Attributes - AnimalTrack",
active_nav=None,
@@ -1001,7 +1043,7 @@ def attrs_index(request: Request):
@ar("/actions/animal-attrs", methods=["POST"])
async def animal_attrs(request: Request):
async def animal_attrs(request: Request, session):
"""POST /actions/animal-attrs - Update attributes on animals."""
db = request.app.state.db
form = await request.form()
@@ -1021,6 +1063,16 @@ async def animal_attrs(request: Request):
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to update
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: at least one attribute required
if not sex and not life_stage and not repro_status:
return _render_attrs_error_form(
@@ -1028,7 +1080,7 @@ async def animal_attrs(request: Request):
)
# Validation: must have animals
if not resolved_ids:
if not ids_for_validation:
return _render_attrs_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
@@ -1039,6 +1091,8 @@ async def animal_attrs(request: Request):
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
)
# Validate selection (check for concurrent changes)
@@ -1067,14 +1121,26 @@ async def animal_attrs(request: Request):
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
# Determine which IDs to use for the update
if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_update = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_update = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids
else:
ids_to_update = resolved_ids
ids_to_update = list(resolved_ids)
# Check we still have animals
if not ids_to_update:
@@ -1108,8 +1174,16 @@ async def animal_attrs(request: Request):
except ValidationError as e:
return _render_attrs_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
updated_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Updated attributes on {updated_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -1120,19 +1194,6 @@ async def animal_attrs(request: Request):
),
)
# Add toast trigger header
updated_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Updated attributes on {updated_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_attrs_error_form(request, db, filter_str, error_message):
"""Render attributes form with error message."""
@@ -1186,6 +1247,7 @@ def outcome_index(request: Request):
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
animals = []
if filter_str:
filter_ast = parse_filter(filter_str)
@@ -1194,6 +1256,9 @@ 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)
@@ -1208,6 +1273,7 @@ def outcome_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
products=products,
animals=animals,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
@@ -1215,7 +1281,7 @@ def outcome_index(request: Request):
@ar("/actions/animal-outcome", methods=["POST"])
async def animal_outcome(request: Request):
async def animal_outcome(request: Request, session):
"""POST /actions/animal-outcome - Record outcome for animals."""
db = request.app.state.db
form = await request.form()
@@ -1256,6 +1322,16 @@ async def animal_outcome(request: Request):
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to update
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Validation: outcome required
if not outcome_str:
return _render_outcome_error_form(request, db, filter_str, "Please select an outcome")
@@ -1269,7 +1345,7 @@ async def animal_outcome(request: Request):
)
# Validation: must have animals
if not resolved_ids:
if not ids_for_validation:
return _render_outcome_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
@@ -1280,6 +1356,8 @@ async def animal_outcome(request: Request):
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
)
# Validate selection (check for concurrent changes)
@@ -1311,14 +1389,26 @@ async def animal_outcome(request: Request):
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
# Determine which IDs to use for the update
if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_update = [sid for sid in selected_ids if sid in current_set]
else:
ids_to_update = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids
else:
ids_to_update = resolved_ids
ids_to_update = list(resolved_ids)
# Check we still have animals
if not ids_to_update:
@@ -1366,11 +1456,19 @@ async def animal_outcome(request: Request):
except ValidationError as e:
return _render_outcome_error_form(request, db, filter_str, str(e))
# Add success toast with link to event
outcome_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Recorded {outcome_str} for {outcome_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -1388,19 +1486,6 @@ async def animal_outcome(request: Request):
),
)
# Add toast trigger header
outcome_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {outcome_str} for {outcome_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_outcome_error_form(request, db, filter_str, error_message):
"""Render outcome form with error message."""
@@ -1485,7 +1570,7 @@ async def status_correct_index(req: Request):
@ar("/actions/animal-status-correct", methods=["POST"])
@require_role(UserRole.ADMIN)
async def animal_status_correct(req: Request):
async def animal_status_correct(req: Request, session):
"""POST /actions/animal-status-correct - Correct status of animals (admin-only)."""
db = req.app.state.db
form = await req.form()
@@ -1598,8 +1683,16 @@ async def animal_status_correct(req: Request):
except ValidationError as e:
return _render_status_correct_error_form(req, db, filter_str, str(e))
# Add success toast with link to event
corrected_count = len(event.entity_refs.get("animal_ids", []))
add_toast(
session,
f"Corrected status to {new_status_str} for {corrected_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
req,
@@ -1616,19 +1709,6 @@ async def animal_status_correct(req: Request):
),
)
# Add toast trigger header
corrected_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Corrected status to {new_status_str} for {corrected_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_status_correct_error_form(request, db, filter_str, error_message):
"""Render status correct form with error message."""

View File

@@ -0,0 +1,84 @@
# ABOUTME: API routes for HTMX partial updates.
# ABOUTME: Provides endpoints for selection preview and hash computation.
from __future__ import annotations
import time
from fasthtml.common import APIRouter
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.web.templates.animal_select import animal_checkbox_list
# APIRouter for multi-file route organization
ar = APIRouter()
@ar("/api/compute-hash", methods=["POST"])
async def compute_hash(request: Request):
"""POST /api/compute-hash - Compute roster hash for selected IDs.
Expects JSON body with:
- selected_ids: list of animal IDs
- from_location_id: optional location ID
Returns JSON with:
- roster_hash: computed hash string
"""
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
selected_ids = body.get("selected_ids", [])
from_location_id = body.get("from_location_id") or None
roster_hash = compute_roster_hash(selected_ids, from_location_id)
return JSONResponse({"roster_hash": roster_hash})
@ar("/api/selection-preview")
def selection_preview(request: Request):
"""GET /api/selection-preview - Get animal checkbox list for filter.
Query params:
- filter: DSL filter string
- selected_ids: optional comma-separated IDs to pre-select
Returns HTML partial with checkbox list.
"""
db = request.app.state.db
filter_str = request.query_params.get("filter", "")
selected_param = request.query_params.get("selected_ids", "")
# Parse pre-selected IDs if provided
selected_ids = None
if selected_param:
selected_ids = [s.strip() for s in selected_param.split(",") if s.strip()]
# Resolve filter to get animals
ts_utc = int(time.time() * 1000)
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
if not resolution.animal_ids:
return HTMLResponse(
content="<p class='text-stone-500 text-sm'>No animals match this filter</p>"
)
# Get animal details
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolution.animal_ids)
# If no pre-selection, default to all selected
if selected_ids is None:
selected_ids = [a.animal_id for a in animals]
# Render checkbox list
from fasthtml.common import to_xml
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import APIRouter, to_xml
from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -112,7 +111,7 @@ def egg_index(request: Request):
@ar("/actions/product-collected", methods=["POST"])
async def product_collected(request: Request):
async def product_collected(request: Request, session):
"""POST /actions/product-collected - Record egg collection."""
db = request.app.state.db
form = await request.form()
@@ -181,7 +180,7 @@ async def product_collected(request: Request):
# Collect product
try:
product_service.collect_product(
event = product_service.collect_product(
payload=payload,
ts_utc=ts_utc,
actor=actor,
@@ -202,8 +201,15 @@ async def product_collected(request: Request):
)
)
# Add success toast with link to event
add_toast(
session,
f"Recorded {quantity} eggs. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with location sticking, qty cleared
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -221,16 +227,9 @@ async def product_collected(request: Request):
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{"showToast": {"message": f"Recorded {quantity} eggs", "type": "success"}}
)
return response
@ar("/actions/product-sold", methods=["POST"])
async def product_sold(request: Request):
async def product_sold(request: Request, session):
"""POST /actions/product-sold - Record product sale (from Eggs page Sell tab)."""
db = request.app.state.db
form = await request.form()
@@ -303,7 +302,7 @@ async def product_sold(request: Request):
# Sell product
try:
product_service.sell_product(
event = product_service.sell_product(
payload=payload,
ts_utc=ts_utc,
actor=actor,
@@ -313,8 +312,15 @@ async def product_sold(request: Request):
except ValidationError as e:
return _render_sell_error(request, locations, products, product_code, str(e))
# Add success toast with link to event
add_toast(
session,
f"Recorded sale of {quantity} {product_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with product sticking
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -332,13 +338,6 @@ async def product_sold(request: Request):
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{"showToast": {"message": f"Recorded sale of {quantity} {product_code}", "type": "success"}}
)
return response
def _render_harvest_error(request, locations, products, selected_location_id, error_message):
"""Render harvest form with error message.

View File

@@ -1,5 +1,5 @@
# ABOUTME: Routes for event log functionality.
# ABOUTME: Handles GET /event-log for viewing location event history.
# ABOUTME: Routes for event log and event detail functionality.
# ABOUTME: Handles GET /event-log for location event history and GET /events/{id} for event details.
from __future__ import annotations
@@ -10,9 +10,11 @@ from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.events.store import EventStore
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.web.templates import render_page
from animaltrack.web.templates.event_detail import event_detail_panel
from animaltrack.web.templates.events import event_log_list, event_log_panel
# APIRouter for multi-file route organization
@@ -105,3 +107,72 @@ def event_log_index(request: Request):
title="Event Log - AnimalTrack",
active_nav="event_log",
)
def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
"""Get animals affected by an event with display info.
Args:
db: Database connection.
event_id: Event ID to look up animals for.
Returns:
List of animal dicts with id, nickname, species_name.
"""
rows = db.execute(
"""
SELECT ar.id, ar.nickname, s.name as species_name
FROM event_animals ea
JOIN animal_registry ar ON ar.id = ea.animal_id
JOIN species s ON s.code = ar.species_code
WHERE ea.event_id = ?
ORDER BY ar.nickname NULLS LAST, ar.id
""",
(event_id,),
).fetchall()
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
@ar("/events/{event_id}")
def event_detail(request: Request, event_id: str):
"""GET /events/{event_id} - Event detail panel for slide-over."""
db = request.app.state.db
# Get event from store
event_store = EventStore(db)
event = event_store.get_event(event_id)
if event is None:
return HTMLResponse(
content="<div class='p-4 text-red-400'>Event not found</div>",
status_code=404,
)
# Check if tombstoned
is_tombstoned = event_store.is_tombstoned(event_id)
# Get affected animals
affected_animals = get_event_animals(db, event_id)
# Get location names if entity_refs has location IDs
location_names = {}
location_ids = []
if "location_id" in event.entity_refs:
location_ids.append(event.entity_refs["location_id"])
if "from_location_id" in event.entity_refs:
location_ids.append(event.entity_refs["from_location_id"])
if "to_location_id" in event.entity_refs:
location_ids.append(event.entity_refs["to_location_id"])
if location_ids:
loc_repo = LocationRepository(db)
for loc_id in location_ids:
loc = loc_repo.get(loc_id)
if loc:
location_names[loc_id] = loc.name
# Return slide-over panel HTML
return HTMLResponse(
content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)),
)

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import APIRouter
from fasthtml.common import APIRouter, add_toast
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -109,7 +108,7 @@ def feed_index(request: Request):
@ar("/actions/feed-given", methods=["POST"])
async def feed_given(request: Request):
async def feed_given(request: Request, session):
"""POST /actions/feed-given - Record feed given."""
db = request.app.state.db
form = await request.form()
@@ -202,7 +201,7 @@ async def feed_given(request: Request):
# Give feed
try:
feed_service.give_feed(
event = feed_service.give_feed(
payload=payload,
ts_utc=ts_utc,
actor=actor,
@@ -238,8 +237,15 @@ async def feed_given(request: Request):
)
)
# Add success toast with link to event
add_toast(
session,
f"Recorded {amount_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with location/type sticking, amount reset
response = HTMLResponse(
return HTMLResponse(
content=str(
render_page(
request,
@@ -260,21 +266,9 @@ async def feed_given(request: Request):
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {amount_kg}kg {feed_type_code}",
"type": "success",
}
}
)
return response
@ar("/actions/feed-purchased", methods=["POST"])
async def feed_purchased(request: Request):
async def feed_purchased(request: Request, session):
"""POST /actions/feed-purchased - Record feed purchase."""
db = request.app.state.db
form = await request.form()
@@ -384,7 +378,7 @@ async def feed_purchased(request: Request):
# Purchase feed
try:
feed_service.purchase_feed(
event = feed_service.purchase_feed(
payload=payload,
ts_utc=ts_utc,
actor=actor,
@@ -402,8 +396,15 @@ async def feed_purchased(request: Request):
# Calculate total for toast
total_kg = bag_size_kg * bags_count
# Add success toast with link to event
add_toast(
session,
f"Purchased {total_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render form with fields cleared
response = HTMLResponse(
return HTMLResponse(
content=str(
render_page(
request,
@@ -420,18 +421,6 @@ async def feed_purchased(request: Request):
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Purchased {total_kg}kg {feed_type_code}",
"type": "success",
}
}
)
return response
def _render_give_error(
request,

View File

@@ -3,11 +3,10 @@
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import APIRouter, to_xml
from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -17,6 +16,7 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
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.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
@@ -100,6 +100,7 @@ def move_index(request: Request):
roster_hash = ""
from_location_id = None
from_location_name = None
animals = []
if filter_str or not request.query_params:
# If no filter, default to empty (show all alive animals)
@@ -110,6 +111,9 @@ def move_index(request: Request):
if resolved_ids:
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)
return render_page(
request,
@@ -123,6 +127,7 @@ def move_index(request: Request):
resolved_count=len(resolved_ids),
from_location_name=from_location_name,
action=animal_move,
animals=animals,
),
title="Move - AnimalTrack",
active_nav="move",
@@ -130,7 +135,7 @@ def move_index(request: Request):
@ar("/actions/animal-move", methods=["POST"])
async def animal_move(request: Request):
async def animal_move(request: Request, session):
"""POST /actions/animal-move - Move animals to new location."""
db = request.app.state.db
form = await request.form()
@@ -149,6 +154,16 @@ async def animal_move(request: Request):
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Check for subset mode (user selected specific animals from checkboxes)
subset_mode = form.get("subset_mode", "") == "true"
selected_ids = form.getlist("selected_ids") if subset_mode else None
# In subset mode, use selected_ids as the animals to move
if subset_mode and selected_ids:
ids_for_validation = list(selected_ids)
else:
ids_for_validation = list(resolved_ids)
# Get locations for potential re-render
locations = LocationRepository(db).list_active()
@@ -157,7 +172,7 @@ async def animal_move(request: Request):
return _render_error_form(request, db, locations, filter_str, "Please select a destination")
# Validation: must have animals
if not resolved_ids:
if not ids_for_validation:
return _render_error_form(request, db, locations, filter_str, "No animals selected to move")
# Validation: destination must be different from source
@@ -186,6 +201,8 @@ async def animal_move(request: Request):
ts_utc=ts_utc,
from_location_id=from_location_id,
confirmed=confirmed,
subset_mode=subset_mode,
selected_ids=selected_ids,
)
# Validate selection (check for concurrent changes)
@@ -215,11 +232,22 @@ async def animal_move(request: Request):
status_code=409,
)
# When confirmed, re-resolve to get current server IDs (per spec: "server re-resolves")
if confirmed:
# Re-resolve the filter at current timestamp to get animals still matching
# Use max of current time and form's ts_utc to ensure we resolve at least
# as late as the submission - important when moves happened after client's resolution
# Determine which IDs to use for the move
if subset_mode and selected_ids:
# In subset mode, use the selected IDs from checkboxes
if confirmed:
# When confirmed, filter selected IDs against current resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
current_set = set(current_resolution.animal_ids)
ids_to_move = [sid for sid in selected_ids if sid in current_set]
# Update from_location_id based on filtered selected IDs
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
else:
ids_to_move = list(selected_ids)
elif confirmed:
# Standard mode with confirmation - re-resolve to get current server IDs
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
@@ -227,7 +255,7 @@ async def animal_move(request: Request):
# Update from_location_id based on current resolution
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
else:
ids_to_move = resolved_ids
ids_to_move = list(resolved_ids)
# Check we still have animals to move after validation
if not ids_to_move:
@@ -257,14 +285,21 @@ async def animal_move(request: Request):
# Move animals
try:
animal_service.move_animals(
event = animal_service.move_animals(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
)
except ValidationError as e:
return _render_error_form(request, db, locations, filter_str, str(e))
# Add success toast with link to event
add_toast(
session,
f"Moved {len(ids_to_move)} animals to {dest_location.name}. <a href='/events/{event.id}' class='underline'>View event →</a>",
"success",
)
# Success: re-render fresh form (nothing sticks per spec)
response = HTMLResponse(
return HTMLResponse(
content=to_xml(
render_page(
request,
@@ -278,18 +313,6 @@ async def animal_move(request: Request):
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Moved {len(ids_to_move)} animals to {dest_location.name}",
"type": "success",
}
}
)
return response
def _render_error_form(request, db, locations, filter_str, error_message):
"""Render form with error message.

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Script, Span
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span
from monsterui.all import (
Alert,
AlertT,
@@ -45,7 +45,6 @@ def event_datetime_field(
Returns:
Div containing the datetime picker with toggle functionality.
"""
toggle_id = f"{field_id}_toggle"
picker_id = f"{field_id}_picker"
input_id = f"{field_id}_input"
@@ -54,38 +53,31 @@ def event_datetime_field(
picker_style = "display: block;" if has_initial else "display: none;"
toggle_text = "Use current time" if has_initial else "Set custom date"
# JavaScript for toggle and conversion
script = f"""
(function() {{
var toggle = document.getElementById('{toggle_id}');
# Inline JavaScript for toggle click handler
toggle_onclick = f"""
var picker = document.getElementById('{picker_id}');
var input = document.getElementById('{input_id}');
var tsField = document.querySelector('input[name="ts_utc"]');
if (picker.style.display === 'none') {{
picker.style.display = 'block';
this.textContent = 'Use current time';
}} else {{
picker.style.display = 'none';
this.textContent = 'Set custom date';
input.value = '';
if (tsField) tsField.value = '0';
}}
"""
if (!toggle || !picker || !input) return;
toggle.addEventListener('click', function(e) {{
e.preventDefault();
if (picker.style.display === 'none') {{
picker.style.display = 'block';
toggle.textContent = 'Use current time';
}} else {{
picker.style.display = 'none';
toggle.textContent = 'Set custom date';
input.value = '';
if (tsField) tsField.value = '0';
}}
}});
input.addEventListener('change', function() {{
if (tsField && input.value) {{
var date = new Date(input.value);
tsField.value = date.getTime().toString();
}} else if (tsField) {{
tsField.value = '0';
}}
}});
}})();
# Inline JavaScript for input change handler
input_onchange = """
var tsField = document.querySelector('input[name="ts_utc"]');
if (tsField && this.value) {
var date = new Date(this.value);
tsField.value = date.getTime().toString();
} else if (tsField) {
tsField.value = '0';
}
"""
return Div(
@@ -96,8 +88,8 @@ def event_datetime_field(
" - ",
Span(
toggle_text,
id=toggle_id,
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
hx_on_click=toggle_onclick,
),
cls="text-sm",
),
@@ -108,6 +100,7 @@ def event_datetime_field(
type="datetime-local",
value=initial_value,
cls="uk-input w-full mt-2",
hx_on_change=input_onchange,
),
P(
"Select date/time for this event (leave empty for current time)",
@@ -119,7 +112,6 @@ def event_datetime_field(
cls="mt-1",
),
Hidden(name="ts_utc", value=initial_ts),
Script(script),
cls="space-y-1",
)
@@ -544,6 +536,7 @@ def tag_add_form(
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-add",
animals: list | None = None,
) -> Form:
"""Create the Add Tag form.
@@ -555,22 +548,36 @@ def tag_add_form(
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns:
Form component for adding tags to animals.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if animals is None:
animals = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
# Selection component - show checkboxes if animals provided and > 1
selection_component = None
subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
@@ -579,7 +586,7 @@ def tag_add_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
@@ -601,8 +608,8 @@ def tag_add_form(
value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck',
),
# Selection preview
selection_preview,
# Selection component (checkboxes or simple count)
selection_component,
# Tag input
LabelInput(
"Tag",
@@ -623,6 +630,7 @@ def tag_add_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary),
@@ -727,6 +735,7 @@ def tag_end_form(
active_tags: list[str] | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-end",
animals: list | None = None,
) -> Form:
"""Create the End Tag form.
@@ -739,24 +748,38 @@ def tag_end_form(
active_tags: List of tags active on selected animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns:
Form component for ending tags on animals.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if active_tags is None:
active_tags = []
if animals is None:
animals = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
# Selection component - show checkboxes if animals provided and > 1
selection_component = None
subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
@@ -765,7 +788,7 @@ def tag_end_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
@@ -792,8 +815,8 @@ def tag_end_form(
value=filter_str,
placeholder="e.g., tag:layer-birds species:duck",
),
# Selection preview
selection_preview,
# Selection component (checkboxes or simple count)
selection_component,
# Tag dropdown
LabelSelect(
*tag_options,
@@ -819,6 +842,7 @@ def tag_end_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
@@ -922,6 +946,7 @@ def attrs_form(
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-attrs",
animals: list | None = None,
) -> Form:
"""Create the Update Attributes form.
@@ -933,22 +958,36 @@ def attrs_form(
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns:
Form component for updating animal attributes.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if animals is None:
animals = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
# Selection component - show checkboxes if animals provided and > 1
selection_component = None
subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
@@ -957,7 +996,7 @@ def attrs_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
@@ -1005,8 +1044,8 @@ def attrs_form(
value=filter_str,
placeholder="e.g., species:duck life_stage:juvenile",
),
# Selection preview
selection_preview,
# Selection component (checkboxes or simple count)
selection_component,
# Attribute dropdowns
LabelSelect(
*sex_options,
@@ -1039,6 +1078,7 @@ def attrs_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary),
@@ -1149,6 +1189,7 @@ def outcome_form(
products: list[tuple[str, str]] | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-outcome",
animals: list | None = None,
) -> Form:
"""Create the Record Outcome form.
@@ -1161,24 +1202,39 @@ def outcome_form(
products: List of (code, name) tuples for product dropdown.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns:
Form component for recording animal outcomes.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if products is None:
products = []
if animals is None:
animals = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
# Selection component - show checkboxes if animals provided and > 1
selection_component = None
subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_component = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
# Fallback to simple count display
selection_component = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
@@ -1187,7 +1243,7 @@ def outcome_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
@@ -1267,7 +1323,7 @@ def outcome_form(
return Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component,
selection_preview,
selection_component,
# Filter field
LabelInput(
label="Filter (DSL)",
@@ -1307,6 +1363,7 @@ def outcome_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive),

View File

@@ -223,7 +223,7 @@ def animal_timeline_list(timeline: list[TimelineEvent]) -> Ul:
def timeline_event_item(event: TimelineEvent) -> Li:
"""Single timeline event item."""
"""Single timeline event item - clickable to view details."""
badge_cls = event_type_badge_class(event.event_type)
summary_text = format_timeline_summary(event.event_type, event.summary)
time_str = format_timestamp(event.ts_utc)
@@ -236,7 +236,11 @@ def timeline_event_item(event: TimelineEvent) -> Li:
),
P(summary_text, cls="text-sm text-stone-300"),
P(f"by {event.actor}", cls="text-xs text-stone-500"),
cls="py-3 border-b border-stone-700 last:border-0",
cls="py-3 border-b border-stone-700 last:border-0 cursor-pointer "
"hover:bg-stone-800/50 -mx-2 px-2 rounded transition-colors",
hx_get=f"/events/{event.event_id}",
hx_target="#event-slide-over",
hx_swap="innerHTML",
)

View File

@@ -0,0 +1,192 @@
# ABOUTME: Animal selection component with checkboxes.
# ABOUTME: Renders a list of animals with checkboxes for subset selection.
from fasthtml.common import Div, Input, Label, P, Span
from animaltrack.repositories.animals import AnimalListItem
def animal_checkbox_list(
animals: list[AnimalListItem],
selected_ids: list[str] | None = None,
max_display: int = 50,
) -> Div:
"""Render a list of animals with checkboxes for selection.
Args:
animals: List of animals to display.
selected_ids: IDs that should be pre-checked. If None, all are checked.
max_display: Maximum animals to show before truncating.
Returns:
Div containing the checkbox list.
"""
if selected_ids is None:
selected_ids = [a.animal_id for a in animals]
selected_set = set(selected_ids)
items = []
for animal in animals[:max_display]:
is_checked = animal.animal_id in selected_set
display_name = animal.nickname or animal.animal_id[:8] + "..."
items.append(
Label(
Input(
type="checkbox",
name="selected_ids",
value=animal.animal_id,
checked=is_checked,
cls="uk-checkbox mr-2",
hx_on_change="updateSelectionCount()",
),
Span(display_name, cls="text-stone-200"),
Span(
f" ({animal.species_code}, {animal.location_name})",
cls="text-stone-500 text-sm",
),
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",
)
)
# Show truncation message if needed
if len(animals) > max_display:
remaining = len(animals) - max_display
items.append(
P(
f"... and {remaining} more",
cls="text-stone-500 text-sm pl-6 py-1",
)
)
# Selection count display
count_display = Div(
Span(
f"{len(selected_ids)} of {len(animals)} selected",
id="selection-count-text",
cls="text-stone-400 text-sm",
),
Div(
Span(
"Select all",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm mr-3",
hx_on_click="selectAllAnimals(true)",
),
Span(
"Select none",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
hx_on_click="selectAllAnimals(false)",
),
cls="flex gap-2",
),
cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2",
)
return Div(
count_display,
Div(
*items,
cls="max-h-64 overflow-y-auto",
),
# Hidden field for roster_hash - will be updated via JS
Input(type="hidden", name="roster_hash", id="roster-hash-field"),
# Script for selection management
selection_script(len(animals)),
id="animal-selection-list",
cls="border border-stone-700 rounded-lg p-3 bg-stone-900/50",
)
def selection_script(total_count: int) -> Div:
"""JavaScript for managing selection state.
Args:
total_count: Total number of animals in the list.
Returns:
Script element with selection management code.
"""
from fasthtml.common import Script
return Script(f"""
function updateSelectionCount() {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
var checked = Array.from(checkboxes).filter(cb => cb.checked).length;
var countText = document.getElementById('selection-count-text');
if (countText) {{
countText.textContent = checked + ' of {total_count} selected';
}}
// Trigger hash recomputation if needed
computeSelectionHash();
}}
function selectAllAnimals(selectAll) {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
checkboxes.forEach(function(cb) {{
cb.checked = selectAll;
}});
updateSelectionCount();
}}
function getSelectedIds() {{
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}}
function computeSelectionHash() {{
// Get selected IDs and compute hash via API
var selectedIds = getSelectedIds();
var fromLocationId = document.querySelector('input[name="from_location_id"]');
var fromLoc = fromLocationId ? fromLocationId.value : '';
fetch('/api/compute-hash', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
selected_ids: selectedIds,
from_location_id: fromLoc
}})
}})
.then(response => response.json())
.then(data => {{
var hashField = document.getElementById('roster-hash-field');
if (hashField) {{
hashField.value = data.roster_hash;
}}
}});
}}
// Initialize hash on load
document.addEventListener('DOMContentLoaded', function() {{
computeSelectionHash();
}});
""")
def selection_summary(count: int, total: int) -> Div:
"""Render a selection summary when all animals are selected.
Shows "X animals selected" with option to customize.
Args:
count: Number of selected animals.
total: Total animals matching filter.
Returns:
Div with selection summary.
"""
return Div(
P(
f"{count} animals selected",
cls="text-stone-300",
),
Span(
"Customize selection",
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
hx_get="", # Will be set by caller
hx_target="#selection-container",
hx_swap="innerHTML",
),
cls="flex justify-between items-center py-2",
)

View File

@@ -1,7 +1,7 @@
# ABOUTME: Base HTML template for AnimalTrack pages.
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
from fasthtml.common import Container, Div, Script, Title
from fasthtml.common import Container, Div, Script, Style, Title
from starlette.requests import Request
from animaltrack.models.reference import UserRole
@@ -14,65 +14,84 @@ from animaltrack.web.templates.sidebar import (
)
def toast_container():
"""Create a toast container for displaying notifications.
def EventSlideOverStyles(): # noqa: N802
"""CSS styles for event detail slide-over panel."""
return Style("""
/* Event slide-over panel - slides from right */
#event-slide-over {
transform: translateX(100%);
transition: transform 0.3s ease-out;
}
This container holds toast notifications that appear in the top-right corner.
Toasts are triggered via HTMX events (HX-Trigger header with showToast).
"""
#event-slide-over.open {
transform: translateX(0);
}
/* Backdrop overlay for event panel */
#event-backdrop {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-out;
}
#event-backdrop.open {
opacity: 1;
pointer-events: auto;
}
""")
def EventSlideOverScript(): # noqa: N802
"""JavaScript for event slide-over panel open/close behavior."""
return Script("""
function openEventPanel() {
document.getElementById('event-slide-over').classList.add('open');
document.getElementById('event-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
// Focus the panel for keyboard events
document.getElementById('event-slide-over').focus();
}
function closeEventPanel() {
document.getElementById('event-slide-over').classList.remove('open');
document.getElementById('event-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
// HTMX event: after loading event content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'event-slide-over') {
openEventPanel();
}
});
""")
def EventSlideOver(): # noqa: N802
"""Event detail slide-over panel container."""
return Div(
id="toast-container",
cls="toast toast-end toast-top z-50",
# Backdrop
Div(
id="event-backdrop",
cls="fixed inset-0 bg-black/60 z-40",
hx_on_click="closeEventPanel()",
),
# Slide-over panel
Div(
# Content loaded via HTMX
Div(
id="event-panel-content",
cls="h-full",
),
id="event-slide-over",
cls="fixed top-0 right-0 bottom-0 w-96 max-w-full bg-[#141413] z-50 "
"shadow-2xl border-l border-stone-700 overflow-hidden",
tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
),
)
def toast_script():
"""JavaScript to handle showToast events from HTMX.
Listens for the showToast event and creates toast notifications
that auto-dismiss after 5 seconds.
"""
script = """
document.body.addEventListener('showToast', function(evt) {
var message = evt.detail.message || 'Action completed';
var type = evt.detail.type || 'success';
// Create alert element with appropriate styling
var alertClass = 'alert shadow-lg mb-2 ';
if (type === 'success') {
alertClass += 'alert-success';
} else if (type === 'error') {
alertClass += 'alert-error';
} else if (type === 'warning') {
alertClass += 'alert-warning';
} else {
alertClass += 'alert-info';
}
var toast = document.createElement('div');
toast.className = alertClass;
toast.innerHTML = '<span>' + message + '</span>';
var container = document.getElementById('toast-container');
if (container) {
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(function() {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 500);
}, 5000);
}
});
"""
return Script(script)
def page(
content,
title: str = "AnimalTrack",
@@ -88,7 +107,7 @@ def page(
- Desktop sidebar (hidden on mobile)
- Mobile bottom nav (hidden on desktop)
- Mobile menu drawer
- Toast container for notifications
- Event detail slide-over panel
Args:
content: Page content (FT components)
@@ -104,12 +123,15 @@ def page(
Title(title),
BottomNavStyles(),
SidebarStyles(),
toast_script(),
EventSlideOverStyles(),
SidebarScript(),
EventSlideOverScript(),
# Desktop sidebar
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
# Mobile menu drawer
MenuDrawer(user_role=user_role),
# Event detail slide-over panel
EventSlideOver(),
# Main content with responsive padding/margin
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
# md:ml-60 to offset for desktop sidebar
@@ -120,7 +142,6 @@ def page(
hx_target="body",
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
),
toast_container(),
# Mobile bottom nav
BottomNav(active_id=active_nav),
)

View File

@@ -0,0 +1,322 @@
# ABOUTME: Template for event detail slide-over panel.
# ABOUTME: Renders event information, payload details, and affected animals.
from datetime import UTC, datetime
from typing import Any
from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul
from animaltrack.models.events import Event
def format_timestamp(ts_utc: int) -> str:
"""Format timestamp for display."""
dt = datetime.fromtimestamp(ts_utc / 1000, tz=UTC)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def event_detail_panel(
event: Event,
affected_animals: list[dict[str, Any]],
is_tombstoned: bool = False,
location_names: dict[str, str] | None = None,
) -> Div:
"""Event detail slide-over panel content.
Args:
event: The event to display.
affected_animals: List of animals affected by this event.
is_tombstoned: Whether the event has been deleted.
location_names: Map of location IDs to names.
Returns:
Div containing the panel content.
"""
if location_names is None:
location_names = {}
return Div(
# Header with close button
Div(
Div(
event_type_badge(event.type),
Span(format_timestamp(event.ts_utc), cls="text-sm text-stone-400 ml-2"),
cls="flex items-center",
),
Button(
"×",
hx_on_click="closeEventPanel()",
cls="text-2xl text-stone-400 hover:text-stone-200 p-2 -mr-2",
type="button",
),
cls="flex items-center justify-between p-4 border-b border-stone-700",
),
# Tombstone warning
tombstone_warning() if is_tombstoned else None,
# Event metadata
Div(
metadata_item("Event ID", event.id),
metadata_item("Actor", event.actor),
metadata_item("Version", str(event.version)),
cls="p-4 space-y-2 border-b border-stone-700",
),
# Payload details
payload_section(event.type, event.payload, location_names),
# Entity references
entity_refs_section(event.entity_refs, location_names),
# Affected animals
affected_animals_section(affected_animals),
id="event-panel-content",
cls="bg-[#141413] h-full overflow-y-auto",
)
def event_type_badge(event_type: str) -> Span:
"""Badge for event type with color coding."""
type_colors = {
"AnimalCohortCreated": "bg-green-900/50 text-green-300",
"AnimalMoved": "bg-purple-900/50 text-purple-300",
"AnimalAttributesUpdated": "bg-blue-900/50 text-blue-300",
"AnimalTagged": "bg-indigo-900/50 text-indigo-300",
"AnimalTagEnded": "bg-slate-700 text-slate-300",
"ProductCollected": "bg-amber-900/50 text-amber-300",
"HatchRecorded": "bg-pink-900/50 text-pink-300",
"AnimalOutcome": "bg-red-900/50 text-red-300",
"AnimalPromoted": "bg-cyan-900/50 text-cyan-300",
"AnimalMerged": "bg-slate-600 text-slate-300",
"AnimalStatusCorrected": "bg-orange-900/50 text-orange-300",
"FeedPurchased": "bg-lime-900/50 text-lime-300",
"FeedGiven": "bg-teal-900/50 text-teal-300",
"ProductSold": "bg-yellow-900/50 text-yellow-300",
}
color_cls = type_colors.get(event_type, "bg-gray-700 text-gray-300")
return Span(event_type, cls=f"text-sm font-medium px-2 py-1 rounded {color_cls}")
def tombstone_warning() -> Div:
"""Warning that event has been deleted."""
return Div(
P(
"⚠ This event has been deleted.",
cls="text-sm text-red-400",
),
cls="p-4 bg-red-900/20 border-b border-red-800",
)
def metadata_item(label: str, value: str) -> Div:
"""Single metadata key-value item."""
return Div(
Span(label + ":", cls="text-stone-500 text-sm"),
Span(value, cls="text-stone-300 text-sm ml-2 font-mono"),
cls="flex",
)
def payload_section(
event_type: str,
payload: dict[str, Any],
location_names: dict[str, str],
) -> Div:
"""Section showing event payload details."""
items = render_payload_items(event_type, payload, location_names)
if not items:
return Div()
return Div(
H3("Details", cls="text-sm font-semibold text-stone-400 mb-2"),
*items,
cls="p-4 border-b border-stone-700",
)
def render_payload_items(
event_type: str,
payload: dict[str, Any],
location_names: dict[str, str],
) -> list:
"""Render payload items based on event type."""
items = []
if event_type == "AnimalCohortCreated":
if "species" in payload:
items.append(payload_item("Species", payload["species"]))
if "count" in payload:
items.append(payload_item("Count", str(payload["count"])))
if "origin" in payload:
items.append(payload_item("Origin", payload["origin"]))
if "sex" in payload:
items.append(payload_item("Sex", payload["sex"]))
if "life_stage" in payload:
items.append(payload_item("Life Stage", payload["life_stage"]))
elif event_type == "AnimalMoved":
from_loc = payload.get("from_location_id", "")
to_loc = payload.get("to_location_id", "")
from_name = location_names.get(from_loc, from_loc[:8] + "..." if from_loc else "")
to_name = location_names.get(to_loc, to_loc[:8] + "..." if to_loc else "")
if from_name:
items.append(payload_item("From", from_name))
if to_name:
items.append(payload_item("To", to_name))
elif event_type == "AnimalTagged":
if "tag" in payload:
items.append(payload_item("Tag", payload["tag"]))
elif event_type == "AnimalTagEnded":
if "tag" in payload:
items.append(payload_item("Tag", payload["tag"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "ProductCollected":
if "product_code" in payload:
items.append(payload_item("Product", payload["product_code"]))
if "quantity" in payload:
items.append(payload_item("Quantity", str(payload["quantity"])))
elif event_type == "AnimalOutcome":
if "outcome" in payload:
items.append(payload_item("Outcome", payload["outcome"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "AnimalPromoted":
if "nickname" in payload:
items.append(payload_item("Nickname", payload["nickname"]))
elif event_type == "AnimalStatusCorrected":
if "old_status" in payload:
items.append(payload_item("Old Status", payload["old_status"]))
if "new_status" in payload:
items.append(payload_item("New Status", payload["new_status"]))
if "reason" in payload:
items.append(payload_item("Reason", payload["reason"]))
elif event_type == "FeedPurchased":
if "feed_code" in payload:
items.append(payload_item("Feed", payload["feed_code"]))
if "quantity_grams" in payload:
kg = payload["quantity_grams"] / 1000
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
if "cost_cents" in payload:
cost = payload["cost_cents"] / 100
items.append(payload_item("Cost", f"${cost:.2f}"))
elif event_type == "FeedGiven":
if "feed_code" in payload:
items.append(payload_item("Feed", payload["feed_code"]))
if "quantity_grams" in payload:
kg = payload["quantity_grams"] / 1000
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
elif event_type == "ProductSold":
if "product_code" in payload:
items.append(payload_item("Product", payload["product_code"]))
if "quantity" in payload:
items.append(payload_item("Quantity", str(payload["quantity"])))
if "price_cents" in payload:
price = payload["price_cents"] / 100
items.append(payload_item("Price", f"${price:.2f}"))
elif event_type == "HatchRecorded":
if "clutch_size" in payload:
items.append(payload_item("Clutch Size", str(payload["clutch_size"])))
if "hatch_count" in payload:
items.append(payload_item("Hatched", str(payload["hatch_count"])))
# Fallback: show raw payload for unknown types
if not items and payload:
for key, value in payload.items():
if not key.startswith("_"):
items.append(payload_item(key, str(value)))
return items
def payload_item(label: str, value: str) -> Div:
"""Single payload item."""
return Div(
Span(label + ":", cls="text-stone-500 text-sm min-w-[100px]"),
Span(value, cls="text-stone-300 text-sm"),
cls="flex gap-2",
)
def entity_refs_section(
entity_refs: dict[str, Any],
location_names: dict[str, str],
) -> Div:
"""Section showing entity references."""
if not entity_refs:
return Div()
items = []
for key, value in entity_refs.items():
# Skip animal_ids - shown in affected animals section
if key == "animal_ids":
continue
display_value = value
# Resolve location names
if key.endswith("_location_id") or key == "location_id":
display_value = location_names.get(value, value[:8] + "..." if value else "")
if isinstance(value, list):
display_value = f"{len(value)} items"
elif isinstance(value, str) and len(value) > 20:
display_value = value[:8] + "..."
items.append(payload_item(key.replace("_", " ").title(), str(display_value)))
if not items:
return Div()
return Div(
H3("References", cls="text-sm font-semibold text-stone-400 mb-2"),
*items,
cls="p-4 border-b border-stone-700",
)
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
"""Section showing affected animals."""
if not animals:
return Div()
animal_items = []
for animal in animals[:20]: # Limit display
display_name = animal.get("nickname") or animal["id"][:8] + "..."
animal_items.append(
Li(
A(
Span(display_name, cls="text-amber-500 hover:underline"),
Span(
f" ({animal.get('species_name', '')})",
cls="text-stone-500 text-xs",
),
href=f"/animals/{animal['id']}",
),
cls="py-1",
)
)
more_count = len(animals) - 20
if more_count > 0:
animal_items.append(
Li(
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
cls="py-1",
)
)
return Div(
H3(
f"Affected Animals ({len(animals)})",
cls="text-sm font-semibold text-stone-400 mb-2",
),
Ul(*animal_items, cls="space-y-1"),
cls="p-4",
)

View File

@@ -24,6 +24,7 @@ def move_form(
from_location_name: str | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-move",
animals: list | None = None,
) -> Form:
"""Create the Move Animals form.
@@ -38,12 +39,17 @@ def move_form(
from_location_name: Name of source location for display.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
Returns:
Form component for moving animals.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if animals is None:
animals = []
# Build destination location options (exclude from_location if set)
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
@@ -59,11 +65,21 @@ def move_form(
cls=AlertT.warning,
)
# Selection preview component
selection_preview = None
if resolved_count > 0:
# Selection component - show checkboxes if animals provided and > 1
selection_component = None
subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
location_info = f" from {from_location_name}" if from_location_name else ""
selection_preview = Div(
selection_component = Div(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
selection_component = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
f" animals selected{location_info}",
@@ -72,7 +88,7 @@ def move_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
selection_component = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
@@ -94,8 +110,8 @@ def move_form(
value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck',
),
# Selection preview
selection_preview,
# Selection component (checkboxes or simple count)
selection_component,
# Destination dropdown
LabelSelect(
*location_options,
@@ -118,6 +134,7 @@ def move_form(
Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""),
Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary),

View File

@@ -77,6 +77,8 @@ def SidebarScript(): # noqa: N802
document.getElementById('menu-drawer').classList.add('open');
document.getElementById('menu-backdrop').classList.add('open');
document.body.style.overflow = 'hidden';
// Focus the drawer for keyboard events
document.getElementById('menu-drawer').focus();
}
function closeMenuDrawer() {
@@ -84,13 +86,6 @@ def SidebarScript(): # noqa: N802
document.getElementById('menu-backdrop').classList.remove('open');
document.body.style.overflow = '';
}
// Close on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeMenuDrawer();
}
});
""")
@@ -255,7 +250,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
Div(
id="menu-backdrop",
cls="fixed inset-0 bg-black/60 z-40",
onclick="closeMenuDrawer()",
hx_on_click="closeMenuDrawer()",
),
# Drawer panel
Div(
@@ -264,7 +259,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"),
Button(
_close_icon(),
onclick="closeMenuDrawer()",
hx_on_click="closeMenuDrawer()",
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
type="button",
),
@@ -277,6 +272,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
),
id="menu-drawer",
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
tabindex="-1",
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
),
cls="md:hidden",
)

View File

@@ -149,7 +149,7 @@ class TestCohortCreationSuccess:
assert count_after == count_before + 3
def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id):
"""Successful cohort creation returns HX-Trigger with toast."""
"""Successful cohort creation stores toast in session."""
resp = client.post(
"/actions/animal-cohort",
data={
@@ -164,8 +164,20 @@ class TestCohortCreationSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
# The session cookie contains base64-encoded toast data with "toasts" key
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
# Base64 decode contains toast message (eyJ0b2FzdHMi... = {"toasts"...)
import base64
# Extract base64 portion from cookie value
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
# FastHTML uses itsdangerous, so format is base64.timestamp.signature
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Created 2 duck" in decoded
class TestCohortCreationValidation:
@@ -363,7 +375,7 @@ class TestHatchRecordingSuccess:
assert count_at_nursery >= 3
def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id):
"""Successful hatch recording returns HX-Trigger with toast."""
"""Successful hatch recording stores toast in session."""
resp = client.post(
"/actions/hatch-recorded",
data={
@@ -375,8 +387,16 @@ class TestHatchRecordingSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
import base64
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Recorded 2 hatchling" in decoded
class TestHatchRecordingValidation:
@@ -709,7 +729,8 @@ class TestTagAddSuccess:
assert tag_count >= len(animals_for_tagging)
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful tag add returns HX-Trigger with toast."""
"""Successful tag add stores toast in session."""
import base64
import time
from animaltrack.selection import compute_roster_hash
@@ -730,8 +751,14 @@ class TestTagAddSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Tagged" in decoded and "test-tag-toast" in decoded
class TestTagAddValidation:
@@ -898,7 +925,8 @@ class TestTagEndSuccess:
assert open_after == 0
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
"""Successful tag end returns HX-Trigger with toast."""
"""Successful tag end stores toast in session."""
import base64
import time
from animaltrack.selection import compute_roster_hash
@@ -919,8 +947,14 @@ class TestTagEndSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Ended tag" in decoded and "test-end-tag" in decoded
class TestTagEndValidation:
@@ -1069,7 +1103,8 @@ class TestAttrsSuccess:
assert adult_count == len(animals_for_tagging)
def test_attrs_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful attrs update returns HX-Trigger with toast."""
"""Successful attrs update stores toast in session."""
import base64
import time
from animaltrack.selection import compute_roster_hash
@@ -1090,8 +1125,14 @@ class TestAttrsSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Updated attributes" in decoded
class TestAttrsValidation:
@@ -1239,7 +1280,8 @@ class TestOutcomeSuccess:
assert harvested_count == len(animals_for_tagging)
def test_outcome_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful outcome recording returns HX-Trigger with toast."""
"""Successful outcome recording stores toast in session."""
import base64
import time
from animaltrack.selection import compute_roster_hash
@@ -1260,8 +1302,14 @@ class TestOutcomeSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
# Toast is stored in session cookie
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Recorded sold" in decoded
class TestOutcomeValidation:

View File

@@ -198,7 +198,7 @@ class TestMoveAnimalSuccess:
location_strip2_id,
ducks_at_strip1,
):
"""Successful move returns HX-Trigger with toast."""
"""Successful move returns session cookie with toast."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
@@ -219,8 +219,16 @@ class TestMoveAnimalSuccess:
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
assert "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
# Base64 decode contains toast message
import base64
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Moved 5 animals to Strip 2" in decoded
def test_move_success_resets_form(
self,