From 0125bc4aaa7d93b3c6f28ca9cdf293cb56e40ed5 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Fri, 2 Jan 2026 10:53:43 +0000 Subject: [PATCH] feat: add location tooltips, links, and detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tooltips with location ID on hover for location names in event detail - Make location names clickable links to /locations/{id} detail page - Create location detail page showing location info, live animal count, and recent events at that location - Add public GET /locations/{id} route (existing admin routes unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/locations.py | 78 ++++++++++- src/animaltrack/web/templates/event_detail.py | 88 +++++++++++-- .../web/templates/location_detail.py | 122 ++++++++++++++++++ 3 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 src/animaltrack/web/templates/location_detail.py diff --git a/src/animaltrack/web/routes/locations.py b/src/animaltrack/web/routes/locations.py index 02f3f45..8729670 100644 --- a/src/animaltrack/web/routes/locations.py +++ b/src/animaltrack/web/routes/locations.py @@ -1,5 +1,5 @@ -# ABOUTME: Routes for Location management functionality (admin-only). -# ABOUTME: Handles GET /locations and POST /actions/location-* routes. +# ABOUTME: Routes for Location management functionality. +# ABOUTME: Handles GET /locations, GET /locations/{id}, and POST /actions/location-* routes. from __future__ import annotations @@ -19,6 +19,7 @@ from animaltrack.services.location import LocationService, ValidationError from animaltrack.web.auth import require_role from animaltrack.web.responses import success_toast from animaltrack.web.templates import render_page +from animaltrack.web.templates.location_detail import location_detail_panel from animaltrack.web.templates.locations import location_list, rename_form # APIRouter for multi-file route organization @@ -33,8 +34,79 @@ def _get_location_service(db) -> LocationService: return LocationService(db, event_store, registry) +def _get_recent_events(db, location_id: str, limit: int = 10) -> list[dict]: + """Get recent events for a location from the event log projection.""" + rows = db.execute( + """ + SELECT event_id, location_id, ts_utc, type, actor, summary + FROM event_log_by_location + WHERE location_id = ? + ORDER BY ts_utc DESC + LIMIT ? + """, + (location_id, limit), + ).fetchall() + + events = [] + for row in rows: + events.append( + { + "event_id": row[0], + "location_id": row[1], + "ts_utc": row[2], + "type": row[3], + "actor": row[4], + } + ) + return events + + +def _get_live_animal_count(db, location_id: str) -> int: + """Get count of live animals at a location.""" + row = db.execute( + """ + SELECT COUNT(*) FROM live_animals_by_location + WHERE location_id = ? + """, + (location_id,), + ).fetchone() + return row[0] if row else 0 + + # ============================================================================= -# GET /locations - Location List +# GET /locations/{id} - Location Detail (Public) +# ============================================================================= + + +@ar("/locations/{location_id}") +async def location_detail(req: Request, location_id: str): + """GET /locations/{id} - Public location detail page.""" + db = req.app.state.db + + # Handle admin rename route - check if it's the special /rename path + # This is handled by a separate route, so we don't need to worry about it here + + location = LocationRepository(db).get(location_id) + + if location is None: + return HTMLResponse(content="Location not found", status_code=404) + + # Get recent events at this location + recent_events = _get_recent_events(db, location_id) + + # Get live animal count + animal_count = _get_live_animal_count(db, location_id) + + return render_page( + req, + location_detail_panel(location, recent_events, animal_count), + title=f"{location.name} - AnimalTrack", + active_nav=None, + ) + + +# ============================================================================= +# GET /locations - Location List (Admin) # ============================================================================= diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py index aa76d8e..6c61a52 100644 --- a/src/animaltrack/web/templates/event_detail.py +++ b/src/animaltrack/web/templates/event_detail.py @@ -159,12 +159,26 @@ def render_payload_items( 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)) + if from_loc: + from_name = location_names.get(from_loc, from_loc[:8] + "...") + items.append( + payload_item_with_link( + "From", + from_name, + f"/locations/{from_loc}", + f"ID: {from_loc}", + ) + ) + if to_loc: + to_name = location_names.get(to_loc, to_loc[:8] + "...") + items.append( + payload_item_with_link( + "To", + to_name, + f"/locations/{to_loc}", + f"ID: {to_loc}", + ) + ) elif event_type == "AnimalTagged": if "tag" in payload: @@ -250,6 +264,48 @@ def payload_item(label: str, value: str) -> Div: ) +def payload_item_with_link(label: str, text: str, href: str, title: str) -> Div: + """Payload item with a clickable link.""" + return Div( + Span(label + ":", cls="text-stone-500 text-sm min-w-[100px]"), + A( + text, + href=href, + title=title, + cls="text-amber-500 hover:underline text-sm", + ), + cls="flex gap-2", + ) + + +def location_display( + location_id: str, + location_names: dict[str, str], + as_link: bool = True, +): + """Render a location ID with name and tooltip. + + Args: + location_id: The location ULID. + location_names: Map of location IDs to names. + as_link: Whether to render as a link (default True). + + Returns: + A or Span element displaying the location name with ID tooltip. + """ + name = location_names.get(location_id, location_id[:8] + "...") + tooltip = f"ID: {location_id}" + + if as_link: + return A( + name, + href=f"/locations/{location_id}", + title=tooltip, + cls="text-amber-500 hover:underline text-sm", + ) + return Span(name, title=tooltip, cls="text-stone-300 text-sm") + + def entity_refs_section( entity_refs: dict[str, Any], location_names: dict[str, str], @@ -264,15 +320,27 @@ def entity_refs_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 "") + # Handle location references with links and tooltips + if (key.endswith("_location_id") or key == "location_id") and isinstance(value, str): + loc_name = location_names.get(value, value[:8] + "...") + items.append( + payload_item_with_link( + key.replace("_", " ").title(), + loc_name, + f"/locations/{value}", + f"ID: {value}", + ) + ) + continue + # Handle lists if isinstance(value, list): display_value = f"{len(value)} items" + # Handle long strings elif isinstance(value, str) and len(value) > 20: display_value = value[:8] + "..." + else: + display_value = value items.append(payload_item(key.replace("_", " ").title(), str(display_value))) diff --git a/src/animaltrack/web/templates/location_detail.py b/src/animaltrack/web/templates/location_detail.py new file mode 100644 index 0000000..4d760a5 --- /dev/null +++ b/src/animaltrack/web/templates/location_detail.py @@ -0,0 +1,122 @@ +# ABOUTME: Template for location detail page. +# ABOUTME: Shows location information, status, and recent events. + +from datetime import UTC, datetime +from typing import Any + +from fasthtml.common import H1, H2, A, Div, Li, P, Span, Ul + +from animaltrack.models.reference import Location + + +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") + + +def location_detail_panel( + location: Location, + recent_events: list[dict[str, Any]] | None = None, + animal_count: int = 0, +) -> Div: + """Location detail page content. + + Args: + location: The location to display. + recent_events: Optional list of recent events at this location. + animal_count: Number of live animals currently at this location. + + Returns: + Div containing the location detail page. + """ + if recent_events is None: + recent_events = [] + + status_badge = ( + Span("Active", cls="text-sm bg-green-900/50 text-green-300 px-2 py-1 rounded") + if location.active + else Span("Archived", cls="text-sm bg-stone-700 text-stone-400 px-2 py-1 rounded") + ) + + return Div( + # Header + Div( + H1(location.name, cls="text-2xl font-bold text-stone-100"), + status_badge, + cls="flex items-center gap-4 mb-6", + ), + # Info card + Div( + info_row("Location ID", location.id, monospace=True), + info_row("Created", format_timestamp(location.created_at_utc)), + info_row("Last Updated", format_timestamp(location.updated_at_utc)), + info_row("Live Animals", str(animal_count)), + cls="bg-stone-900/50 rounded-lg p-4 space-y-2 mb-6", + ), + # Recent events section + recent_events_section(recent_events) if recent_events else Div(), + # Back link + Div( + A( + "← Back to Event Log", + href="/event-log", + cls="text-amber-500 hover:underline", + ), + cls="mt-6", + ), + cls="max-w-2xl", + ) + + +def info_row(label: str, value: str, monospace: bool = False) -> Div: + """Single info row with label and value.""" + value_cls = "text-stone-200" + if monospace: + value_cls += " font-mono text-sm" + return Div( + Span(label + ":", cls="text-stone-500 min-w-[120px]"), + Span(value, cls=value_cls), + cls="flex gap-4", + ) + + +def recent_events_section(events: list[dict[str, Any]]) -> Div: + """Section showing recent events at this location.""" + event_items = [] + for event in events[:10]: # Limit to 10 most recent + event_items.append( + Li( + A( + Span( + event.get("type", "Unknown"), + cls="text-amber-500 hover:underline", + ), + Span( + f" - {format_timestamp(event.get('ts_utc', 0))}", + cls="text-stone-500 text-sm", + ), + href=f"/events/{event.get('event_id')}", + hx_get=f"/events/{event.get('event_id')}", + hx_target="#event-panel", + hx_swap="innerHTML", + ), + cls="py-1", + ) + ) + + if not event_items: + return Div( + H2("Recent Events", cls="text-lg font-semibold text-stone-300 mb-2"), + P("No events recorded at this location.", cls="text-stone-500"), + cls="mt-4", + ) + + return Div( + H2( + f"Recent Events ({len(events)})", + cls="text-lg font-semibold text-stone-300 mb-2", + ), + Ul(*event_items, cls="space-y-1"), + cls="mt-4", + )