feat: event detail page styling and deleted events indicator

- Fix event detail page for direct navigation by using FastHTML's
  idiomatic htmx parameter instead of manual header check
- Add custom toaster.py with HTML support using NotStr to render
  clickable links in toast messages
- Add hx_preserve to toast container to survive HTMX body swaps
- Add is_deleted column to event_log_by_location table
- Update event_log projection revert() to set is_deleted flag
  instead of deleting rows
- Add strikethrough styling for deleted events in event log

🤖 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-03 11:03:47 +00:00
parent e86af247da
commit 240cf440cb
8 changed files with 158 additions and 52 deletions

View File

@@ -0,0 +1,4 @@
-- ABOUTME: Migration 0010 - add_event_log_is_deleted
-- ABOUTME: Adds is_deleted flag to event_log_by_location for deleted event visibility.
ALTER TABLE event_log_by_location ADD COLUMN is_deleted INTEGER NOT NULL DEFAULT 0;

View File

@@ -64,9 +64,9 @@ class EventLogProjection(Projection):
) )
def revert(self, event: Event) -> None: def revert(self, event: Event) -> None:
"""Revert event by removing entry from event_log_by_location.""" """Revert event by marking entry as deleted in event_log_by_location."""
self.db.execute( self.db.execute(
"DELETE FROM event_log_by_location WHERE event_id = ?", "UPDATE event_log_by_location SET is_deleted = 1 WHERE event_id = ?",
(event.id,), (event.id,),
) )

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from fasthtml.common import Beforeware, Meta, fast_app, setup_toasts from fasthtml.common import Beforeware, Meta, fast_app
from monsterui.all import Theme from monsterui.all import Theme
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.requests import Request from starlette.requests import Request
@@ -34,6 +34,7 @@ from animaltrack.web.routes import (
products_router, products_router,
registry_router, registry_router,
) )
from animaltrack.web.toaster import setup_toasts_with_html
# Default static directory relative to this module # Default static directory relative to this module
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static" DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
@@ -148,8 +149,8 @@ def create_app(
app.state.settings = settings app.state.settings = settings
app.state.db = db app.state.db = db
# Setup toast notifications with 5 second duration # Setup toast notifications with 5 second duration (custom version supports HTML)
setup_toasts(app, duration=5000) setup_toasts_with_html(app, duration=5000)
# Register exception handlers for auth errors # Register exception handlers for auth errors
async def authentication_error_handler(request, exc): async def authentication_error_handler(request, exc):

View File

@@ -6,9 +6,9 @@ from __future__ import annotations
import json import json
from typing import Any from typing import Any
from fasthtml.common import APIRouter, to_xml from fasthtml.common import APIRouter
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse from starlette.responses import JSONResponse
from animaltrack.events.delete import delete_event from animaltrack.events.delete import delete_event
from animaltrack.events.dependencies import find_dependent_events from animaltrack.events.dependencies import find_dependent_events
@@ -49,7 +49,7 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
""" """
rows = db.execute( rows = db.execute(
""" """
SELECT event_id, location_id, ts_utc, type, actor, summary SELECT event_id, location_id, ts_utc, type, actor, summary, is_deleted
FROM event_log_by_location FROM event_log_by_location
WHERE location_id = ? WHERE location_id = ?
ORDER BY ts_utc DESC ORDER BY ts_utc DESC
@@ -68,6 +68,7 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
"type": row[3], "type": row[3],
"actor": row[4], "actor": row[4],
"summary": json.loads(row[5]), "summary": json.loads(row[5]),
"is_deleted": bool(row[6]),
} }
) )
return events return events
@@ -94,10 +95,12 @@ def get_all_events(
if event_type: if event_type:
rows = db.execute( rows = db.execute(
""" """
SELECT id, ts_utc, type, actor, payload SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
FROM events CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
WHERE type = ? FROM events e
ORDER BY ts_utc DESC LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = ?
ORDER BY e.ts_utc DESC
LIMIT ? LIMIT ?
""", """,
(event_type, limit), (event_type, limit),
@@ -105,9 +108,11 @@ def get_all_events(
else: else:
rows = db.execute( rows = db.execute(
""" """
SELECT id, ts_utc, type, actor, payload SELECT e.id, e.ts_utc, e.type, e.actor, e.payload,
FROM events CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
ORDER BY ts_utc DESC FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
ORDER BY e.ts_utc DESC
LIMIT ? LIMIT ?
""", """,
(limit,), (limit,),
@@ -126,6 +131,7 @@ def get_all_events(
"type": row[2], "type": row[2],
"actor": row[3], "actor": row[3],
"summary": summary, "summary": summary,
"is_deleted": bool(row[5]),
} }
) )
return events return events
@@ -188,7 +194,7 @@ def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dic
@ar("/event-log") @ar("/event-log")
def event_log_index(request: Request): def event_log_index(request: Request, htmx):
"""GET /event-log - Event log for all events or filtered by location/type.""" """GET /event-log - Event log for all events or filtered by location/type."""
db = request.app.state.db db = request.app.state.db
@@ -228,12 +234,9 @@ def event_log_index(request: Request):
if event_type: if event_type:
events = [e for e in events if e["type"] == event_type] events = [e for e in events if e["type"] == event_type]
# Check if HTMX request # HTMX request → return partial (just the event list)
is_htmx = request.headers.get("HX-Request") == "true" if htmx.request:
return event_log_list(events)
if is_htmx:
# Return partial - just the event list
return HTMLResponse(content=to_xml(event_log_list(events)))
# Full page render # Full page render
return render_page( return render_page(
@@ -270,8 +273,8 @@ def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
@ar("/events/{event_id}") @ar("/events/{event_id}")
def event_detail(request: Request, event_id: str): def event_detail(request: Request, event_id: str, htmx):
"""GET /events/{event_id} - Event detail panel for slide-over.""" """GET /events/{event_id} - Event detail panel for slide-over or full page."""
db = request.app.state.db db = request.app.state.db
# Get event from store # Get event from store
@@ -279,10 +282,12 @@ def event_detail(request: Request, event_id: str):
event = event_store.get_event(event_id) event = event_store.get_event(event_id)
if event is None: if event is None:
return HTMLResponse( from fasthtml.common import Div
content="<div class='p-4 text-red-400'>Event not found</div>",
status_code=404, error_content = Div("Event not found", cls="p-4 text-red-400")
) if htmx.request:
return error_content
return render_page(request, error_content, title="Event Not Found")
# Check if tombstoned # Check if tombstoned
is_tombstoned = event_store.is_tombstoned(event_id) is_tombstoned = event_store.is_tombstoned(event_id)
@@ -311,12 +316,15 @@ def event_detail(request: Request, event_id: str):
auth = request.scope.get("auth") auth = request.scope.get("auth")
user_role = auth.role if auth else None user_role = auth.role if auth else None
# Return slide-over panel HTML # Build the panel
return HTMLResponse( panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
content=to_xml(
event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role) # HTMX request (slide-over) → return just panel
), if htmx.request:
) return panel
# Direct navigation → wrap in full page layout
return render_page(request, panel, title=f"Event {event.id}")
@ar("/events/{event_id}/delete", methods=["POST"]) @ar("/events/{event_id}/delete", methods=["POST"])

View File

@@ -136,13 +136,15 @@ def page(
# Main content with responsive padding/margin # Main content with responsive padding/margin
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav) # pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
# md:ml-60 to offset for desktop sidebar # md:ml-60 to offset for desktop sidebar
# hx-boost enables AJAX for all descendant forms/links # hx-boost enables AJAX for all descendant links/forms
Div( Div(
Container(content), Container(content),
hx_boost="true", hx_boost="true",
hx_target="body", hx_target="body",
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100", cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
), ),
# Toast container with hx-preserve to survive body swaps for OOB toast injection
Div(id="fh-toast-container", hx_preserve=True),
# Mobile bottom nav # Mobile bottom nav
BottomNav(active_id=active_nav), BottomNav(active_id=active_nav),
) )

View File

@@ -92,20 +92,26 @@ def event_log_item(
ts_utc: int, ts_utc: int,
actor: str, actor: str,
summary: dict[str, Any], summary: dict[str, Any],
is_deleted: bool = False,
) -> Any: ) -> Any:
"""Render a single event log item.""" """Render a single event log item."""
badge_cls = event_type_badge_class(event_type) badge_cls = event_type_badge_class(event_type)
summary_text = format_event_summary(event_type, summary) summary_text = format_event_summary(event_type, summary)
time_str = format_timestamp(ts_utc) time_str = format_timestamp(ts_utc)
# Add strikethrough styling for deleted events
deleted_cls = "line-through opacity-50" if is_deleted else ""
return Li( return Li(
Div( Div(
Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"), Span(
Span(time_str, cls="text-xs text-stone-500 ml-2"), event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls} {deleted_cls}"
),
Span(time_str, cls=f"text-xs text-stone-500 ml-2 {deleted_cls}"),
cls="flex items-center gap-2 mb-1", cls="flex items-center gap-2 mb-1",
), ),
P(summary_text, cls="text-sm text-stone-700"), P(summary_text, cls=f"text-sm text-stone-700 {deleted_cls}"),
P(f"by {actor}", cls="text-xs text-stone-400"), P(f"by {actor}", cls=f"text-xs text-stone-400 {deleted_cls}"),
cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50", cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50",
hx_get=f"/events/{event_id}", hx_get=f"/events/{event_id}",
hx_target="#event-panel-content", hx_target="#event-panel-content",
@@ -128,6 +134,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
ts_utc=e["ts_utc"], ts_utc=e["ts_utc"],
actor=e["actor"], actor=e["actor"],
summary=e["summary"], summary=e["summary"],
is_deleted=e.get("is_deleted", False),
) )
for e in events for e in events
] ]

