Fix cost/egg window to use later of first egg or first feed event
All checks were successful
Deploy / deploy (push) Successful in 1m39s

When egg data is imported but feed data starts later, cost/egg was
incorrectly using the egg window (e.g., 30 days) instead of the
period where both data types exist. Now cost/egg uses max(first_egg,
first_feed) to ensure accurate cost calculation.

Each metric now displays its own window: "4.7 eggs/day (30d) | €0.28/egg (7d)"

🤖 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-10 20:07:15 +00:00
parent 86dc3a13d2
commit 880ef2b397
2 changed files with 31 additions and 11 deletions

View File

@@ -184,7 +184,8 @@ def _get_global_cost_per_egg(db: Any, now_ms: int) -> tuple[float | None, int]:
"""Calculate global cost per egg over dynamic window. """Calculate global cost per egg over dynamic window.
Aggregates feed costs and egg counts across all locations. Aggregates feed costs and egg counts across all locations.
Uses a dynamic window based on the first egg collection event. Uses a dynamic window based on the later of first egg event or first feed event,
ensuring we only calculate cost for periods with complete data.
Args: Args:
db: Database connection. db: Database connection.
@@ -195,9 +196,22 @@ def _get_global_cost_per_egg(db: Any, now_ms: int) -> tuple[float | None, int]:
""" """
from animaltrack.events import FEED_GIVEN from animaltrack.events import FEED_GIVEN
# Calculate dynamic window based on first egg event # Calculate dynamic window based on the later of first egg or first feed event
# This ensures we only calculate cost/egg for periods with both data types
first_egg_ts = _get_first_event_ts(db, "ProductCollected", product_prefix="egg.") first_egg_ts = _get_first_event_ts(db, "ProductCollected", product_prefix="egg.")
window_start, window_end, window_days = _calculate_window(now_ms, first_egg_ts) first_feed_ts = _get_first_event_ts(db, "FeedGiven")
# Use the later timestamp (max) to ensure complete data for both metrics
if first_egg_ts is None and first_feed_ts is None:
first_event_ts = None
elif first_egg_ts is None:
first_event_ts = first_feed_ts
elif first_feed_ts is None:
first_event_ts = first_egg_ts
else:
first_event_ts = max(first_egg_ts, first_feed_ts)
window_start, window_end, window_days = _calculate_window(now_ms, first_event_ts)
event_store = EventStore(db) event_store = EventStore(db)
@@ -300,17 +314,18 @@ def _get_eggs_display_data(db: Any, locations: list) -> dict:
Returns: Returns:
Dict with harvest_events, sell_events, eggs_per_day, cost_per_egg, Dict with harvest_events, sell_events, eggs_per_day, cost_per_egg,
eggs_window_days, sales_stats, location_names. eggs_window_days, cost_window_days, sales_stats, location_names.
""" """
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
eggs_per_day, eggs_window_days = _get_eggs_per_day(db, now_ms) eggs_per_day, eggs_window_days = _get_eggs_per_day(db, now_ms)
cost_per_egg, _ = _get_global_cost_per_egg(db, now_ms) cost_per_egg, cost_window_days = _get_global_cost_per_egg(db, now_ms)
return { return {
"harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10), "harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10),
"sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10), "sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10),
"eggs_per_day": eggs_per_day, "eggs_per_day": eggs_per_day,
"cost_per_egg": cost_per_egg, "cost_per_egg": cost_per_egg,
"eggs_window_days": eggs_window_days, "eggs_window_days": eggs_window_days,
"cost_window_days": cost_window_days,
"sales_stats": _get_sales_stats(db, now_ms), "sales_stats": _get_sales_stats(db, now_ms),
"location_names": {loc.id: loc.name for loc in locations}, "location_names": {loc.id: loc.name for loc in locations},
} }

View File

