From 240cf440cb5cb8f8e3111629265ca4667449e40e Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 3 Jan 2026 11:03:47 +0000 Subject: [PATCH] feat: event detail page styling and deleted events indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- migrations/0010-addeventlogisdeleted.sql | 4 ++ src/animaltrack/projections/event_log.py | 4 +- src/animaltrack/web/app.py | 7 ++- src/animaltrack/web/routes/events.py | 66 +++++++++++---------- src/animaltrack/web/templates/base.py | 4 +- src/animaltrack/web/templates/events.py | 15 +++-- src/animaltrack/web/toaster.py | 73 ++++++++++++++++++++++++ tests/test_projection_event_log.py | 37 +++++++----- 8 files changed, 158 insertions(+), 52 deletions(-) create mode 100644 migrations/0010-addeventlogisdeleted.sql create mode 100644 src/animaltrack/web/toaster.py diff --git a/migrations/0010-addeventlogisdeleted.sql b/migrations/0010-addeventlogisdeleted.sql new file mode 100644 index 0000000..35954e2 --- /dev/null +++ b/migrations/0010-addeventlogisdeleted.sql @@ -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; \ No newline at end of file diff --git a/src/animaltrack/projections/event_log.py b/src/animaltrack/projections/event_log.py index e181b90..a1b6a92 100644 --- a/src/animaltrack/projections/event_log.py +++ b/src/animaltrack/projections/event_log.py @@ -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,), ) diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 901b5fc..6b091e1 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -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): diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py index f991b80..23df35f 100644 --- a/src/animaltrack/web/routes/events.py +++ b/src/animaltrack/web/routes/events.py @@ -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="
Event not found
", - 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"]) diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index f5ab09d..a49d5f7 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -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), ) diff --git a/src/animaltrack/web/templates/events.py b/src/animaltrack/web/templates/events.py index a54ed25..abb6347 100644 --- a/src/animaltrack/web/templates/events.py +++ b/src/animaltrack/web/templates/events.py @@ -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 ] diff --git a/src/animaltrack/web/toaster.py b/src/animaltrack/web/toaster.py new file mode 100644 index 0000000..e098794 --- /dev/null +++ b/src/animaltrack/web/toaster.py @@ -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) diff --git a/tests/test_projection_event_log.py b/tests/test_projection_event_log.py index a79590d..7ad8ecb 100644 --- a/tests/test_projection_event_log.py +++ b/tests/test_projection_event_log.py @@ -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