diff --git a/src/animaltrack/repositories/animals.py b/src/animaltrack/repositories/animals.py
index 494f47d..789df63 100644
--- a/src/animaltrack/repositories/animals.py
+++ b/src/animaltrack/repositories/animals.py
@@ -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 = "",
diff --git a/src/animaltrack/selection/validation.py b/src/animaltrack/selection/validation.py
index 7d1dd61..156088b 100644
--- a/src/animaltrack/selection/validation.py
+++ b/src/animaltrack/selection/validation.py
@@ -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,
+ )
diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py
index f7cf781..f4107b9 100644
--- a/src/animaltrack/web/app.py
+++ b/src/animaltrack/web/app.py
@@ -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)
diff --git a/src/animaltrack/web/routes/__init__.py b/src/animaltrack/web/routes/__init__.py
index e2e703f..8daab46 100644
--- a/src/animaltrack/web/routes/__init__.py
+++ b/src/animaltrack/web/routes/__init__.py
@@ -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",
diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py
index 851751b..c014902 100644
--- a/src/animaltrack/web/routes/actions.py
+++ b/src/animaltrack/web/routes/actions.py
@@ -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). View event →",
+ "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). View event →",
+ "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}'. View event →",
+ "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). View event →",
+ "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). View event →",
+ "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). View event →",
+ "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). View event →",
+ "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."""
diff --git a/src/animaltrack/web/routes/api.py b/src/animaltrack/web/routes/api.py
new file mode 100644
index 0000000..cffaa5e
--- /dev/null
+++ b/src/animaltrack/web/routes/api.py
@@ -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="
No animals match this filter
"
+ )
+
+ # 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)))
diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py
index 0238f1e..255d3a5 100644
--- a/src/animaltrack/web/routes/eggs.py
+++ b/src/animaltrack/web/routes/eggs.py
@@ -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. View event →",
+ "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}. View event →",
+ "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.
diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py
index 5a6746d..436eaa6 100644
--- a/src/animaltrack/web/routes/events.py
+++ b/src/animaltrack/web/routes/events.py
@@ -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="Event not found
",
+ 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)),
+ )
diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py
index d36d1a5..d3eb2d0 100644
--- a/src/animaltrack/web/routes/feed.py
+++ b/src/animaltrack/web/routes/feed.py
@@ -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}. View event →",
+ "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}. View event →",
+ "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,
diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py
index 364c620..ab2bc1b 100644
--- a/src/animaltrack/web/routes/move.py
+++ b/src/animaltrack/web/routes/move.py
@@ -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}. View event →",
+ "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.
diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py
index 3c62207..eab2cb4 100644
--- a/src/animaltrack/web/templates/actions.py
+++ b/src/animaltrack/web/templates/actions.py
@@ -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),
diff --git a/src/animaltrack/web/templates/animal_detail.py b/src/animaltrack/web/templates/animal_detail.py
index 91b0b10..fec9f45 100644
--- a/src/animaltrack/web/templates/animal_detail.py
+++ b/src/animaltrack/web/templates/animal_detail.py
@@ -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",
)
diff --git a/src/animaltrack/web/templates/animal_select.py b/src/animaltrack/web/templates/animal_select.py
new file mode 100644
index 0000000..b058f14
--- /dev/null
+++ b/src/animaltrack/web/templates/animal_select.py
@@ -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",
+ )
diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py
index 73bf864..e387c6e 100644
--- a/src/animaltrack/web/templates/base.py
+++ b/src/animaltrack/web/templates/base.py
@@ -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 = '' + message + '';
-
- 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),
)
diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py
new file mode 100644
index 0000000..29d223d
--- /dev/null
+++ b/src/animaltrack/web/templates/event_detail.py
@@ -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",
+ )
diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py
index 6aa2512..59bb0b8 100644
--- a/src/animaltrack/web/templates/move.py
+++ b/src/animaltrack/web/templates/move.py
@@ -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),
diff --git a/src/animaltrack/web/templates/sidebar.py b/src/animaltrack/web/templates/sidebar.py
index 929aeb1..bd13bdd 100644
--- a/src/animaltrack/web/templates/sidebar.py
+++ b/src/animaltrack/web/templates/sidebar.py
@@ -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",
)
diff --git a/tests/test_web_actions.py b/tests/test_web_actions.py
index e9224ee..70f3991 100644
--- a/tests/test_web_actions.py
+++ b/tests/test_web_actions.py
@@ -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:
diff --git a/tests/test_web_move.py b/tests/test_web_move.py
index a822ff2..3425e40 100644
--- a/tests/test_web_move.py
+++ b/tests/test_web_move.py
@@ -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,