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