Fix tombstone bug in stats and add cost statistics to forms
All checks were successful
Deploy / deploy (push) Successful in 1m38s

Bug fix: Stats queries (eggs/day, feed/bird/day, etc.) were not excluding
tombstoned (deleted) events. Updated EventStore.list_events() to exclude
tombstoned events by default via LEFT JOIN, and updated direct SQL queries
in stats.py with the same tombstone exclusion.

New stats added:
- Harvest form: cost/egg (global, 30-day avg)
- Sell form: avg price/egg (30-day)
- Give feed form: cost/bird/day (global, 30-day avg)

🤖 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-09 06:19:30 +00:00
parent e42eede010
commit d91ee362fa
7 changed files with 299 additions and 39 deletions

View File

@@ -144,6 +144,7 @@ class EventStore:
until_utc: int | None = None,
actor: str | None = None,
limit: int = 100,
include_tombstoned: bool = False,
) -> list[Event]:
"""List events with optional filters.
@@ -153,34 +154,44 @@ class EventStore:
until_utc: Include events with ts_utc <= until_utc.
actor: Filter by actor.
limit: Maximum number of events to return.
include_tombstoned: If True, include tombstoned (deleted) events.
Defaults to False, excluding tombstoned events.
Returns:
List of events ordered by ts_utc ASC.
"""
query = "SELECT id, type, ts_utc, actor, entity_refs, payload, version FROM events"
query = """
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
"""
conditions = []
params: list = []
# Exclude tombstoned events unless explicitly included
if not include_tombstoned:
conditions.append("t.target_event_id IS NULL")
if event_type is not None:
conditions.append("type = ?")
conditions.append("e.type = ?")
params.append(event_type)
if since_utc is not None:
conditions.append("ts_utc >= ?")
conditions.append("e.ts_utc >= ?")
params.append(since_utc)
if until_utc is not None:
conditions.append("ts_utc <= ?")
conditions.append("e.ts_utc <= ?")
params.append(until_utc)
if actor is not None:
conditions.append("actor = ?")
conditions.append("e.actor = ?")
params.append(actor)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY ts_utc ASC"
query += " ORDER BY e.ts_utc ASC"
query += f" LIMIT {limit}"
rows = self.db.execute(query, tuple(params)).fetchall()

View File

