feat: add location tooltips, links, and detail page

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 10:53:43 +00:00
parent d19e5b7120
commit 0125bc4aaa
3 changed files with 275 additions and 13 deletions

View File

@@ -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)
# =============================================================================

View File

@@ -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)))

View File

@@ -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",
)