Add recent events and stats to eggs, feed, and move forms
All checks were successful
Deploy / deploy (push) Successful in 2m40s

- Create recent_events.py helper for rendering event lists with humanized
  timestamps and deleted event styling (line-through + opacity)
- Query events with ORDER BY ts_utc DESC to show newest first
- Join event_tombstones to detect deleted events
- Fix move form to read animal_ids (not resolved_ids) from entity_refs
- Fix feed purchase format to use total_kg from entity_refs
- Use hx_get with #event-panel-content target for slide-over panel
- Add days-since-last stats for move and feed forms

🤖 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-08 21:10:09 +00:00
parent 62cc6c07d1
commit e42eede010
11 changed files with 1102 additions and 33 deletions

View File

@@ -10,6 +10,7 @@ from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from animaltrack.events import PRODUCT_COLLECTED, PRODUCT_SOLD
from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.models.reference import UserDefault from animaltrack.models.reference import UserDefault
@@ -26,6 +27,9 @@ from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page
from animaltrack.web.templates.eggs import eggs_page from animaltrack.web.templates.eggs import eggs_page
# 30 days in milliseconds
THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
def _parse_ts_utc(form_value: str | None) -> int: def _parse_ts_utc(form_value: str | None) -> int:
"""Parse ts_utc from form, defaulting to current time if empty or zero. """Parse ts_utc from form, defaulting to current time if empty or zero.
@@ -90,6 +94,132 @@ def _get_sellable_products(db):
return [p for p in all_products if p.active and p.sellable] return [p for p in all_products if p.active and p.sellable]
def _get_recent_events(db: Any, event_type: str, limit: int = 10):
"""Get recent events of a type, most recent first.
Args:
db: Database connection.
event_type: Event type string (e.g., PRODUCT_COLLECTED).
limit: Maximum number of events to return.
Returns:
List of (Event, is_deleted) tuples, most recent first.
"""
import json
from animaltrack.models.events import Event
# Query newest events first with tombstone status
query = """
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
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 ?
"""
rows = db.execute(query, (event_type, limit)).fetchall()
return [
(
Event(
id=row[0],
type=row[1],
ts_utc=row[2],
actor=row[3],
entity_refs=json.loads(row[4]),
payload=json.loads(row[5]),
version=row[6],
),
bool(row[7]),
)
for row in rows
]
def _get_eggs_per_day(db: Any, now_ms: int) -> float | None:
"""Calculate eggs per day over 30-day window.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Eggs per day average, or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
event_store = EventStore(db)
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 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
return total_eggs / 30.0
def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
"""Calculate sales statistics over 30-day window.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Dict with 'total_qty' and 'total_cents', or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
event_store = EventStore(db)
events = event_store.list_events(
event_type=PRODUCT_SOLD,
since_utc=window_start,
until_utc=now_ms,
limit=10000,
)
if not events:
return None
total_qty = 0
total_cents = 0
for event in events:
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}
def _get_eggs_display_data(db: Any, locations: list) -> dict:
"""Get all display data for eggs page (events and stats).
Args:
db: Database connection.
locations: List of Location objects for name lookup.
Returns:
Dict with harvest_events, sell_events, eggs_per_day, 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),
"sales_stats": _get_sales_stats(db, now_ms),
"location_names": {loc.id: loc.name for loc in locations},
}
@ar("/") @ar("/")
def egg_index(request: Request): def egg_index(request: Request):
"""GET / - Eggs page with Harvest/Sell tabs.""" """GET / - Eggs page with Harvest/Sell tabs."""
@@ -115,6 +245,9 @@ def egg_index(request: Request):
if defaults: if defaults:
selected_location_id = defaults.location_id selected_location_id = defaults.location_id
# Get recent events and stats
display_data = _get_eggs_display_data(db, locations)
return render_page( return render_page(
request, request,
eggs_page( eggs_page(
@@ -124,6 +257,7 @@ def egg_index(request: Request):
selected_location_id=selected_location_id, selected_location_id=selected_location_id,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
**display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
@@ -152,19 +286,21 @@ async def product_collected(request: Request, session):
# Validate location_id # Validate location_id
if not location_id: if not location_id:
return _render_harvest_error(request, locations, products, None, "Please select a location") return _render_harvest_error(
request, db, locations, products, None, "Please select a location"
)
# Validate quantity # Validate quantity
try: try:
quantity = int(quantity_str) quantity = int(quantity_str)
except ValueError: except ValueError:
return _render_harvest_error( return _render_harvest_error(
request, locations, products, location_id, "Quantity must be a number" request, db, locations, products, location_id, "Quantity must be a number"
) )
if quantity < 1: if quantity < 1:
return _render_harvest_error( return _render_harvest_error(
request, locations, products, location_id, "Quantity must be at least 1" request, db, locations, products, location_id, "Quantity must be at least 1"
) )
# Get timestamp - use provided or current (supports backdating) # Get timestamp - use provided or current (supports backdating)
@@ -175,7 +311,7 @@ async def product_collected(request: Request, session):
if not resolved_ids: if not resolved_ids:
return _render_harvest_error( return _render_harvest_error(
request, locations, products, location_id, "No ducks at this location" request, db, locations, products, location_id, "No ducks at this location"
) )
# Create product service # Create product service
@@ -208,7 +344,7 @@ async def product_collected(request: Request, session):
route="/actions/product-collected", route="/actions/product-collected",
) )
except ValidationError as e: except ValidationError as e:
return _render_harvest_error(request, locations, products, location_id, str(e)) return _render_harvest_error(request, db, locations, products, location_id, str(e))
# Save user defaults (only if user exists in database) # Save user defaults (only if user exists in database)
if UserRepository(db).get(actor): if UserRepository(db).get(actor):
@@ -228,6 +364,9 @@ async def product_collected(request: Request, session):
"success", "success",
) )
# Get display data (includes newly created event)
display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with location sticking, qty cleared # Success: re-render form with location sticking, qty cleared
return render_page( return render_page(
request, request,
@@ -238,6 +377,7 @@ async def product_collected(request: Request, session):
selected_location_id=location_id, selected_location_id=location_id,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
**display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
@@ -268,19 +408,19 @@ async def product_sold(request: Request, session):
# Validate product_code # Validate product_code
if not product_code: if not product_code:
return _render_sell_error(request, locations, products, None, "Please select a product") return _render_sell_error(request, db, locations, products, None, "Please select a product")
# Validate quantity # Validate quantity
try: try:
quantity = int(quantity_str) quantity = int(quantity_str)
except ValueError: except ValueError:
return _render_sell_error( return _render_sell_error(
request, locations, products, product_code, "Quantity must be a number" request, db, locations, products, product_code, "Quantity must be a number"
) )
if quantity < 1: if quantity < 1:
return _render_sell_error( return _render_sell_error(
request, locations, products, product_code, "Quantity must be at least 1" request, db, locations, products, product_code, "Quantity must be at least 1"
) )
# Validate total_price_cents # Validate total_price_cents
@@ -288,12 +428,12 @@ async def product_sold(request: Request, session):
total_price_cents = int(total_price_str) total_price_cents = int(total_price_str)
except ValueError: except ValueError:
return _render_sell_error( return _render_sell_error(
request, locations, products, product_code, "Total price must be a number" request, db, locations, products, product_code, "Total price must be a number"
) )
if total_price_cents < 0: if total_price_cents < 0:
return _render_sell_error( return _render_sell_error(
request, locations, products, product_code, "Total price cannot be negative" request, db, locations, products, product_code, "Total price cannot be negative"
) )
# Get timestamp - use provided or current (supports backdating) # Get timestamp - use provided or current (supports backdating)
@@ -326,7 +466,7 @@ async def product_sold(request: Request, session):
route="/actions/product-sold", route="/actions/product-sold",
) )
except ValidationError as e: except ValidationError as e:
return _render_sell_error(request, locations, products, product_code, str(e)) return _render_sell_error(request, db, locations, products, product_code, str(e))
# Add success toast with link to event # Add success toast with link to event
add_toast( add_toast(
@@ -335,6 +475,9 @@ async def product_sold(request: Request, session):
"success", "success",
) )
# Get display data (includes newly created event)
display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with product sticking # Success: re-render form with product sticking
return render_page( return render_page(
request, request,
@@ -345,17 +488,19 @@ async def product_sold(request: Request, session):
selected_product_code=product_code, selected_product_code=product_code,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
**display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
) )
def _render_harvest_error(request, locations, products, selected_location_id, error_message): def _render_harvest_error(request, db, locations, products, selected_location_id, error_message):
"""Render harvest form with error message. """Render harvest form with error message.
Args: Args:
request: The HTTP request. request: The HTTP request.
db: Database connection.
locations: List of active locations. locations: List of active locations.
products: List of sellable products. products: List of sellable products.
selected_location_id: Currently selected location. selected_location_id: Currently selected location.
@@ -364,6 +509,7 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
""" """
display_data = _get_eggs_display_data(db, locations)
return HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
@@ -376,6 +522,7 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
harvest_error=error_message, harvest_error=error_message,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
**display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
@@ -385,11 +532,12 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
) )
def _render_sell_error(request, locations, products, selected_product_code, error_message): def _render_sell_error(request, db, locations, products, selected_product_code, error_message):
"""Render sell form with error message. """Render sell form with error message.
Args: Args:
request: The HTTP request. request: The HTTP request.
db: Database connection.
locations: List of active locations. locations: List of active locations.
products: List of sellable products. products: List of sellable products.
selected_product_code: Currently selected product code. selected_product_code: Currently selected product code.
@@ -398,6 +546,7 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
""" """
display_data = _get_eggs_display_data(db, locations)
return HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
@@ -410,6 +559,7 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
sell_error=error_message, sell_error=error_message,
harvest_action=product_collected, harvest_action=product_collected,
sell_action=product_sold, sell_action=product_sold,
**display_data,
), ),
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",

View File

@@ -10,8 +10,10 @@ from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from animaltrack.events import FEED_GIVEN, FEED_PURCHASED
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.models.events import Event
from animaltrack.models.reference import UserDefault from animaltrack.models.reference import UserDefault
from animaltrack.projections import EventLogProjection, ProjectionRegistry from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.feed import FeedInventoryProjection from animaltrack.projections.feed import FeedInventoryProjection
@@ -23,6 +25,9 @@ from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page
from animaltrack.web.templates.feed import feed_page from animaltrack.web.templates.feed import feed_page
# 30 days in milliseconds
THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
def _parse_ts_utc(form_value: str | None) -> int: def _parse_ts_utc(form_value: str | None) -> int:
"""Parse ts_utc from form, defaulting to current time if empty or zero. """Parse ts_utc from form, defaulting to current time if empty or zero.
@@ -64,6 +69,164 @@ def get_feed_balance(db: Any, feed_type_code: str) -> int | None:
return row[0] if row else None return row[0] if row else None
def _get_recent_events(db: Any, event_type: str, limit: int = 10):
"""Get recent events of a type, most recent first.
Args:
db: Database connection.
event_type: Event type string.
limit: Maximum number of events to return.
Returns:
List of (Event, is_deleted) tuples, most recent first.
"""
import json
# Query newest events first with tombstone status
query = """
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
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 ?
"""
rows = db.execute(query, (event_type, limit)).fetchall()
return [
(
Event(
id=row[0],
type=row[1],
ts_utc=row[2],
actor=row[3],
entity_refs=json.loads(row[4]),
payload=json.loads(row[5]),
version=row[6],
),
bool(row[7]),
)
for row in rows
]
def _get_feed_per_bird_per_day(db: Any, now_ms: int) -> float | None:
"""Calculate feed consumption per bird per day over 30-day window.
Uses global bird-days across all locations.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Feed consumption in grams per bird per day, or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
# Get total feed given 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,
)
total_kg = sum(e.entity_refs.get("amount_kg", 0) for e in events)
if total_kg == 0:
return None
total_g = total_kg * 1000
# 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
return total_g / bird_days
def _get_purchase_stats(db: Any, now_ms: int) -> dict | None:
"""Calculate purchase statistics over 30-day window.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Dict with 'total_kg' and 'avg_price_per_kg_cents', or None if no data.
"""
window_start = now_ms - THIRTY_DAYS_MS
event_store = EventStore(db)
events = event_store.list_events(
event_type=FEED_PURCHASED,
since_utc=window_start,
until_utc=now_ms,
limit=10000,
)
if not events:
return None
total_kg = 0
total_cost_cents = 0
for event in events:
bag_size = event.entity_refs.get("bag_size_kg", 0)
bags_count = event.entity_refs.get("bags_count", 0)
bag_price_cents = event.entity_refs.get("bag_price_cents", 0)
total_kg += bag_size * bags_count
total_cost_cents += bag_price_cents * bags_count
if total_kg == 0:
return None
avg_price_per_kg_cents = total_cost_cents / total_kg
return {"total_kg": total_kg, "avg_price_per_kg_cents": avg_price_per_kg_cents}
def _get_feed_display_data(db: Any, locations: list, feed_types: list) -> dict:
"""Get all display data for feed page (events and stats).
Args:
db: Database connection.
locations: List of Location objects for name lookup.
feed_types: List of FeedType objects for name lookup.
Returns:
Dict with display data for feed page.
"""
now_ms = int(time.time() * 1000)
return {
"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),
"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},
}
@ar("/feed") @ar("/feed")
def feed_index(request: Request): def feed_index(request: Request):
"""GET /feed - Feed Quick Capture page.""" """GET /feed - Feed Quick Capture page."""
@@ -90,6 +253,9 @@ def feed_index(request: Request):
selected_feed_type_code = defaults.feed_type_code selected_feed_type_code = defaults.feed_type_code
default_amount_kg = defaults.amount_kg default_amount_kg = defaults.amount_kg
# Get recent events and stats
display_data = _get_feed_display_data(db, locations, feed_types)
return render_page( return render_page(
request, request,
feed_page( feed_page(
@@ -101,6 +267,7 @@ def feed_index(request: Request):
default_amount_kg=default_amount_kg, default_amount_kg=default_amount_kg,
give_action=feed_given, give_action=feed_given,
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data,
), ),
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
@@ -135,6 +302,7 @@ async def feed_given(request: Request, session):
if not location_id: if not location_id:
return _render_give_error( return _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Please select a location", "Please select a location",
@@ -146,6 +314,7 @@ async def feed_given(request: Request, session):
if not feed_type_code: if not feed_type_code:
return _render_give_error( return _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Please select a feed type", "Please select a feed type",
@@ -159,6 +328,7 @@ async def feed_given(request: Request, session):
except ValueError: except ValueError:
return _render_give_error( return _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Amount must be a number", "Amount must be a number",
@@ -169,6 +339,7 @@ async def feed_given(request: Request, session):
if amount_kg < 1: if amount_kg < 1:
return _render_give_error( return _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Amount must be at least 1 kg", "Amount must be at least 1 kg",
@@ -211,6 +382,7 @@ async def feed_given(request: Request, session):
except ValidationError as e: except ValidationError as e:
return _render_give_error( return _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
str(e), str(e),
@@ -244,6 +416,9 @@ async def feed_given(request: Request, session):
"success", "success",
) )
# Get display data (includes newly created event)
display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with location/type sticking, amount reset # Success: re-render form with location/type sticking, amount reset
return render_page( return render_page(
request, request,
@@ -257,6 +432,7 @@ async def feed_given(request: Request, session):
balance_warning=balance_warning, balance_warning=balance_warning,
give_action=feed_given, give_action=feed_given,
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data,
), ),
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
@@ -286,6 +462,7 @@ async def feed_purchased(request: Request, session):
if not feed_type_code: if not feed_type_code:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Please select a feed type", "Please select a feed type",
@@ -297,6 +474,7 @@ async def feed_purchased(request: Request, session):
except ValueError: except ValueError:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Bag size must be a number", "Bag size must be a number",
@@ -305,6 +483,7 @@ async def feed_purchased(request: Request, session):
if bag_size_kg < 1: if bag_size_kg < 1:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Bag size must be at least 1 kg", "Bag size must be at least 1 kg",
@@ -316,6 +495,7 @@ async def feed_purchased(request: Request, session):
except ValueError: except ValueError:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Bags count must be a number", "Bags count must be a number",
@@ -324,6 +504,7 @@ async def feed_purchased(request: Request, session):
if bags_count < 1: if bags_count < 1:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Bags count must be at least 1", "Bags count must be at least 1",
@@ -336,6 +517,7 @@ async def feed_purchased(request: Request, session):
except ValueError: except ValueError:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Price must be a number", "Price must be a number",
@@ -344,6 +526,7 @@ async def feed_purchased(request: Request, session):
if bag_price_cents < 0: if bag_price_cents < 0:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
"Price cannot be negative", "Price cannot be negative",
@@ -385,6 +568,7 @@ async def feed_purchased(request: Request, session):
except ValidationError as e: except ValidationError as e:
return _render_purchase_error( return _render_purchase_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
str(e), str(e),
@@ -400,6 +584,9 @@ async def feed_purchased(request: Request, session):
"success", "success",
) )
# Get display data (includes newly created event)
display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with fields cleared # Success: re-render form with fields cleared
return render_page( return render_page(
request, request,
@@ -409,6 +596,7 @@ async def feed_purchased(request: Request, session):
active_tab="purchase", active_tab="purchase",
give_action=feed_given, give_action=feed_given,
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data,
), ),
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
@@ -417,6 +605,7 @@ async def feed_purchased(request: Request, session):
def _render_give_error( def _render_give_error(
request, request,
db,
locations, locations,
feed_types, feed_types,
error_message, error_message,
@@ -427,6 +616,7 @@ def _render_give_error(
Args: Args:
request: The Starlette request object. request: The Starlette request object.
db: Database connection.
locations: List of active locations. locations: List of active locations.
feed_types: List of active feed types. feed_types: List of active feed types.
error_message: Error message to display. error_message: Error message to display.
@@ -436,6 +626,7 @@ def _render_give_error(
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
""" """
display_data = _get_feed_display_data(db, locations, feed_types)
return HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
@@ -449,6 +640,7 @@ def _render_give_error(
give_error=error_message, give_error=error_message,
give_action=feed_given, give_action=feed_given,
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data,
), ),
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
@@ -458,11 +650,12 @@ def _render_give_error(
) )
def _render_purchase_error(request, locations, feed_types, error_message): def _render_purchase_error(request, db, locations, feed_types, error_message):
"""Render purchase form with error message. """Render purchase form with error message.
Args: Args:
request: The Starlette request object. request: The Starlette request object.
db: Database connection.
locations: List of active locations. locations: List of active locations.
feed_types: List of active feed types. feed_types: List of active feed types.
error_message: Error message to display. error_message: Error message to display.
@@ -470,6 +663,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
Returns: Returns:
HTMLResponse with 422 status. HTMLResponse with 422 status.
""" """
display_data = _get_feed_display_data(db, locations, feed_types)
return HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
@@ -481,6 +675,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
purchase_error=error_message, purchase_error=error_message,
give_action=feed_given, give_action=feed_given,
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data,
), ),
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",

View File

@@ -10,8 +10,10 @@ from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from animaltrack.events import ANIMAL_MOVED
from animaltrack.events.payloads import AnimalMovedPayload from animaltrack.events.payloads import AnimalMovedPayload
from animaltrack.events.store import EventStore from animaltrack.events.store import EventStore
from animaltrack.models.events import Event
from animaltrack.projections import EventLogProjection, ProjectionRegistry from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.event_animals import EventAnimalsProjection
@@ -24,6 +26,9 @@ from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page
from animaltrack.web.templates.move import diff_panel, move_form from animaltrack.web.templates.move import diff_panel, move_form
# Milliseconds per day
MS_PER_DAY = 24 * 60 * 60 * 1000
def _parse_ts_utc(form_value: str | None) -> int: def _parse_ts_utc(form_value: str | None) -> int:
"""Parse ts_utc from form, defaulting to current time if empty or zero. """Parse ts_utc from form, defaulting to current time if empty or zero.
@@ -44,6 +49,91 @@ def _parse_ts_utc(form_value: str | None) -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
def _get_recent_move_events(db: Any, limit: int = 10):
"""Get recent AnimalMoved events, most recent first.
Args:
db: Database connection.
limit: Maximum number of events to return.
Returns:
List of (Event, is_deleted) tuples, most recent first.
"""
import json
# Query newest events first with tombstone status
query = """
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
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 ?
"""
rows = db.execute(query, (ANIMAL_MOVED, limit)).fetchall()
return [
(
Event(
id=row[0],
type=row[1],
ts_utc=row[2],
actor=row[3],
entity_refs=json.loads(row[4]),
payload=json.loads(row[5]),
version=row[6],
),
bool(row[7]),
)
for row in rows
]
def _get_days_since_last_move(db: Any, now_ms: int) -> int | None:
"""Calculate days since the last move event.
Args:
db: Database connection.
now_ms: Current timestamp in milliseconds.
Returns:
Number of days since last move, or None if no moves exist.
"""
# Query the most recent move event (newest first)
query = """
SELECT ts_utc FROM events
WHERE type = ?
ORDER BY ts_utc DESC
LIMIT 1
"""
row = db.execute(query, (ANIMAL_MOVED,)).fetchone()
if not row:
return None
diff_ms = now_ms - row[0]
return diff_ms // MS_PER_DAY
def _get_move_display_data(db: Any, locations: list) -> dict:
"""Get all display data for move page (events and stats).
Args:
db: Database connection.
locations: List of Location objects for name lookup.
Returns:
Dict with recent_events, days_since_last_move, location_names.
"""
now_ms = int(time.time() * 1000)
return {
"recent_events": _get_recent_move_events(db, limit=10),
"days_since_last_move": _get_days_since_last_move(db, now_ms),
"location_names": {loc.id: loc.name for loc in locations},
}
# APIRouter for multi-file route organization # APIRouter for multi-file route organization
ar = APIRouter() ar = APIRouter()
@@ -115,6 +205,9 @@ def move_index(request: Request):
animal_repo = AnimalRepository(db) animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids) animals = animal_repo.get_by_ids(resolved_ids)
# Get recent events and stats
display_data = _get_move_display_data(db, locations)
return render_page( return render_page(
request, request,
move_form( move_form(
@@ -128,6 +221,7 @@ def move_index(request: Request):
from_location_name=from_location_name, from_location_name=from_location_name,
action=animal_move, action=animal_move,
animals=animals, animals=animals,
**display_data,
), ),
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",
@@ -298,12 +392,16 @@ async def animal_move(request: Request, session):
"success", "success",
) )
# Get display data for fresh form
display_data = _get_move_display_data(db, locations)
# Success: re-render fresh form (nothing sticks per spec) # Success: re-render fresh form (nothing sticks per spec)
return render_page( return render_page(
request, request,
move_form( move_form(
locations, locations,
action=animal_move, action=animal_move,
**display_data,
), ),
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",
@@ -339,6 +437,9 @@ def _render_error_form(request, db, locations, filter_str, error_message):
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc) from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id) roster_hash = compute_roster_hash(resolved_ids, from_location_id)
# Get display data for recent events and stats
display_data = _get_move_display_data(db, locations)
return HTMLResponse( return HTMLResponse(
content=to_xml( content=to_xml(
render_page( render_page(
@@ -354,6 +455,7 @@ def _render_error_form(request, db, locations, filter_str, error_message):
from_location_name=from_location_name, from_location_name=from_location_name,
error=error_message, error=error_message,
action=animal_move, action=animal_move,
**display_data,
), ),
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",

View File

@@ -15,8 +15,10 @@ from monsterui.all import (
) )
from ulid import ULID from ulid import ULID
from animaltrack.models.events import Event
from animaltrack.models.reference import Location, Product from animaltrack.models.reference import Location, Product
from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section
def eggs_page( def eggs_page(
@@ -29,6 +31,11 @@ def eggs_page(
sell_error: str | None = None, sell_error: str | None = None,
harvest_action: Callable[..., Any] | str = "/actions/product-collected", harvest_action: Callable[..., Any] | str = "/actions/product-collected",
sell_action: Callable[..., Any] | str = "/actions/product-sold", sell_action: Callable[..., Any] | str = "/actions/product-sold",
harvest_events: list[tuple[Event, bool]] | None = None,
sell_events: list[tuple[Event, bool]] | None = None,
eggs_per_day: float | None = None,
sales_stats: dict | None = None,
location_names: dict[str, str] | None = None,
): ):
"""Create the Eggs page with tabbed forms. """Create the Eggs page with tabbed forms.
@@ -42,11 +49,18 @@ def eggs_page(
sell_error: Error message for sell form. sell_error: Error message for sell form.
harvest_action: Route function or URL for harvest form. harvest_action: Route function or URL for harvest form.
sell_action: Route function or URL for sell form. sell_action: Route function or URL for sell form.
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.
location_names: Dict mapping location_id to location name for display.
Returns: Returns:
Page content with tabbed forms. Page content with tabbed forms.
""" """
harvest_active = active_tab == "harvest" harvest_active = active_tab == "harvest"
if location_names is None:
location_names = {}
return Div( return Div(
H1("Eggs", cls="text-2xl font-bold mb-6"), H1("Eggs", cls="text-2xl font-bold mb-6"),
@@ -65,6 +79,9 @@ def eggs_page(
selected_location_id=selected_location_id, selected_location_id=selected_location_id,
error=harvest_error, error=harvest_error,
action=harvest_action, action=harvest_action,
recent_events=harvest_events,
eggs_per_day=eggs_per_day,
location_names=location_names,
), ),
cls="uk-active" if harvest_active else None, cls="uk-active" if harvest_active else None,
), ),
@@ -74,6 +91,8 @@ def eggs_page(
selected_product_code=selected_product_code, selected_product_code=selected_product_code,
error=sell_error, error=sell_error,
action=sell_action, action=sell_action,
recent_events=sell_events,
sales_stats=sales_stats,
), ),
cls=None if harvest_active else "uk-active", cls=None if harvest_active else "uk-active",
), ),
@@ -87,7 +106,10 @@ def harvest_form(
selected_location_id: str | None = None, selected_location_id: str | None = None,
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/product-collected", action: Callable[..., Any] | str = "/actions/product-collected",
) -> Form: recent_events: list[tuple[Event, bool]] | None = None,
eggs_per_day: float | None = None,
location_names: dict[str, str] | None = None,
) -> Div:
"""Create the Harvest form for egg collection. """Create the Harvest form for egg collection.
Args: Args:
@@ -95,10 +117,18 @@ def harvest_form(
selected_location_id: Pre-selected location ID (sticks after submission). selected_location_id: Pre-selected location ID (sticks after submission).
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. 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.
location_names: Dict mapping location_id to location name for display.
Returns: Returns:
Form component for recording egg harvests. Div containing form and recent events section.
""" """
if recent_events is None:
recent_events = []
if location_names is None:
location_names = {}
# Build location options # Build location options
location_options = [ location_options = [
Option( Option(
@@ -123,7 +153,19 @@ def harvest_form(
cls="mb-4", cls="mb-4",
) )
return Form( # Format function for harvest events
def format_harvest_event(event: Event) -> tuple[str, str]:
quantity = event.entity_refs.get("quantity", 0)
loc_id = event.entity_refs.get("location_id", "")
loc_name = location_names.get(loc_id, "Unknown")
return f"{quantity} eggs from {loc_name}", event.id
# Build stats text
stat_text = None
if eggs_per_day is not None:
stat_text = f"{eggs_per_day:.1f} eggs/day (30-day avg)"
form = Form(
H2("Harvest Eggs", cls="text-xl font-bold mb-4"), H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
@@ -164,13 +206,25 @@ def harvest_form(
cls="space-y-4", cls="space-y-4",
) )
return Div(
form,
recent_events_section(
title="Recent Harvests",
events=recent_events,
format_fn=format_harvest_event,
stat_text=stat_text,
),
)
def sell_form( def sell_form(
products: list[Product], products: list[Product],
selected_product_code: str | None = "egg.duck", selected_product_code: str | None = "egg.duck",
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/product-sold", action: Callable[..., Any] | str = "/actions/product-sold",
) -> Form: recent_events: list[tuple[Event, bool]] | None = None,
sales_stats: dict | None = None,
) -> Div:
"""Create the Sell form for recording sales. """Create the Sell form for recording sales.
Args: Args:
@@ -178,10 +232,17 @@ def sell_form(
selected_product_code: Pre-selected product code (defaults to egg.duck). selected_product_code: Pre-selected product code (defaults to egg.duck).
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first.
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
Returns: Returns:
Form component for recording product sales. Div containing form and recent events section.
""" """
if recent_events is None:
recent_events = []
if sales_stats is None:
sales_stats = {}
# Build product options # Build product options
product_options = [ product_options = [
Option( Option(
@@ -206,7 +267,23 @@ def sell_form(
cls="mb-4", cls="mb-4",
) )
return Form( # Format function for sell events
def format_sell_event(event: Event) -> tuple[str, str]:
quantity = event.entity_refs.get("quantity", 0)
product_code = event.entity_refs.get("product_code", "")
total_cents = event.entity_refs.get("total_price_cents", 0)
total_eur = total_cents / 100
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
# Build stats text
stat_text = None
total_qty = sales_stats.get("total_qty")
total_cents = sales_stats.get("total_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)"
form = Form(
H2("Sell Products", cls="text-xl font-bold mb-4"), H2("Sell Products", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
@@ -266,6 +343,16 @@ def sell_form(
cls="space-y-4", cls="space-y-4",
) )
return Div(
form,
recent_events_section(
title="Recent Sales",
events=recent_events,
format_fn=format_sell_event,
stat_text=stat_text,
),
)
# Keep the old function name for backwards compatibility # Keep the old function name for backwards compatibility
def egg_form( def egg_form(
@@ -274,8 +361,8 @@ def egg_form(
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/product-collected", action: Callable[..., Any] | str = "/actions/product-collected",
) -> Div: ) -> Div:
"""Legacy function - returns harvest form wrapped in a Div. """Legacy function - returns harvest form.
Deprecated: Use eggs_page() for the full tabbed interface. Deprecated: Use eggs_page() for the full tabbed interface.
""" """
return Div(harvest_form(locations, selected_location_id, error, action)) return harvest_form(locations, selected_location_id, error, action)

View File

@@ -15,8 +15,10 @@ from monsterui.all import (
) )
from ulid import ULID from ulid import ULID
from animaltrack.models.events import Event
from animaltrack.models.reference import FeedType, Location from animaltrack.models.reference import FeedType, Location
from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section
def feed_page( def feed_page(
@@ -31,6 +33,12 @@ def feed_page(
balance_warning: str | None = None, balance_warning: str | None = None,
give_action: Callable[..., Any] | str = "/actions/feed-given", give_action: Callable[..., Any] | str = "/actions/feed-given",
purchase_action: Callable[..., Any] | str = "/actions/feed-purchased", purchase_action: Callable[..., Any] | str = "/actions/feed-purchased",
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,
purchase_stats: dict | None = None,
location_names: dict[str, str] | None = None,
feed_type_names: dict[str, str] | None = None,
): ):
"""Create the Feed Quick Capture page with tabbed forms. """Create the Feed Quick Capture page with tabbed forms.
@@ -46,11 +54,21 @@ def feed_page(
balance_warning: Warning about negative inventory balance. balance_warning: Warning about negative inventory balance.
give_action: Route function or URL for give feed form. give_action: Route function or URL for give feed form.
purchase_action: Route function or URL for purchase feed form. purchase_action: Route function or URL for purchase feed form.
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.
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.
Returns: Returns:
Page content with tabbed forms. Page content with tabbed forms.
""" """
give_active = active_tab == "give" give_active = active_tab == "give"
if location_names is None:
location_names = {}
if feed_type_names is None:
feed_type_names = {}
return Div( return Div(
H1("Feed", cls="text-2xl font-bold mb-6"), H1("Feed", cls="text-2xl font-bold mb-6"),
@@ -73,11 +91,22 @@ def feed_page(
error=give_error, error=give_error,
balance_warning=balance_warning, balance_warning=balance_warning,
action=give_action, action=give_action,
recent_events=give_events,
feed_per_bird_per_day_g=feed_per_bird_per_day_g,
location_names=location_names,
feed_type_names=feed_type_names,
), ),
cls="uk-active" if give_active else None, cls="uk-active" if give_active else None,
), ),
Li( Li(
purchase_feed_form(feed_types, error=purchase_error, action=purchase_action), purchase_feed_form(
feed_types,
error=purchase_error,
action=purchase_action,
recent_events=purchase_events,
purchase_stats=purchase_stats,
feed_type_names=feed_type_names,
),
cls=None if give_active else "uk-active", cls=None if give_active else "uk-active",
), ),
), ),
@@ -94,7 +123,11 @@ def give_feed_form(
error: str | None = None, error: str | None = None,
balance_warning: str | None = None, balance_warning: str | None = None,
action: Callable[..., Any] | str = "/actions/feed-given", action: Callable[..., Any] | str = "/actions/feed-given",
) -> Form: recent_events: list[tuple[Event, bool]] | None = None,
feed_per_bird_per_day_g: float | None = None,
location_names: dict[str, str] | None = None,
feed_type_names: dict[str, str] | None = None,
) -> Div:
"""Create the Give Feed form. """Create the Give Feed form.
Args: Args:
@@ -106,10 +139,21 @@ def give_feed_form(
error: Error message to display. error: Error message to display.
balance_warning: Warning about negative balance. balance_warning: Warning about negative balance.
action: Route function or URL for form submission. 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.
location_names: Dict mapping location_id to location name.
feed_type_names: Dict mapping feed_type_code to feed type name.
Returns: Returns:
Form component for giving feed. Div containing form and recent events section.
""" """
if recent_events is None:
recent_events = []
if location_names is None:
location_names = {}
if feed_type_names is None:
feed_type_names = {}
# Build location options # Build location options
location_options = [ location_options = [
Option( Option(
@@ -154,7 +198,21 @@ def give_feed_form(
cls="mb-4", cls="mb-4",
) )
return Form( # Format function for feed given events
def format_give_event(event: Event) -> tuple[str, str]:
amount_kg = event.entity_refs.get("amount_kg", 0)
loc_id = event.entity_refs.get("location_id", "")
feed_code = event.entity_refs.get("feed_type_code", "")
loc_name = location_names.get(loc_id, "Unknown")
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
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)"
form = Form(
H2("Give Feed", cls="text-xl font-bold mb-4"), H2("Give Feed", cls="text-xl font-bold mb-4"),
error_component, error_component,
warning_component, warning_component,
@@ -200,22 +258,45 @@ def give_feed_form(
cls="space-y-4", cls="space-y-4",
) )
return Div(
form,
recent_events_section(
title="Recent Feed Given",
events=recent_events,
format_fn=format_give_event,
stat_text=stat_text,
),
)
def purchase_feed_form( def purchase_feed_form(
feed_types: list[FeedType], feed_types: list[FeedType],
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/feed-purchased", action: Callable[..., Any] | str = "/actions/feed-purchased",
) -> Form: recent_events: list[tuple[Event, bool]] | None = None,
purchase_stats: dict | None = None,
feed_type_names: dict[str, str] | None = None,
) -> Div:
"""Create the Purchase Feed form. """Create the Purchase Feed form.
Args: Args:
feed_types: List of active feed types. feed_types: List of active feed types.
error: Error message to display. error: Error message to display.
action: Route function or URL for form submission. action: Route function or URL for form submission.
recent_events: Recent (Event, is_deleted) tuples, most recent first.
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
feed_type_names: Dict mapping feed_type_code to feed type name.
Returns: Returns:
Form component for purchasing feed. Div containing form and recent events section.
""" """
if recent_events is None:
recent_events = []
if purchase_stats is None:
purchase_stats = {}
if feed_type_names is None:
feed_type_names = {}
# Build feed type options # Build feed type options
feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types] feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types]
feed_type_options.insert( feed_type_options.insert(
@@ -230,7 +311,26 @@ def purchase_feed_form(
cls="mb-4", cls="mb-4",
) )
return Form( # Format function for purchase events
# Note: entity_refs has total_kg, payload has bag details
def format_purchase_event(event: Event) -> tuple[str, str]:
total_kg = event.entity_refs.get("total_kg", 0)
price_per_kg = event.entity_refs.get("price_per_kg_cents", 0)
total_cents = total_kg * price_per_kg
total_eur = total_cents / 100
feed_code = event.entity_refs.get("feed_type_code", "")
feed_name = feed_type_names.get(feed_code, feed_code)
return f"{total_kg}kg {feed_name} for €{total_eur:.2f}", event.id
# Build stats text
stat_text = None
total_kg = purchase_stats.get("total_kg")
avg_price = purchase_stats.get("avg_price_per_kg_cents")
if total_kg is not None and avg_price is not None:
avg_eur = avg_price / 100
stat_text = f"{total_kg}kg purchased, €{avg_eur:.2f}/kg avg (30-day)"
form = Form(
H2("Purchase Feed", cls="text-xl font-bold mb-4"), H2("Purchase Feed", cls="text-xl font-bold mb-4"),
error_component, error_component,
# Feed type dropdown - using raw Select to fix value handling # Feed type dropdown - using raw Select to fix value handling
@@ -301,3 +401,13 @@ def purchase_feed_form(
method="post", method="post",
cls="space-y-4", cls="space-y-4",
) )
return Div(
form,
recent_events_section(
title="Recent Purchases",
events=recent_events,
format_fn=format_purchase_event,
stat_text=stat_text,
),
)

View File

@@ -98,7 +98,7 @@ def recent_events_section(events: list[dict[str, Any]]) -> Div:
), ),
href=f"/events/{event.get('event_id')}", href=f"/events/{event.get('event_id')}",
hx_get=f"/events/{event.get('event_id')}", hx_get=f"/events/{event.get('event_id')}",
hx_target="#event-panel", hx_target="#event-panel-content",
hx_swap="innerHTML", hx_swap="innerHTML",
), ),
cls="py-1", cls="py-1",

View File

@@ -8,9 +8,11 @@ from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
from ulid import ULID from ulid import ULID
from animaltrack.models.events import Event
from animaltrack.models.reference import Location from animaltrack.models.reference import Location
from animaltrack.selection.validation import SelectionDiff from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.actions import event_datetime_field from animaltrack.web.templates.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section
def move_form( def move_form(
@@ -25,7 +27,10 @@ def move_form(
error: str | None = None, error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-move", action: Callable[..., Any] | str = "/actions/animal-move",
animals: list | None = None, animals: list | None = None,
) -> Form: recent_events: list[tuple[Event, bool]] | None = None,
days_since_last_move: int | None = None,
location_names: dict[str, str] | None = None,
) -> Div:
"""Create the Move Animals form. """Create the Move Animals form.
Args: Args:
@@ -40,9 +45,12 @@ def move_form(
error: Optional error message to display. error: Optional error message to display.
action: Route function or URL string for form submission. action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional). animals: List of AnimalListItem for checkbox selection (optional).
recent_events: Recent (Event, is_deleted) tuples, most recent first.
days_since_last_move: Number of days since the last move event.
location_names: Dict mapping location_id to location name.
Returns: Returns:
Form component for moving animals. Div containing form and recent events section.
""" """
from animaltrack.web.templates.animal_select import animal_checkbox_list from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -50,6 +58,10 @@ def move_form(
resolved_ids = [] resolved_ids = []
if animals is None: if animals is None:
animals = [] animals = []
if recent_events is None:
recent_events = []
if location_names is None:
location_names = {}
# Build destination location options (exclude from_location if set) # Build destination location options (exclude from_location if set)
location_options = [Option("Select destination...", value="", disabled=True, selected=True)] location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
@@ -103,7 +115,25 @@ def move_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
] ]
return Form( # Format function for move events
# Note: entity_refs stores animal_ids, not resolved_ids
def format_move_event(event: Event) -> tuple[str, str]:
to_loc_id = event.entity_refs.get("to_location_id", "")
to_loc_name = location_names.get(to_loc_id, "Unknown")
count = len(event.entity_refs.get("animal_ids", []))
return f"{count} animals to {to_loc_name}", event.id
# Build stats text
stat_text = None
if days_since_last_move is not None:
if days_since_last_move == 0:
stat_text = "Last move: today"
elif days_since_last_move == 1:
stat_text = "Last move: yesterday"
else:
stat_text = f"Last move: {days_since_last_move} days ago"
form = Form(
H2("Move Animals", cls="text-xl font-bold mb-4"), H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
@@ -152,6 +182,16 @@ def move_form(
cls="space-y-4", cls="space-y-4",
) )
return Div(
form,
recent_events_section(
title="Recent Moves",
events=recent_events,
format_fn=format_move_event,
stat_text=stat_text,
),
)
def diff_panel( def diff_panel(
diff: SelectionDiff, diff: SelectionDiff,

View File

@@ -0,0 +1,119 @@
# ABOUTME: Helper components for displaying recent events on forms.
# ABOUTME: Provides event list rendering with humanized timestamps and links.
import time
from collections.abc import Callable
from typing import Any
from fasthtml.common import Div, P, Span
from animaltrack.models.events import Event
# Milliseconds per unit
MS_PER_SECOND = 1000
MS_PER_MINUTE = 60 * MS_PER_SECOND
MS_PER_HOUR = 60 * MS_PER_MINUTE
MS_PER_DAY = 24 * MS_PER_HOUR
def humanize_time_ago(ts_utc: int) -> str:
"""Convert a timestamp to a human-readable relative time.
Args:
ts_utc: Timestamp in milliseconds since epoch.
Returns:
Human-readable string like "2h ago", "3 days ago", "just now".
"""
now_ms = int(time.time() * 1000)
diff_ms = now_ms - ts_utc
if diff_ms < 0:
return "in the future"
if diff_ms < MS_PER_MINUTE:
return "just now"
if diff_ms < MS_PER_HOUR:
minutes = diff_ms // MS_PER_MINUTE
return f"{minutes}m ago"
if diff_ms < MS_PER_DAY:
hours = diff_ms // MS_PER_HOUR
return f"{hours}h ago"
days = diff_ms // MS_PER_DAY
if days == 1:
return "1 day ago"
return f"{days} days ago"
def recent_events_section(
title: str,
events: list[tuple[Event, bool]],
format_fn: Callable[[Event], tuple[str, str]],
stat_text: str | None = None,
) -> Div:
"""Render a section with stats and recent events.
Args:
title: Section title (e.g., "Recent Harvests").
events: List of (Event, is_deleted) tuples, most recent first.
format_fn: Function that takes an Event and returns (description, event_id).
Description is the text to display, event_id for linking.
stat_text: Optional statistics text to show above the event list.
Returns:
Div containing the stats and event list.
"""
children: list[Any] = []
# Stats section
if stat_text:
children.append(
Div(
P(stat_text, cls="text-sm text-stone-600 dark:text-stone-400"),
cls="mb-3 p-2 bg-stone-50 dark:bg-stone-800 rounded",
)
)
# Title
children.append(
P(
title,
cls="text-xs font-semibold text-stone-500 dark:text-stone-400 uppercase tracking-wide mb-2",
)
)
# Event list
if not events:
children.append(
P("No recent events", cls="text-sm text-stone-400 dark:text-stone-500 italic")
)
else:
event_items = []
for event, is_deleted in events:
description, event_id = format_fn(event)
time_ago = humanize_time_ago(event.ts_utc)
# Apply deleted styling if tombstoned
deleted_cls = "line-through opacity-50" if is_deleted else ""
event_items.append(
Div(
Div(
Span(description, cls=f"flex-1 {deleted_cls}"),
Span(
time_ago, cls=f"text-stone-400 dark:text-stone-500 ml-2 {deleted_cls}"
),
cls="flex justify-between items-center",
),
cls="block text-sm py-1 px-2 rounded hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors cursor-pointer",
hx_get=f"/events/{event_id}",
hx_target="#event-panel-content",
hx_swap="innerHTML",
)
)
children.append(Div(*event_items, cls="space-y-1"))
return Div(*children, cls="mt-6 pt-4 border-t border-stone-200 dark:border-stone-700")

View File

@@ -211,3 +211,60 @@ class TestEggCollection:
# The response should contain the form with the location pre-selected # The response should contain the form with the location pre-selected
# Check for "selected" attribute on the option with our location_id # Check for "selected" attribute on the option with our location_id
assert "selected" in resp.text and location_strip1_id in resp.text assert "selected" in resp.text and location_strip1_id in resp.text
class TestEggsRecentEvents:
"""Tests for recent events display on eggs page."""
def test_harvest_tab_shows_recent_events_section(self, client):
"""Harvest tab shows Recent Harvests section."""
resp = client.get("/")
assert resp.status_code == 200
assert "Recent Harvests" in resp.text
def test_sell_tab_shows_recent_events_section(self, client):
"""Sell tab shows Recent Sales section."""
resp = client.get("/?tab=sell")
assert resp.status_code == 200
assert "Recent Sales" in resp.text
def test_harvest_event_appears_in_recent(
self, client, seeded_db, location_strip1_id, ducks_at_strip1
):
"""Newly created harvest event appears in recent events list."""
resp = client.post(
"/actions/product-collected",
data={
"location_id": location_strip1_id,
"quantity": "12",
"nonce": "test-nonce-recent-1",
},
)
assert resp.status_code == 200
# Recent events should include the newly created event
# Check for event link pattern
assert "/events/" in resp.text
def test_harvest_event_links_to_detail(
self, client, seeded_db, location_strip1_id, ducks_at_strip1
):
"""Harvest events in recent list link to event detail page."""
# Create an event
resp = client.post(
"/actions/product-collected",
data={
"location_id": location_strip1_id,
"quantity": "8",
"nonce": "test-nonce-recent-2",
},
)
assert resp.status_code == 200
# Get the event ID from DB
event_row = seeded_db.execute(
"SELECT id FROM events WHERE type = 'ProductCollected' ORDER BY id DESC LIMIT 1"
).fetchone()
event_id = event_row[0]
# The response should contain a link to the event detail
assert f"/events/{event_id}" in resp.text

View File

@@ -360,3 +360,99 @@ class TestInventoryWarning:
assert resp.status_code in [200, 302, 303] assert resp.status_code in [200, 302, 303]
# The response should contain a warning about negative inventory # The response should contain a warning about negative inventory
assert "warning" in resp.text.lower() or "negative" in resp.text.lower() assert "warning" in resp.text.lower() or "negative" in resp.text.lower()
class TestFeedRecentEvents:
"""Tests for recent events display on feed page."""
def test_give_tab_shows_recent_events_section(self, client):
"""Give Feed tab shows Recent Feed Given section."""
resp = client.get("/feed")
assert resp.status_code == 200
assert "Recent Feed Given" in resp.text
def test_purchase_tab_shows_recent_events_section(self, client):
"""Purchase Feed tab shows Recent Purchases section."""
resp = client.get("/feed?tab=purchase")
assert resp.status_code == 200
assert "Recent Purchases" in resp.text
def test_give_feed_event_appears_in_recent(
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
):
"""Newly created feed given event appears in recent events list."""
resp = client.post(
"/actions/feed-given",
data={
"location_id": location_strip1_id,
"feed_type_code": "layer",
"amount_kg": "5",
"nonce": "test-nonce-recent-feed-1",
},
)
assert resp.status_code == 200
# Recent events should include the newly created event
assert "/events/" in resp.text
def test_give_feed_event_links_to_detail(
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
):
"""Feed given events in recent list link to event detail page."""
resp = client.post(
"/actions/feed-given",
data={
"location_id": location_strip1_id,
"feed_type_code": "layer",
"amount_kg": "5",
"nonce": "test-nonce-recent-feed-2",
},
)
assert resp.status_code == 200
# Get the event ID from DB
event_row = seeded_db.execute(
"SELECT id FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1"
).fetchone()
event_id = event_row[0]
# The response should contain a link to the event detail
assert f"/events/{event_id}" in resp.text
def test_purchase_event_appears_in_recent(self, client, seeded_db):
"""Newly created purchase event appears in recent events list."""
resp = client.post(
"/actions/feed-purchased",
data={
"feed_type_code": "layer",
"bag_size_kg": "20",
"bags_count": "2",
"bag_price_euros": "24.00",
"nonce": "test-nonce-recent-purchase-1",
},
)
# The route returns purchase tab active after purchase
assert resp.status_code == 200
assert "/events/" in resp.text
def test_purchase_event_links_to_detail(self, client, seeded_db):
"""Purchase events in recent list link to event detail page."""
resp = client.post(
"/actions/feed-purchased",
data={
"feed_type_code": "layer",
"bag_size_kg": "20",
"bags_count": "2",
"bag_price_euros": "24.00",
"nonce": "test-nonce-recent-purchase-2",
},
)
assert resp.status_code == 200
# Get the event ID from DB
event_row = seeded_db.execute(
"SELECT id FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1"
).fetchone()
event_id = event_row[0]
# The response should contain a link to the event detail
assert f"/events/{event_id}" in resp.text

View File

@@ -472,3 +472,116 @@ class TestMoveAnimalMismatch:
payload = json.loads(event_row[0]) payload = json.loads(event_row[0])
# Should have moved 3 animals (5 original - 2 moved by client B) # Should have moved 3 animals (5 original - 2 moved by client B)
assert len(payload["resolved_ids"]) == 3 assert len(payload["resolved_ids"]) == 3
class TestMoveRecentEvents:
"""Tests for recent events display on move page."""
def test_move_form_shows_recent_events_section(self, client):
"""Move form shows Recent Moves section."""
resp = client.get("/move")
assert resp.status_code == 200
assert "Recent Moves" in resp.text
def test_move_event_appears_in_recent(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""Newly created move event appears in recent events list."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-recent-move-1",
},
)
assert resp.status_code == 200
# Recent events should include the newly created event
assert "/events/" in resp.text
def test_move_event_links_to_detail(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""Move events in recent list link to event detail page."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-recent-move-2",
},
)
assert resp.status_code == 200
# Get the event ID from DB
event_row = seeded_db.execute(
"SELECT id FROM events WHERE type = 'AnimalMoved' ORDER BY id DESC LIMIT 1"
).fetchone()
event_id = event_row[0]
# The response should contain a link to the event detail
assert f"/events/{event_id}" in resp.text
def test_days_since_last_move_shows_today(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""After a move today, shows 'Last move: today'."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-recent-move-3",
},
)
assert resp.status_code == 200
# Stats should show "Last move: today"
assert "Last move: today" in resp.text