@@ -149,16 +149,19 @@ def _count_eggs_in_window(
Returns (eggs_count, species) where species is extracted from product_code.
Window is inclusive on both ends: [window_start, window_end].
Excludes tombstoned (deleted) events.
"""
rows = db.execute(
"""
SELECT json_extract(entity_refs, '$.product_code') as product_code,
json_extract(entity_refs, '$.quantity') as quantity
FROM events
WHERE type = 'ProductCollected'
AND json_extract(entity_refs, '$.location_id') = :location_id
AND ts_utc >= :window_start
AND ts_utc <= :window_end
SELECT json_extract(e.entity_refs, '$.product_code') as product_code,
json_extract(e.entity_refs, '$.quantity') as quantity
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = 'ProductCollected'
AND json_extract(e.entity_refs, '$.location_id') = :location_id
AND e.ts_utc >= :window_start
AND e.ts_utc <= :window_end
AND t.target_event_id IS NULL
""",
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
).fetchall()
@@ -182,16 +185,19 @@ def _get_feed_events_in_window(
"""Get all FeedGiven events at location in window.
Window is inclusive on both ends: [window_start, window_end].
Excludes tombstoned (deleted) events.
"""
rows = db.execute(
"""
SELECT ts_utc, entity_refs
FROM events
WHERE type = 'FeedGiven'
AND json_extract(entity_refs, '$.location_id') = :location_id
AND ts_utc >= :window_start
AND ts_utc <= :window_end
ORDER BY ts_utc
SELECT e.ts_utc, e.entity_refs
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = 'FeedGiven'
AND json_extract(e.entity_refs, '$.location_id') = :location_id
AND e.ts_utc >= :window_start
AND e.ts_utc <= :window_end
AND t.target_event_id IS NULL
ORDER BY e.ts_utc
""",
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
).fetchall()
@@ -213,15 +219,18 @@ def _get_feed_price_at_time(db: Any, feed_type_code: str, ts_utc: int) -> int:
"""Get the feed price per kg in cents at a given time.
Returns the price from the most recent FeedPurchased event <= ts_utc.
Excludes tombstoned (deleted) events.
"""
row = db.execute(
"""
SELECT json_extract(entity_refs, '$.price_per_kg_cents') as price
FROM events
WHERE type = 'FeedPurchased'
AND json_extract(entity_refs, '$.feed_type_code') = :feed_type_code
AND ts_utc <= :ts_utc
ORDER BY ts_utc DESC
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = 'FeedPurchased'
AND json_extract(e.entity_refs, '$.feed_type_code') = :feed_type_code
AND e.ts_utc <= :ts_utc
AND t.target_event_id IS NULL
ORDER BY e.ts_utc DESC
LIMIT 1
""",
{"feed_type_code": feed_type_code, "ts_utc": ts_utc},

View File

@@ -169,6 +169,75 @@ def _get_eggs_per_day(db: Any, now_ms: int) -> float | None:
return total_eggs / 30.0
def _get_global_cost_per_egg(db: Any, now_ms: int) -> float | None:
"""Calculate global cost per egg over 30-day window.
Aggregates feed costs and egg counts across all locations.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Cost per egg in EUR, or None if no eggs collected.
"""
from animaltrack.events import FEED_GIVEN
window_start = now_ms - THIRTY_DAYS_MS
event_store = EventStore(db)
# Count eggs across all locations
egg_events = event_store.list_events(
event_type=PRODUCT_COLLECTED,
since_utc=window_start,
until_utc=now_ms,
limit=10000,
)
total_eggs = 0
for event in egg_events:
product_code = event.entity_refs.get("product_code", "")
if product_code.startswith("egg."):
total_eggs += event.entity_refs.get("quantity", 0)
if total_eggs == 0:
return None
# Sum feed costs across all locations
feed_events = event_store.list_events(
event_type=FEED_GIVEN,
since_utc=window_start,
until_utc=now_ms,
limit=10000,
)
total_cost_cents = 0.0
for event in feed_events:
amount_kg = event.entity_refs.get("amount_kg", 0)
feed_type_code = event.entity_refs.get("feed_type_code", "")
# Look up price at the time of feeding
price_row = db.execute(
"""
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = 'FeedPurchased'
AND json_extract(e.entity_refs, '$.feed_type_code') = ?
AND e.ts_utc <= ?
AND t.target_event_id IS NULL
ORDER BY e.ts_utc DESC
LIMIT 1
""",
(feed_type_code, event.ts_utc),
).fetchone()
price_per_kg_cents = price_row[0] if price_row else 0
total_cost_cents += amount_kg * price_per_kg_cents
return (total_cost_cents / 100) / total_eggs
def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
"""Calculate sales statistics over 30-day window.
@@ -177,7 +246,8 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
now_ms: Current timestamp in milliseconds.
Returns:
Dict with 'total_qty' and 'total_cents', or None if no data.
Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents',
or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
event_store = EventStore(db)
@@ -197,7 +267,13 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
total_qty += event.entity_refs.get("quantity", 0)
total_cents += event.entity_refs.get("total_price_cents", 0)
return {"total_qty": total_qty, "total_cents": total_cents}
avg_price_per_egg_cents = total_cents / total_qty if total_qty > 0 else 0
return {
"total_qty": total_qty,
"total_cents": total_cents,
"avg_price_per_egg_cents": avg_price_per_egg_cents,
}
def _get_eggs_display_data(db: Any, locations: list) -> dict:
@@ -208,13 +284,15 @@ def _get_eggs_display_data(db: Any, locations: list) -> dict:
locations: List of Location objects for name lookup.
Returns:
Dict with harvest_events, sell_events, eggs_per_day, sales_stats, location_names.
Dict with harvest_events, sell_events, eggs_per_day, cost_per_egg,
sales_stats, location_names.
"""
now_ms = int(time.time() * 1000)
return {
"harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10),
"sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10),
"eggs_per_day": _get_eggs_per_day(db, now_ms),
"cost_per_egg": _get_global_cost_per_egg(db, now_ms),
"sales_stats": _get_sales_stats(db, now_ms),
"location_names": {loc.id: loc.name for loc in locations},
}

View File

@@ -166,6 +166,83 @@ def _get_feed_per_bird_per_day(db: Any, now_ms: int) -> float | None:
return total_g / bird_days
def _get_cost_per_bird_per_day(db: Any, now_ms: int) -> float | None:
"""Calculate feed cost per bird per day over 30-day window.
Uses global bird-days and feed costs across all locations.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Feed cost in EUR per bird per day, or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
# Get total bird-days across all locations
row = db.execute(
"""
SELECT COALESCE(SUM(
MIN(COALESCE(ali.end_utc, :window_end), :window_end) -
MAX(ali.start_utc, :window_start)
), 0) as total_ms
FROM animal_location_intervals ali
JOIN animal_registry ar ON ali.animal_id = ar.animal_id
WHERE ali.start_utc < :window_end
AND (ali.end_utc IS NULL OR ali.end_utc > :window_start)
AND ar.status = 'alive'
""",
{"window_start": window_start, "window_end": now_ms},
).fetchone()
total_ms = row[0] if row else 0
ms_per_day = 24 * 60 * 60 * 1000
bird_days = total_ms // ms_per_day if total_ms else 0
if bird_days == 0:
return None
# Get total feed cost in window (all locations)
event_store = EventStore(db)
events = event_store.list_events(
event_type=FEED_GIVEN,
since_utc=window_start,
until_utc=now_ms,
limit=10000,
)
if not events:
return None
total_cost_cents = 0.0
for event in events:
amount_kg = event.entity_refs.get("amount_kg", 0)
feed_type_code = event.entity_refs.get("feed_type_code", "")
# Look up price at the time of feeding
price_row = db.execute(
"""
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
FROM events e
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
WHERE e.type = 'FeedPurchased'
AND json_extract(e.entity_refs, '$.feed_type_code') = ?
AND e.ts_utc <= ?
AND t.target_event_id IS NULL
ORDER BY e.ts_utc DESC
LIMIT 1
""",
(feed_type_code, event.ts_utc),
).fetchone()
price_per_kg_cents = price_row[0] if price_row else 0
total_cost_cents += amount_kg * price_per_kg_cents
# Convert to EUR and divide by bird-days
return (total_cost_cents / 100) / bird_days
def _get_purchase_stats(db: Any, now_ms: int) -> dict | None:
"""Calculate purchase statistics over 30-day window.
@@ -221,6 +298,7 @@ def _get_feed_display_data(db: Any, locations: list, feed_types: list) -> dict:
"give_events": _get_recent_events(db, FEED_GIVEN, limit=10),
"purchase_events": _get_recent_events(db, FEED_PURCHASED, limit=10),
"feed_per_bird_per_day_g": _get_feed_per_bird_per_day(db, now_ms),
"cost_per_bird_per_day": _get_cost_per_bird_per_day(db, now_ms),
"purchase_stats": _get_purchase_stats(db, now_ms),
"location_names": {loc.id: loc.name for loc in locations},
"feed_type_names": {ft.code: ft.name for ft in feed_types},

View File

@@ -34,6 +34,7 @@ def eggs_page(
harvest_events: list[tuple[Event, bool]] | None = None,
sell_events: list[tuple[Event, bool]] | None = None,
eggs_per_day: float | None = None,
cost_per_egg: float | None = None,
sales_stats: dict | None = None,
location_names: dict[str, str] | None = None,
):
@@ -52,7 +53,8 @@ def eggs_page(
harvest_events: Recent ProductCollected events (most recent first).
sell_events: Recent ProductSold events (most recent first).
eggs_per_day: 30-day average eggs per day.
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
cost_per_egg: 30-day average cost per egg in EUR.
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
location_names: Dict mapping location_id to location name for display.
Returns:
@@ -81,6 +83,7 @@ def eggs_page(
action=harvest_action,
recent_events=harvest_events,
eggs_per_day=eggs_per_day,
cost_per_egg=cost_per_egg,
location_names=location_names,
),
cls="uk-active" if harvest_active else None,
@@ -108,6 +111,7 @@ def harvest_form(
action: Callable[..., Any] | str = "/actions/product-collected",
recent_events: list[tuple[Event, bool]] | None = None,
eggs_per_day: float | None = None,
cost_per_egg: float | None = None,
location_names: dict[str, str] | None = None,
) -> Div:
"""Create the Harvest form for egg collection.
@@ -119,6 +123,7 @@ def harvest_form(
action: Route function or URL string for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first.
eggs_per_day: 30-day average eggs per day.
cost_per_egg: 30-day average cost per egg in EUR.
location_names: Dict mapping location_id to location name for display.
Returns:
@@ -160,10 +165,13 @@ def harvest_form(
loc_name = location_names.get(loc_id, "Unknown")
return f"{quantity} eggs from {loc_name}", event.id
# Build stats text
stat_text = None
# Build stats text - combine eggs/day and cost/egg
stat_parts = []
if eggs_per_day is not None:
stat_text = f"{eggs_per_day:.1f} eggs/day (30-day avg)"
stat_parts.append(f"{eggs_per_day:.1f} eggs/day")
if cost_per_egg is not None:
stat_parts.append(f"{cost_per_egg:.3f}/egg cost")
stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None
form = Form(
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
@@ -275,13 +283,18 @@ def sell_form(
total_eur = total_cents / 100
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
# Build stats text
stat_text = None
# Build stats text - combine total sold, revenue, and avg price
stat_parts = []
total_qty = sales_stats.get("total_qty")
total_cents = sales_stats.get("total_cents")
avg_price_cents = sales_stats.get("avg_price_per_egg_cents")
if total_qty is not None and total_cents is not None:
total_eur = total_cents / 100
stat_text = f"{total_qty} sold for €{total_eur:.2f} (30-day total)"
stat_parts.append(f"{total_qty} sold for €{total_eur:.2f}")
if avg_price_cents is not None and avg_price_cents > 0:
avg_price_eur = avg_price_cents / 100
stat_parts.append(f"{avg_price_eur:.2f}/egg avg")
stat_text = " | ".join(stat_parts) + " (30-day)" if stat_parts else None
form = Form(
H2("Sell Products", cls="text-xl font-bold mb-4"),

View File

@@ -36,6 +36,7 @@ def feed_page(
give_events: list[tuple[Event, bool]] | None = None,
purchase_events: list[tuple[Event, bool]] | None = None,
feed_per_bird_per_day_g: float | None = None,
cost_per_bird_per_day: float | None = None,
purchase_stats: dict | None = None,
location_names: dict[str, str] | None = None,
feed_type_names: dict[str, str] | None = None,
@@ -57,6 +58,7 @@ def feed_page(
give_events: Recent FeedGiven events (most recent first).
purchase_events: Recent FeedPurchased events (most recent first).
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
cost_per_bird_per_day: Average feed cost per bird per day in EUR.
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
location_names: Dict mapping location_id to location name.
feed_type_names: Dict mapping feed_type_code to feed type name.
@@ -93,6 +95,7 @@ def feed_page(
action=give_action,
recent_events=give_events,
feed_per_bird_per_day_g=feed_per_bird_per_day_g,
cost_per_bird_per_day=cost_per_bird_per_day,
location_names=location_names,
feed_type_names=feed_type_names,
),
@@ -125,6 +128,7 @@ def give_feed_form(
action: Callable[..., Any] | str = "/actions/feed-given",
recent_events: list[tuple[Event, bool]] | None = None,
feed_per_bird_per_day_g: float | None = None,
cost_per_bird_per_day: float | None = None,
location_names: dict[str, str] | None = None,
feed_type_names: dict[str, str] | None = None,
) -> Div:
@@ -141,6 +145,7 @@ def give_feed_form(
action: Route function or URL for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first.
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
cost_per_bird_per_day: Average feed cost per bird per day in EUR.
location_names: Dict mapping location_id to location name.
feed_type_names: Dict mapping feed_type_code to feed type name.
@@ -207,10 +212,13 @@ def give_feed_form(
feed_name = feed_type_names.get(feed_code, feed_code)
return f"{amount_kg}kg {feed_name} to {loc_name}", event.id
# Build stats text
stat_text = None
# Build stats text - combine g/bird/day and cost/bird/day
stat_parts = []
if feed_per_bird_per_day_g is not None:
stat_text = f"{feed_per_bird_per_day_g:.1f} g/bird/day (30-day avg)"
stat_parts.append(f"{feed_per_bird_per_day_g:.1f}g/bird/day")
if cost_per_bird_per_day is not None:
stat_parts.append(f"{cost_per_bird_per_day:.3f}/bird/day cost")
stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None
form = Form(
H2("Give Feed", cls="text-xl font-bold mb-4"),

View File

@@ -359,3 +359,66 @@ class TestTombstoneChecking:
)
assert event_store.is_tombstoned(event.id) is True
def test_list_events_excludes_tombstoned_by_default(self, migrated_db, event_store, now_utc):
"""list_events excludes tombstoned events by default."""
event1 = event_store.append_event(
event_type=PRODUCT_COLLECTED,
ts_utc=now_utc,
actor="ppetru",
entity_refs={},
payload={"order": 1},
)
event2 = event_store.append_event(
event_type=PRODUCT_COLLECTED,
ts_utc=now_utc + 1000,
actor="ppetru",
entity_refs={},
payload={"order": 2},
)
# Tombstone event1
tombstone_id = generate_id()
migrated_db.execute(
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
VALUES (?, ?, ?, ?, ?)""",
(tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"),
)
events = event_store.list_events()
assert len(events) == 1
assert events[0].id == event2.id
def test_list_events_includes_tombstoned_when_requested(
self, migrated_db, event_store, now_utc
):
"""list_events includes tombstoned events when include_tombstoned=True."""
event1 = event_store.append_event(
event_type=PRODUCT_COLLECTED,
ts_utc=now_utc,
actor="ppetru",
entity_refs={},
payload={"order": 1},
)
event2 = event_store.append_event(
event_type=PRODUCT_COLLECTED,
ts_utc=now_utc + 1000,
actor="ppetru",
entity_refs={},
payload={"order": 2},
)
# Tombstone event1
tombstone_id = generate_id()
migrated_db.execute(
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
VALUES (?, ?, ?, ?, ?)""",
(tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"),
)
events = event_store.list_events(include_tombstoned=True)
assert len(events) == 2
assert events[0].id == event1.id
assert events[1].id == event2.id