View File

@@ -0,0 +1,73 @@
# ABOUTME: Custom toast system that supports HTML content in messages.
# ABOUTME: Extends FastHTML's toaster to use NotStr for unescaped HTML rendering.
from fasthtml.common import Button, Div, NotStr, Span
# Use FastHTML's toast session key and container ID for compatibility
TOAST_CONTAINER_ID = "fh-toast-container"
TOAST_SESSION_KEY = "toasts"
def Toast(message: str, typ: str = "info", dismiss: bool = False, duration: int = 5000): # noqa: N802
"""Create a toast notification element with HTML support.
Unlike FastHTML's Toast, this version uses NotStr to allow HTML in messages.
PascalCase follows FastHTML's component naming convention.
"""
x_btn = (
Button(
"x",
cls="fh-toast-dismiss",
onclick="htmx.remove(this?.parentElement);",
)
if dismiss
else None
)
# Use NotStr to prevent HTML escaping in the message
return Div(
Span(NotStr(message)),
x_btn,
cls=f"fh-toast fh-toast-{typ}",
hx_on_transitionend=f"setTimeout(() => this?.remove(), {duration});",
)
def render_toasts(sess):
"""Render queued toasts from session with HTML support."""
toasts = [
Toast(msg, typ, dismiss, sess["toast_duration"])
for msg, typ, dismiss in sess.pop(TOAST_SESSION_KEY, [])
]
return Div(*toasts, id=TOAST_CONTAINER_ID, hx_swap_oob=f"beforeend:#{TOAST_CONTAINER_ID}")
def toast_after(resp, req, sess):
"""After-response hook to inject toast HTML into the response."""
from fastcore.xml import FT
from fasthtml.core import FtResponse
if TOAST_SESSION_KEY in sess and (not resp or isinstance(resp, (tuple, FT, FtResponse))):
sess["toast_duration"] = req.app.state.toast_duration
req.injects.append(render_toasts(sess))
def setup_toasts_with_html(app, duration: int = 5000):
"""Setup toast system with HTML support.
This replaces FastHTML's default toast_after with our custom version
that renders HTML content without escaping.
"""
from fasthtml.common import Script, Style
from fasthtml.toaster import js, toast_css
from fasthtml.toaster import toast_after as default_toast_after
app.state.toast_duration = duration
# Add CSS and JS (same as FastHTML's setup_toasts)
app.hdrs += [Style(toast_css), Script(js)]
# Remove FastHTML's default toast_after if present, add our custom one
if default_toast_after in app.after:
app.after.remove(default_toast_after)
app.after.append(toast_after)

