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:
"""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(
"DELETE FROM event_log_by_location WHERE event_id = ?",
"UPDATE event_log_by_location SET is_deleted = 1 WHERE event_id = ?",
(event.id,),
)

View File

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

View File

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

View File

@@ -136,13 +136,15 @@ def page(
# 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
# hx-boost enables AJAX for all descendant forms/links
# hx-boost enables AJAX for all descendant links/forms
Div(
Container(content),
hx_boost="true",
hx_target="body",
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
BottomNav(active_id=active_nav),
)

View File

@@ -92,20 +92,26 @@ def event_log_item(
ts_utc: int,
actor: str,
summary: dict[str, Any],
is_deleted: bool = False,
) -> Any:
"""Render a single event log item."""
badge_cls = event_type_badge_class(event_type)
summary_text = format_event_summary(event_type, summary)
time_str = format_timestamp(ts_utc)
# Add strikethrough styling for deleted events
deleted_cls = "line-through opacity-50" if is_deleted else ""
return Li(
Div(
Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"),
Span(time_str, cls="text-xs text-stone-500 ml-2"),
Span(
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",
),
P(summary_text, cls="text-sm text-stone-700"),
P(f"by {actor}", cls="text-xs text-stone-400"),
P(summary_text, cls=f"text-sm text-stone-700 {deleted_cls}"),
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",
hx_get=f"/events/{event_id}",
hx_target="#event-panel-content",
@@ -128,6 +134,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
ts_utc=e["ts_utc"],
actor=e["actor"],
summary=e["summary"],
is_deleted=e.get("is_deleted", False),
)
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)
projection.apply(event)
# Verify row exists
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
assert count == 1
# Verify row exists and is not deleted
row = seeded_db.execute(
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?", (event_id,)
).fetchone()
assert row[0] == 0
# Revert
projection.revert(event)
# Verify row removed
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
assert count == 0
# Verify row is marked as deleted (not removed)
row = seeded_db.execute(
"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):
"""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()
location_id = row[0]
@@ -439,16 +443,23 @@ class TestEventLogProjectionRevert:
)
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]
assert count == 2
# Revert only event1
projection.revert(event1)
# Event2 should still exist
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
assert count == 1
# Event1 should be marked as deleted
row = seeded_db.execute(
"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()
assert row[0] == "01ARZ3NDEKTSV4RRFFQ69G5002"
# Event2 should still be active (not deleted)
row = seeded_db.execute(
"SELECT is_deleted FROM event_log_by_location WHERE event_id = ?",
("01ARZ3NDEKTSV4RRFFQ69G5002",),
).fetchone()
assert row[0] == 0