From 3937d675ba9d519f99298e2e19e75ad85bdd3916 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 1 Jan 2026 19:10:57 +0000 Subject: [PATCH] feat: add event detail slide-over, fix toasts, and checkbox selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three major features implemented: 1. Event Detail Slide-Over Panel - Click timeline events to view details in slide-over - New /events/{event_id} route and event_detail.py template - Type-specific payload rendering for all event types 2. Toast System Refactor - Switch from custom addEventListener to FastHTML's add_toast() - Replace HX-Trigger headers with session-based toasts - Add event links in toast messages - Replace addEventListener with hx_on_* in templates 3. Checkbox Selection for Animal Subsets - New animal_select.py component with checkbox list - New /api/compute-hash and /api/selection-preview endpoints - Add subset_mode support to SelectionContext validation - Update 5 forms: outcome, move, tag-add, tag-end, attrs - Users can select specific animals from filtered results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/repositories/animals.py | 62 ++++ src/animaltrack/selection/validation.py | 79 +++++ src/animaltrack/web/app.py | 7 +- src/animaltrack/web/routes/__init__.py | 2 + src/animaltrack/web/routes/actions.py | 326 +++++++++++------- src/animaltrack/web/routes/api.py | 84 +++++ src/animaltrack/web/routes/eggs.py | 43 ++- src/animaltrack/web/routes/events.py | 75 +++- src/animaltrack/web/routes/feed.py | 53 ++- src/animaltrack/web/routes/move.py | 71 ++-- src/animaltrack/web/templates/actions.py | 175 ++++++---- .../web/templates/animal_detail.py | 8 +- .../web/templates/animal_select.py | 192 +++++++++++ src/animaltrack/web/templates/base.py | 137 ++++---- src/animaltrack/web/templates/event_detail.py | 322 +++++++++++++++++ src/animaltrack/web/templates/move.py | 31 +- src/animaltrack/web/templates/sidebar.py | 15 +- tests/test_web_actions.py | 84 ++++- tests/test_web_move.py | 14 +- 19 files changed, 1420 insertions(+), 360 deletions(-) create mode 100644 src/animaltrack/web/routes/api.py create mode 100644 src/animaltrack/web/templates/animal_select.py create mode 100644 src/animaltrack/web/templates/event_detail.py 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,