View File

@@ -405,19 +405,23 @@ class TestEventLogProjectionRevert:
event = make_product_collected_event(event_id, location_id, animal_ids) event = make_product_collected_event(event_id, location_id, animal_ids)
projection.apply(event) projection.apply(event)
# Verify row exists # Verify row exists and is not deleted
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0] row = seeded_db.execute(
assert count == 1 "SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
).fetchone()
assert row[0] == 0
# Revert # Revert
projection.revert(event) projection.revert(event)
# Verify row removed # Verify row is marked as deleted (not removed)
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0] row = seeded_db.execute(
assert count == 0 "SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
).fetchone()
assert row[0] == 1
def test_revert_only_affects_specific_event(self, seeded_db): def test_revert_only_affects_specific_event(self, seeded_db):
"""Revert only removes the specific event log entry.""" """Revert only marks the specific event log entry as deleted."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0] location_id = row[0]
@@ -439,16 +443,23 @@ class TestEventLogProjectionRevert:
) )
projection.apply(event2) projection.apply(event2)
# Verify both exist # Verify both exist and are not deleted
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0] count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
assert count == 2 assert count == 2
# Revert only event1 # Revert only event1
projection.revert(event1) projection.revert(event1)
# Event2 should still exist # Event1 should be marked as deleted
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0] row = seeded_db.execute(
assert count == 1 "SELECT is_deleted FROM event_log_by_location WHERE event_id = ?",
("01ARZ3NDEKTSV4RRFFQ69G5001",),
).fetchone()
assert row[0] == 1
row = seeded_db.execute("SELECT event_id FROM event_log_by_location").fetchone() # Event2 should still be active (not deleted)
assert row[0] == "01ARZ3NDEKTSV4RRFFQ69G5002" row = seeded_db.execute(
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?",
("01ARZ3NDEKTSV4RRFFQ69G5002",),
).fetchone()
assert row[0] == 0