@@ -36,6 +36,7 @@ def eggs_page(
eggs_per_day: float | None = None, eggs_per_day: float | None = None,
cost_per_egg: float | None = None, cost_per_egg: float | None = None,
eggs_window_days: int = 30, eggs_window_days: int = 30,
cost_window_days: int = 30,
sales_stats: dict | None = None, sales_stats: dict | None = None,
location_names: dict[str, str] | None = None, location_names: dict[str, str] | None = None,
# Field value preservation on errors # Field value preservation on errors
@@ -62,7 +63,8 @@ def eggs_page(
sell_events: Recent ProductSold events (most recent first). sell_events: Recent ProductSold events (most recent first).
eggs_per_day: Average eggs per day over window. eggs_per_day: Average eggs per day over window.
cost_per_egg: Average cost per egg in EUR over window. cost_per_egg: Average cost per egg in EUR over window.
eggs_window_days: Actual window size in days for the metrics. eggs_window_days: Actual window size in days for eggs_per_day.
cost_window_days: Actual window size in days for cost_per_egg.
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'. 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. location_names: Dict mapping location_id to location name for display.
harvest_quantity: Preserved quantity value on error. harvest_quantity: Preserved quantity value on error.
@@ -100,6 +102,7 @@ def eggs_page(
eggs_per_day=eggs_per_day, eggs_per_day=eggs_per_day,
cost_per_egg=cost_per_egg, cost_per_egg=cost_per_egg,
eggs_window_days=eggs_window_days, eggs_window_days=eggs_window_days,
cost_window_days=cost_window_days,
location_names=location_names, location_names=location_names,
default_quantity=harvest_quantity, default_quantity=harvest_quantity,
default_notes=harvest_notes, default_notes=harvest_notes,
@@ -135,6 +138,7 @@ def harvest_form(
eggs_per_day: float | None = None, eggs_per_day: float | None = None,
cost_per_egg: float | None = None, cost_per_egg: float | None = None,
eggs_window_days: int = 30, eggs_window_days: int = 30,
cost_window_days: int = 30,
location_names: dict[str, str] | None = None, location_names: dict[str, str] | None = None,
default_quantity: str | None = None, default_quantity: str | None = None,
default_notes: str | None = None, default_notes: str | None = None,
@@ -149,7 +153,8 @@ def harvest_form(
recent_events: Recent (Event, is_deleted) tuples, most recent first. recent_events: Recent (Event, is_deleted) tuples, most recent first.
eggs_per_day: Average eggs per day over window. eggs_per_day: Average eggs per day over window.
cost_per_egg: Average cost per egg in EUR over window. cost_per_egg: Average cost per egg in EUR over window.
eggs_window_days: Actual window size in days for the metrics. eggs_window_days: Actual window size in days for eggs_per_day.
cost_window_days: Actual window size in days for cost_per_egg.
location_names: Dict mapping location_id to location name for display. location_names: Dict mapping location_id to location name for display.
default_quantity: Preserved quantity value on error. default_quantity: Preserved quantity value on error.
default_notes: Preserved notes value on error. default_notes: Preserved notes value on error.
@@ -193,13 +198,13 @@ def harvest_form(
loc_name = location_names.get(loc_id, "Unknown") loc_name = location_names.get(loc_id, "Unknown")
return f"{quantity} eggs from {loc_name}", event.id return f"{quantity} eggs from {loc_name}", event.id
# Build stats text - combine eggs/day and cost/egg # Build stats text - each metric shows its own window
stat_parts = [] stat_parts = []
if eggs_per_day is not None: if eggs_per_day is not None:
stat_parts.append(f"{eggs_per_day:.1f} eggs/day") stat_parts.append(f"{eggs_per_day:.1f} eggs/day ({eggs_window_days}d)")
if cost_per_egg is not None: if cost_per_egg is not None:
stat_parts.append(f"{cost_per_egg:.3f}/egg cost") stat_parts.append(f"{cost_per_egg:.3f}/egg ({cost_window_days}d)")
stat_text = " | ".join(stat_parts) + f" ({eggs_window_days}-day avg)" if stat_parts else None stat_text = " | ".join(stat_parts) if stat_parts else None
form = Form( form = Form(
H2("Harvest Eggs", cls="text-xl font-bold mb-4"), H2("Harvest Eggs", cls="text-xl font-bold mb-4"),