diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fd1facf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# Credentials — never commit this
+.env
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+
+# Docker
+*.log
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..0f8124d
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,14 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Run as a non-root user — no reason for a web process to have root privileges
+RUN adduser --disabled-password --no-create-home appuser
+USER appuser
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/database.py b/backend/database.py
new file mode 100644
index 0000000..f81811c
--- /dev/null
+++ b/backend/database.py
@@ -0,0 +1,22 @@
+import os
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+DATABASE_URL = os.environ["DATABASE_URL"]
+
+engine = create_engine(DATABASE_URL, pool_pre_ping=True)
+
+SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ """FastAPI dependency — yields a DB session and closes it when done."""
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..af55333
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,24 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from routers import eggs, flock, feed, stats
+
+app = FastAPI(title="Eggtracker API")
+
+# Allow requests from the Nginx frontend (same host, different port internally)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(eggs.router)
+app.include_router(flock.router)
+app.include_router(feed.router)
+app.include_router(stats.router)
+
+
+@app.get("/api/health")
+def health():
+ return {"status": "ok"}
diff --git a/backend/models.py b/backend/models.py
new file mode 100644
index 0000000..186356f
--- /dev/null
+++ b/backend/models.py
@@ -0,0 +1,35 @@
+from datetime import date, datetime
+from sqlalchemy import Integer, Date, DateTime, Text, Numeric, func
+from sqlalchemy.orm import Mapped, mapped_column
+from database import Base
+
+
+class EggCollection(Base):
+ __tablename__ = "egg_collections"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
+ eggs: Mapped[int] = mapped_column(Integer, nullable=False)
+ notes: Mapped[str] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class FlockHistory(Base):
+ __tablename__ = "flock_history"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
+ chicken_count: Mapped[int] = mapped_column(Integer, nullable=False)
+ notes: Mapped[str] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class FeedPurchase(Base):
+ __tablename__ = "feed_purchases"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
+ bags: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
+ price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
+ notes: Mapped[str] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..f0f9307
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,6 @@
+fastapi==0.115.0
+uvicorn==0.32.0
+sqlalchemy==2.0.36
+pymysql==1.1.1
+cryptography==43.0.3
+pydantic==2.9.2
diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/routers/eggs.py b/backend/routers/eggs.py
new file mode 100644
index 0000000..cb44d84
--- /dev/null
+++ b/backend/routers/eggs.py
@@ -0,0 +1,68 @@
+from datetime import date
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import EggCollection
+from schemas import EggCollectionCreate, EggCollectionUpdate, EggCollectionOut
+
+router = APIRouter(prefix="/api/eggs", tags=["eggs"])
+
+
+@router.get("", response_model=list[EggCollectionOut])
+def list_eggs(
+ start: Optional[date] = None,
+ end: Optional[date] = None,
+ db: Session = Depends(get_db),
+):
+ q = select(EggCollection).order_by(EggCollection.date.desc())
+ if start:
+ q = q.where(EggCollection.date >= start)
+ if end:
+ q = q.where(EggCollection.date <= end)
+ return db.scalars(q).all()
+
+
+@router.post("", response_model=EggCollectionOut, status_code=201)
+def create_egg_collection(body: EggCollectionCreate, db: Session = Depends(get_db)):
+ record = EggCollection(**body.model_dump())
+ db.add(record)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail=f"An entry for {body.date} already exists. Edit it from the History page.")
+ db.refresh(record)
+ return record
+
+
+@router.put("/{record_id}", response_model=EggCollectionOut)
+def update_egg_collection(
+ record_id: int,
+ body: EggCollectionUpdate,
+ db: Session = Depends(get_db),
+):
+ record = db.get(EggCollection, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(record, field, value)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail=f"An entry for that date already exists.")
+ db.refresh(record)
+ return record
+
+
+@router.delete("/{record_id}", status_code=204)
+def delete_egg_collection(record_id: int, db: Session = Depends(get_db)):
+ record = db.get(EggCollection, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ db.delete(record)
+ db.commit()
diff --git a/backend/routers/feed.py b/backend/routers/feed.py
new file mode 100644
index 0000000..8ba2ed7
--- /dev/null
+++ b/backend/routers/feed.py
@@ -0,0 +1,59 @@
+from datetime import date
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import FeedPurchase
+from schemas import FeedPurchaseCreate, FeedPurchaseUpdate, FeedPurchaseOut
+
+router = APIRouter(prefix="/api/feed", tags=["feed"])
+
+
+@router.get("", response_model=list[FeedPurchaseOut])
+def list_feed_purchases(
+ start: Optional[date] = None,
+ end: Optional[date] = None,
+ db: Session = Depends(get_db),
+):
+ q = select(FeedPurchase).order_by(FeedPurchase.date.desc())
+ if start:
+ q = q.where(FeedPurchase.date >= start)
+ if end:
+ q = q.where(FeedPurchase.date <= end)
+ return db.scalars(q).all()
+
+
+@router.post("", response_model=FeedPurchaseOut, status_code=201)
+def create_feed_purchase(body: FeedPurchaseCreate, db: Session = Depends(get_db)):
+ record = FeedPurchase(**body.model_dump())
+ db.add(record)
+ db.commit()
+ db.refresh(record)
+ return record
+
+
+@router.put("/{record_id}", response_model=FeedPurchaseOut)
+def update_feed_purchase(
+ record_id: int,
+ body: FeedPurchaseUpdate,
+ db: Session = Depends(get_db),
+):
+ record = db.get(FeedPurchase, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(record, field, value)
+ db.commit()
+ db.refresh(record)
+ return record
+
+
+@router.delete("/{record_id}", status_code=204)
+def delete_feed_purchase(record_id: int, db: Session = Depends(get_db)):
+ record = db.get(FeedPurchase, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ db.delete(record)
+ db.commit()
diff --git a/backend/routers/flock.py b/backend/routers/flock.py
new file mode 100644
index 0000000..5cfcccb
--- /dev/null
+++ b/backend/routers/flock.py
@@ -0,0 +1,70 @@
+from datetime import date
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import FlockHistory
+from schemas import FlockHistoryCreate, FlockHistoryUpdate, FlockHistoryOut
+
+router = APIRouter(prefix="/api/flock", tags=["flock"])
+
+
+@router.get("", response_model=list[FlockHistoryOut])
+def list_flock_history(db: Session = Depends(get_db)):
+ q = select(FlockHistory).order_by(FlockHistory.date.desc())
+ return db.scalars(q).all()
+
+
+@router.get("/current", response_model=Optional[FlockHistoryOut])
+def get_current_flock(db: Session = Depends(get_db)):
+ """Returns the most recent flock entry — the current flock size."""
+ q = select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
+ return db.scalars(q).first()
+
+
+@router.get("/at/{target_date}", response_model=Optional[FlockHistoryOut])
+def get_flock_at_date(target_date: date, db: Session = Depends(get_db)):
+ """Returns the flock size that was in effect on a given date."""
+ q = (
+ select(FlockHistory)
+ .where(FlockHistory.date <= target_date)
+ .order_by(FlockHistory.date.desc())
+ .limit(1)
+ )
+ return db.scalars(q).first()
+
+
+@router.post("", response_model=FlockHistoryOut, status_code=201)
+def create_flock_entry(body: FlockHistoryCreate, db: Session = Depends(get_db)):
+ record = FlockHistory(**body.model_dump())
+ db.add(record)
+ db.commit()
+ db.refresh(record)
+ return record
+
+
+@router.put("/{record_id}", response_model=FlockHistoryOut)
+def update_flock_entry(
+ record_id: int,
+ body: FlockHistoryUpdate,
+ db: Session = Depends(get_db),
+):
+ record = db.get(FlockHistory, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ for field, value in body.model_dump(exclude_none=True).items():
+ setattr(record, field, value)
+ db.commit()
+ db.refresh(record)
+ return record
+
+
+@router.delete("/{record_id}", status_code=204)
+def delete_flock_entry(record_id: int, db: Session = Depends(get_db)):
+ record = db.get(FlockHistory, record_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="Record not found")
+ db.delete(record)
+ db.commit()
diff --git a/backend/routers/stats.py b/backend/routers/stats.py
new file mode 100644
index 0000000..eb3c401
--- /dev/null
+++ b/backend/routers/stats.py
@@ -0,0 +1,207 @@
+import calendar
+from datetime import date, timedelta
+from decimal import Decimal
+from fastapi import APIRouter, Depends
+from sqlalchemy import select, func
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import EggCollection, FlockHistory, FeedPurchase
+from schemas import DashboardStats, BudgetStats, MonthlySummary
+
+router = APIRouter(prefix="/api/stats", tags=["stats"])
+
+
+def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
+ """
+ For each collection in the last 30 days, look up the flock size that was
+ in effect on that date using a correlated subquery, then average eggs/hen
+ across those days. This gives an accurate result even when flock size changed.
+ """
+ flock_at_date = (
+ select(FlockHistory.chicken_count)
+ .where(FlockHistory.date <= EggCollection.date)
+ .order_by(FlockHistory.date.desc())
+ .limit(1)
+ .correlate(EggCollection)
+ .scalar_subquery()
+ )
+
+ rows = db.execute(
+ select(EggCollection.eggs, flock_at_date.label('flock_count'))
+ .where(EggCollection.date >= start_30d)
+ ).all()
+
+ valid = [(r.eggs, r.flock_count) for r in rows if r.flock_count]
+ if not valid:
+ return None
+ return round(sum(e / f for e, f in valid) / len(valid), 3)
+
+
+def _current_flock(db: Session) -> int | None:
+ row = db.scalars(
+ select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
+ ).first()
+ return row.chicken_count if row else None
+
+
+def _total_eggs(db: Session, start: date | None = None, end: date | None = None) -> int:
+ q = select(func.coalesce(func.sum(EggCollection.eggs), 0))
+ if start:
+ q = q.where(EggCollection.date >= start)
+ if end:
+ q = q.where(EggCollection.date <= end)
+ return db.scalar(q)
+
+
+def _total_feed_cost(db: Session, start: date | None = None, end: date | None = None):
+ q = select(
+ func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0)
+ )
+ if start:
+ q = q.where(FeedPurchase.date >= start)
+ if end:
+ q = q.where(FeedPurchase.date <= end)
+ return db.scalar(q)
+
+
+@router.get("/dashboard", response_model=DashboardStats)
+def dashboard_stats(db: Session = Depends(get_db)):
+ today = date.today()
+ start_30d = today - timedelta(days=30)
+ start_7d = today - timedelta(days=7)
+
+ total_alltime = _total_eggs(db)
+ total_30d = _total_eggs(db, start=start_30d)
+ total_7d = _total_eggs(db, start=start_7d)
+ flock = _current_flock(db)
+
+ # Count how many distinct days have a collection logged
+ days_tracked = db.scalar(
+ select(func.count(func.distinct(EggCollection.date)))
+ )
+
+ # Average eggs per day over the last 30 days (only counting days with data)
+ days_with_data_30d = db.scalar(
+ select(func.count(func.distinct(EggCollection.date)))
+ .where(EggCollection.date >= start_30d)
+ )
+
+ avg_per_day = round(total_30d / days_with_data_30d, 2) if days_with_data_30d else None
+ avg_per_hen = _avg_per_hen_30d(db, start_30d)
+
+ return DashboardStats(
+ current_flock=flock,
+ total_eggs_alltime=total_alltime,
+ total_eggs_30d=total_30d,
+ total_eggs_7d=total_7d,
+ avg_eggs_per_day_30d=avg_per_day,
+ avg_eggs_per_hen_day_30d=avg_per_hen,
+ days_tracked=days_tracked,
+ )
+
+
+@router.get("/monthly", response_model=list[MonthlySummary])
+def monthly_stats(db: Session = Depends(get_db)):
+ MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
+
+ # Monthly egg totals
+ egg_rows = db.execute(
+ select(
+ func.year(EggCollection.date).label('year'),
+ func.month(EggCollection.date).label('month'),
+ func.sum(EggCollection.eggs).label('total_eggs'),
+ func.count(EggCollection.date).label('days_logged'),
+ )
+ .group_by(func.year(EggCollection.date), func.month(EggCollection.date))
+ .order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc())
+ ).all()
+
+ if not egg_rows:
+ return []
+
+ # Monthly feed costs
+ feed_rows = db.execute(
+ select(
+ func.year(FeedPurchase.date).label('year'),
+ func.month(FeedPurchase.date).label('month'),
+ func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag).label('feed_cost'),
+ )
+ .group_by(func.year(FeedPurchase.date), func.month(FeedPurchase.date))
+ ).all()
+
+ feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
+
+ results = []
+ for row in egg_rows:
+ y, m = int(row.year), int(row.month)
+ last_day = calendar.monthrange(y, m)[1]
+ month_end = date(y, m, last_day)
+
+ # Flock size in effect at the end of this month
+ flock_row = db.scalars(
+ select(FlockHistory)
+ .where(FlockHistory.date <= month_end)
+ .order_by(FlockHistory.date.desc())
+ .limit(1)
+ ).first()
+ flock = flock_row.chicken_count if flock_row else None
+
+ total_eggs = int(row.total_eggs)
+ days_logged = int(row.days_logged)
+ avg_per_day = round(total_eggs / days_logged, 2) if days_logged else None
+ avg_per_hen = round(avg_per_day / flock, 3) if (avg_per_day and flock) else None
+
+ raw_feed_cost = feed_map.get((y, m))
+ feed_cost = round(Decimal(str(raw_feed_cost)), 2) if raw_feed_cost else None
+ cpe = round(Decimal(str(raw_feed_cost)) / Decimal(total_eggs), 4) if (raw_feed_cost and total_eggs) else None
+ cpd = round(cpe * 12, 4) if cpe else None
+
+ results.append(MonthlySummary(
+ year=y,
+ month=m,
+ month_label=f"{MONTH_NAMES[m - 1]} {y}",
+ total_eggs=total_eggs,
+ days_logged=days_logged,
+ avg_eggs_per_day=avg_per_day,
+ flock_at_month_end=flock,
+ avg_eggs_per_hen_per_day=avg_per_hen,
+ feed_cost=feed_cost,
+ cost_per_egg=cpe,
+ cost_per_dozen=cpd,
+ ))
+
+ return results
+
+
+@router.get("/budget", response_model=BudgetStats)
+def budget_stats(db: Session = Depends(get_db)):
+ today = date.today()
+ start_30d = today - timedelta(days=30)
+
+ total_cost = _total_feed_cost(db)
+ total_cost_30d = _total_feed_cost(db, start=start_30d)
+ total_eggs = _total_eggs(db)
+ total_eggs_30d = _total_eggs(db, start=start_30d)
+
+ def cost_per_egg(cost, eggs):
+ if not eggs or not cost:
+ return None
+ return round(Decimal(str(cost)) / Decimal(eggs), 4)
+
+ def cost_per_dozen(cpe):
+ return round(cpe * 12, 4) if cpe else None
+
+ cpe = cost_per_egg(total_cost, total_eggs)
+ cpe_30d = cost_per_egg(total_cost_30d, total_eggs_30d)
+
+ return BudgetStats(
+ total_feed_cost=round(Decimal(str(total_cost)), 2) if total_cost else None,
+ total_feed_cost_30d=round(Decimal(str(total_cost_30d)), 2) if total_cost_30d else None,
+ total_eggs_alltime=total_eggs,
+ total_eggs_30d=total_eggs_30d,
+ cost_per_egg=cpe,
+ cost_per_dozen=cost_per_dozen(cpe),
+ cost_per_egg_30d=cpe_30d,
+ cost_per_dozen_30d=cost_per_dozen(cpe_30d),
+ )
diff --git a/backend/schemas.py b/backend/schemas.py
new file mode 100644
index 0000000..fd7b515
--- /dev/null
+++ b/backend/schemas.py
@@ -0,0 +1,109 @@
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+# ── Egg Collections ───────────────────────────────────────────────────────────
+
+class EggCollectionCreate(BaseModel):
+ date: date
+ eggs: int = Field(ge=0)
+ notes: Optional[str] = None
+
+class EggCollectionUpdate(BaseModel):
+ date: Optional[date] = None
+ eggs: Optional[int] = Field(default=None, ge=0)
+ notes: Optional[str] = None
+
+class EggCollectionOut(BaseModel):
+ id: int
+ date: date
+ eggs: int
+ notes: Optional[str]
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+# ── Flock History ─────────────────────────────────────────────────────────────
+
+class FlockHistoryCreate(BaseModel):
+ date: date
+ chicken_count: int = Field(ge=0)
+ notes: Optional[str] = None
+
+class FlockHistoryUpdate(BaseModel):
+ date: Optional[date] = None
+ chicken_count: Optional[int] = Field(default=None, ge=0)
+ notes: Optional[str] = None
+
+class FlockHistoryOut(BaseModel):
+ id: int
+ date: date
+ chicken_count: int
+ notes: Optional[str]
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+# ── Feed Purchases ────────────────────────────────────────────────────────────
+
+class FeedPurchaseCreate(BaseModel):
+ date: date
+ bags: Decimal = Field(gt=0, decimal_places=2)
+ price_per_bag: Decimal = Field(gt=0, decimal_places=2)
+ notes: Optional[str] = None
+
+class FeedPurchaseUpdate(BaseModel):
+ date: Optional[date] = None
+ bags: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
+ price_per_bag: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
+ notes: Optional[str] = None
+
+class FeedPurchaseOut(BaseModel):
+ id: int
+ date: date
+ bags: Decimal
+ price_per_bag: Decimal
+ notes: Optional[str]
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+# ── Stats ─────────────────────────────────────────────────────────────────────
+
+class MonthlySummary(BaseModel):
+ year: int
+ month: int
+ month_label: str
+ total_eggs: int
+ days_logged: int
+ avg_eggs_per_day: Optional[float]
+ flock_at_month_end: Optional[int]
+ avg_eggs_per_hen_per_day: Optional[float]
+ feed_cost: Optional[Decimal]
+ cost_per_egg: Optional[Decimal]
+ cost_per_dozen: Optional[Decimal]
+
+
+class DashboardStats(BaseModel):
+ current_flock: Optional[int]
+ total_eggs_alltime: int
+ total_eggs_30d: int
+ total_eggs_7d: int
+ avg_eggs_per_day_30d: Optional[float]
+ avg_eggs_per_hen_day_30d: Optional[float]
+ days_tracked: int
+
+class BudgetStats(BaseModel):
+ total_feed_cost: Optional[Decimal]
+ total_feed_cost_30d: Optional[Decimal]
+ total_eggs_alltime: int
+ total_eggs_30d: int
+ cost_per_egg: Optional[Decimal]
+ cost_per_dozen: Optional[Decimal]
+ cost_per_egg_30d: Optional[Decimal]
+ cost_per_dozen_30d: Optional[Decimal]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..4ad8e01
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,59 @@
+services:
+
+ # ── MySQL ────────────────────────────────────────────────────────────────────
+ db:
+ image: mysql:8.0
+ restart: unless-stopped
+ env_file: .env
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE}
+ MYSQL_USER: ${MYSQL_USER}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ volumes:
+ - mysql_data:/var/lib/mysql # persistent data
+ - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # run once on first start
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - backend
+
+ # ── FastAPI ──────────────────────────────────────────────────────────────────
+ api:
+ build: ./backend
+ restart: unless-stopped
+ env_file: .env
+ environment:
+ DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
+ depends_on:
+ db:
+ condition: service_healthy # wait for MySQL to be ready before starting
+ networks:
+ - backend
+
+ # ── Nginx ────────────────────────────────────────────────────────────────────
+ nginx:
+ image: nginx:alpine
+ restart: unless-stopped
+ ports:
+ - "8056:80"
+ volumes:
+ - ./nginx/html:/usr/share/nginx/html:ro # static files — edit locally, live immediately
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # nginx config
+ depends_on:
+ - api
+ networks:
+ - backend
+
+# ── Volumes ───────────────────────────────────────────────────────────────────
+volumes:
+ mysql_data: # survives container restarts and rebuilds
+
+# ── Networks ──────────────────────────────────────────────────────────────────
+networks:
+ backend:
+ driver: bridge
diff --git a/mysql/init.sql b/mysql/init.sql
new file mode 100644
index 0000000..fc1ab9b
--- /dev/null
+++ b/mysql/init.sql
@@ -0,0 +1,45 @@
+-- Eggtracker schema
+-- This file runs automatically on first container startup only.
+-- To re-run it, remove the mysql_data volume: docker compose down -v
+
+CREATE DATABASE IF NOT EXISTS eggtracker
+ CHARACTER SET utf8mb4
+ COLLATE utf8mb4_unicode_ci;
+
+USE eggtracker;
+
+-- ── Egg collections ───────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS egg_collections (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ date DATE NOT NULL,
+ eggs INT UNSIGNED NOT NULL,
+ notes TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uq_date (date)
+) ENGINE=InnoDB;
+
+-- ── Flock history ─────────────────────────────────────────────────────────────
+-- Each row records a change in flock size. The count in effect for any given
+-- date is the most recent row with date <= that date.
+CREATE TABLE IF NOT EXISTS flock_history (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ date DATE NOT NULL,
+ chicken_count INT UNSIGNED NOT NULL,
+ notes TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ INDEX idx_date (date)
+) ENGINE=InnoDB;
+
+-- ── Feed purchases ────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS feed_purchases (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ date DATE NOT NULL,
+ bags DECIMAL(5, 2) NOT NULL, -- decimal for partial bags
+ price_per_bag DECIMAL(10, 2) NOT NULL,
+ notes TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ INDEX idx_date (date)
+) ENGINE=InnoDB;
diff --git a/nginx/html/404.html b/nginx/html/404.html
new file mode 100644
index 0000000..198e164
--- /dev/null
+++ b/nginx/html/404.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ 404 Not Found — Eggtracker
+
+
+
+
+
+
+
+
404
+
Page not found
+
The page you're looking for doesn't exist.
+
Go to Dashboard
+
+
+
+
+
diff --git a/nginx/html/50x.html b/nginx/html/50x.html
new file mode 100644
index 0000000..9f71673
--- /dev/null
+++ b/nginx/html/50x.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Server Error — Eggtracker
+
+
+
+
+
+
+
+
5xx
+
Something went wrong
+
The server ran into a problem. This usually means the API container is still starting up — wait a moment and try again.
+
Try Again
+
+
+
+
diff --git a/nginx/html/budget.html b/nginx/html/budget.html
new file mode 100644
index 0000000..079686e
--- /dev/null
+++ b/nginx/html/budget.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+ Budget — Eggtracker
+
+
+
+
+
+
+
+ Budget & Feed Costs
+
+
+
+
+ All-Time
+
+
+
+ Last 30 Days
+
+
+
+
+
+
+ Purchase History
+
+
+
+
+ | Date |
+ Bags |
+ Price / Bag |
+ Total |
+ Notes |
+ |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
+
diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css
new file mode 100644
index 0000000..2240b35
--- /dev/null
+++ b/nginx/html/css/style.css
@@ -0,0 +1,262 @@
+/* ── Variables ───────────────────────────────────────────────────────────── */
+:root {
+ --green: #3d6b4f;
+ --green-dark: #2e5240;
+ --amber: #d4850a;
+ --bg: #f7f4ef;
+ --card-bg: #ffffff;
+ --border: #ddd6cc;
+ --text: #2c2c2c;
+ --muted: #6b6b6b;
+ --danger: #b83232;
+ --danger-dark: #962828;
+ --radius: 8px;
+ --shadow: 0 2px 8px rgba(0,0,0,0.08);
+ --nav-h: 56px;
+}
+
+/* ── Reset ───────────────────────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: system-ui, -apple-system, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+a { color: var(--green); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Nav ─────────────────────────────────────────────────────────────────── */
+.nav {
+ height: var(--nav-h);
+ background: var(--green);
+ display: flex;
+ align-items: center;
+ padding: 0 1.5rem;
+ gap: 2rem;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+}
+
+.nav-brand {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: #fff;
+ white-space: nowrap;
+}
+.nav-brand:hover { text-decoration: none; }
+
+.nav-links {
+ display: flex;
+ gap: 0.25rem;
+ list-style: none;
+}
+
+.nav-links a {
+ color: rgba(255,255,255,0.8);
+ padding: 0.4rem 0.75rem;
+ border-radius: 4px;
+ font-size: 0.95rem;
+ transition: background 0.15s, color 0.15s;
+}
+.nav-links a:hover,
+.nav-links a.active {
+ background: rgba(255,255,255,0.18);
+ color: #fff;
+ text-decoration: none;
+}
+
+/* ── Layout ──────────────────────────────────────────────────────────────── */
+.container {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+h1 { font-size: 1.6rem; margin-bottom: 1.5rem; }
+h2 { font-size: 1.15rem; margin-bottom: 1rem; color: var(--green-dark); }
+
+/* ── Stat cards ──────────────────────────────────────────────────────────── */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.25rem 1rem;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+.stat-card .label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--muted);
+ margin-bottom: 0.4rem;
+}
+.stat-card .value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--green);
+ line-height: 1.1;
+}
+.stat-card .unit {
+ font-size: 0.78rem;
+ color: var(--muted);
+ margin-top: 0.2rem;
+}
+.stat-card.accent .value { color: var(--amber); }
+
+/* ── Cards / sections ────────────────────────────────────────────────────── */
+.card {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+ margin-bottom: 2rem;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+/* ── Tables ──────────────────────────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; }
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--card-bg);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ font-size: 0.93rem;
+}
+
+thead { background: var(--green); color: #fff; }
+th, td { padding: 0.7rem 1rem; text-align: left; }
+th { font-weight: 600; font-size: 0.82rem; letter-spacing: 0.04em; }
+tbody tr:nth-child(even) { background: #f9f7f4; }
+tbody tr:hover { background: #f0ebe3; }
+
+td.notes { color: var(--muted); font-style: italic; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.actions { white-space: nowrap; }
+.empty-row td { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; }
+.total-row td { font-weight: 600; background: #ede8e0 !important; }
+
+/* ── Forms ───────────────────────────────────────────────────────────────── */
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
+ gap: 1rem;
+ align-items: end;
+}
+
+.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
+.form-group.span-full { grid-column: 1 / -1; }
+
+label { font-size: 0.875rem; font-weight: 500; }
+
+input[type="text"],
+input[type="number"],
+input[type="date"],
+textarea,
+select {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 0.93rem;
+ font-family: inherit;
+ background: #fff;
+ color: var(--text);
+ transition: border-color 0.15s, box-shadow 0.15s;
+ width: 100%;
+}
+input:focus, textarea:focus, select:focus {
+ outline: none;
+ border-color: var(--green);
+ box-shadow: 0 0 0 3px rgba(61,107,79,0.12);
+}
+textarea { resize: vertical; min-height: 68px; }
+
+/* Inline edit inputs inside table cells */
+td input[type="text"],
+td input[type="number"],
+td input[type="date"] {
+ padding: 0.3rem 0.5rem;
+ font-size: 0.88rem;
+ min-width: 80px;
+}
+
+/* ── Buttons ─────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-block;
+ padding: 0.5rem 1.1rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ font-family: inherit;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+ white-space: nowrap;
+ line-height: 1.4;
+}
+.btn-primary { background: var(--green); color: #fff; }
+.btn-primary:hover { background: var(--green-dark); }
+.btn-danger { background: var(--danger); color: #fff; }
+.btn-danger:hover { background: var(--danger-dark); }
+.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
+.btn-ghost:hover { background: #ede8e0; }
+.btn-sm { padding: 0.28rem 0.65rem; font-size: 0.8rem; }
+
+/* ── Messages ────────────────────────────────────────────────────────────── */
+.message {
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+ display: none;
+}
+.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
+.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
+.message.visible { display: block; }
+
+/* ── Filter bar ──────────────────────────────────────────────────────────── */
+.filter-bar {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+}
+.filter-bar .form-group { min-width: 140px; }
+
+/* ── Misc ────────────────────────────────────────────────────────────────── */
+.text-muted { color: var(--muted); font-size: 0.9rem; }
+.mt-1 { margin-top: 0.5rem; }
+.mt-2 { margin-top: 1rem; }
+.gap-1 { gap: 0.5rem; }
+.flex { display: flex; }
+
+/* ── Mobile nav ──────────────────────────────────────────────────────────── */
+@media (max-width: 640px) {
+ .nav { gap: 0.5rem; padding: 0 0.75rem; }
+ .nav-brand span { display: none; } /* hide text, keep emoji */
+ .nav-links { overflow-x: auto; scrollbar-width: none; }
+ .nav-links::-webkit-scrollbar { display: none; }
+ .nav-links a { padding: 0.4rem 0.55rem; font-size: 0.82rem; white-space: nowrap; }
+}
diff --git a/nginx/html/favicon.svg b/nginx/html/favicon.svg
new file mode 100644
index 0000000..d8bf08b
--- /dev/null
+++ b/nginx/html/favicon.svg
@@ -0,0 +1,3 @@
+
diff --git a/nginx/html/flock.html b/nginx/html/flock.html
new file mode 100644
index 0000000..b33a2d1
--- /dev/null
+++ b/nginx/html/flock.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Flock — Eggtracker
+
+
+
+
+
+
+
+ Flock Management
+
+
+
+
+
Current Flock Size
+
—
+
chickens
+
+
+
+
+
+
+
Log a Flock Change
+
+
+
+
+
+ Flock History
+
+
+
+
+ | Date |
+ Count |
+ Notes |
+ |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
diff --git a/nginx/html/history.html b/nginx/html/history.html
new file mode 100644
index 0000000..43ae5e9
--- /dev/null
+++ b/nginx/html/history.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ History — Eggtracker
+
+
+
+
+
+
+
+ Egg Collection History
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Eggs |
+ Notes |
+ |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
+
diff --git a/nginx/html/index.html b/nginx/html/index.html
new file mode 100644
index 0000000..d531849
--- /dev/null
+++ b/nginx/html/index.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ Dashboard — Eggtracker
+
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+
+
+
+
+
+
+
Avg / Hen / Day (30d)
—
eggs/hen
+
+
+
+
+
+
+
+
+
Eggs — Last 30 Days
+
No egg data yet — log some collections to see the chart.
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Eggs |
+ Notes |
+
+
+
+ | Loading… |
+
+
+
+
+ View full history →
+
+
+
+
+
+
+
diff --git a/nginx/html/js/api.js b/nginx/html/js/api.js
new file mode 100644
index 0000000..937d46c
--- /dev/null
+++ b/nginx/html/js/api.js
@@ -0,0 +1,67 @@
+// api.js — shared fetch helpers and utilities used by every page
+
+const API = {
+ async _fetch(url, options = {}) {
+ const res = await fetch(url, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
+ throw new Error(err.detail || `Request failed (${res.status})`);
+ }
+ if (res.status === 204) return null; // DELETE returns No Content
+ return res.json();
+ },
+
+ get: (url) => API._fetch(url),
+ post: (url, data) => API._fetch(url, { method: 'POST', body: JSON.stringify(data) }),
+ put: (url, data) => API._fetch(url, { method: 'PUT', body: JSON.stringify(data) }),
+ del: (url) => API._fetch(url, { method: 'DELETE' }),
+};
+
+// Show a timed success or error message inside a .message element
+function showMessage(el, text, type = 'success') {
+ el.textContent = text;
+ el.className = `message ${type} visible`;
+ setTimeout(() => { el.className = 'message'; }, 4000);
+}
+
+// Set an input[type=date] to today's date (using local time, not UTC)
+function setToday(inputEl) {
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = String(now.getMonth() + 1).padStart(2, '0');
+ const d = String(now.getDate()).padStart(2, '0');
+ inputEl.value = `${y}-${m}-${d}`;
+}
+
+// Format YYYY-MM-DD → MM/DD/YYYY for display
+function fmtDate(str) {
+ if (!str) return '—';
+ const [y, m, d] = str.split('-');
+ return `${m}/${d}/${y}`;
+}
+
+// Format a number as a dollar amount
+function fmtMoney(val) {
+ if (val == null || val === '' || isNaN(Number(val))) return '—';
+ return '$' + Number(val).toFixed(2);
+}
+
+// Format a small decimal (cost per egg) with 4 decimal places
+function fmtMoneyFull(val) {
+ if (val == null || val === '' || isNaN(Number(val))) return '—';
+ return '$' + Number(val).toFixed(4);
+}
+
+// Highlight the nav link that matches the current page
+function highlightNav() {
+ const path = window.location.pathname.replace(/\.html$/, '').replace(/\/$/, '') || '/';
+ document.querySelectorAll('.nav-links a').forEach(a => {
+ const href = a.getAttribute('href').replace(/\/$/, '') || '/';
+ if (href === path) a.classList.add('active');
+ });
+}
+
+document.addEventListener('DOMContentLoaded', highlightNav);
diff --git a/nginx/html/js/budget.js b/nginx/html/js/budget.js
new file mode 100644
index 0000000..d4a0081
--- /dev/null
+++ b/nginx/html/js/budget.js
@@ -0,0 +1,163 @@
+let feedData = [];
+
+async function loadBudget() {
+ const msg = document.getElementById('msg');
+ try {
+ const [stats, purchases] = await Promise.all([
+ API.get('/api/stats/budget'),
+ API.get('/api/feed'),
+ ]);
+
+ // All-time stats
+ document.getElementById('b-cost-total').textContent = fmtMoney(stats.total_feed_cost);
+ document.getElementById('b-eggs-total').textContent = stats.total_eggs_alltime;
+ document.getElementById('b-cpe').textContent = fmtMoneyFull(stats.cost_per_egg);
+ document.getElementById('b-cpd').textContent = fmtMoney(stats.cost_per_dozen);
+
+ // Last 30 days
+ document.getElementById('b-cost-30d').textContent = fmtMoney(stats.total_feed_cost_30d);
+ document.getElementById('b-eggs-30d').textContent = stats.total_eggs_30d;
+ document.getElementById('b-cpe-30d').textContent = fmtMoneyFull(stats.cost_per_egg_30d);
+ document.getElementById('b-cpd-30d').textContent = fmtMoney(stats.cost_per_dozen_30d);
+
+ feedData = purchases;
+ renderTable();
+ } catch (err) {
+ showMessage(msg, `Failed to load budget data: ${err.message}`, 'error');
+ }
+}
+
+function renderTable() {
+ const tbody = document.getElementById('feed-body');
+ const tfoot = document.getElementById('feed-foot');
+
+ if (feedData.length === 0) {
+ tbody.innerHTML = '| No feed purchases logged yet. |
';
+ tfoot.innerHTML = '';
+ return;
+ }
+
+ tbody.innerHTML = feedData.map(e => {
+ const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2);
+ return `
+
+ | ${fmtDate(e.date)} |
+ ${parseFloat(e.bags)} |
+ ${fmtMoney(e.price_per_bag)} |
+ ${fmtMoney(total)} |
+ ${e.notes || ''} |
+
+
+
+ |
+
+ `;
+ }).join('');
+
+ // Total row
+ const grandTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0);
+ tfoot.innerHTML = `
+
+ | Total |
+ ${fmtMoney(grandTotal)} |
+ ${feedData.length} purchases |
+
+ `;
+}
+
+function startEdit(id) {
+ const entry = feedData.find(e => e.id === id);
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+
+ row.innerHTML = `
+ |
+ |
+ |
+ — |
+ |
+
+
+
+ |
+ `;
+}
+
+async function saveEdit(id) {
+ const msg = document.getElementById('msg');
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+ const inputs = row.querySelectorAll('input');
+ const [dateInput, bagsInput, priceInput, notesInput] = inputs;
+
+ try {
+ const updated = await API.put(`/api/feed/${id}`, {
+ date: dateInput.value,
+ bags: parseFloat(bagsInput.value),
+ price_per_bag: parseFloat(priceInput.value),
+ notes: notesInput.value.trim() || null,
+ });
+ const idx = feedData.findIndex(e => e.id === id);
+ feedData[idx] = updated;
+ renderTable();
+ loadBudget();
+ showMessage(msg, 'Purchase updated.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+async function deleteEntry(id) {
+ if (!confirm('Delete this purchase?')) return;
+ const msg = document.getElementById('msg');
+ try {
+ await API.del(`/api/feed/${id}`);
+ feedData = feedData.filter(e => e.id !== id);
+ renderTable();
+ loadBudget();
+ showMessage(msg, 'Purchase deleted.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.getElementById('feed-form');
+ const msg = document.getElementById('msg');
+ const bagsInput = document.getElementById('bags');
+ const priceInput = document.getElementById('price');
+ const totalDisplay = document.getElementById('total-display');
+
+ setToday(document.getElementById('date'));
+
+ // Live total calculation
+ function updateTotal() {
+ const bags = parseFloat(bagsInput.value) || 0;
+ const price = parseFloat(priceInput.value) || 0;
+ totalDisplay.value = bags && price ? fmtMoney(bags * price) : '';
+ }
+ bagsInput.addEventListener('input', updateTotal);
+ priceInput.addEventListener('input', updateTotal);
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const data = {
+ date: document.getElementById('date').value,
+ bags: parseFloat(bagsInput.value),
+ price_per_bag: parseFloat(priceInput.value),
+ notes: document.getElementById('notes').value.trim() || null,
+ };
+
+ try {
+ await API.post('/api/feed', data);
+ showMessage(msg, 'Purchase saved!');
+ form.reset();
+ totalDisplay.value = '';
+ setToday(document.getElementById('date'));
+ loadBudget();
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+ });
+
+ loadBudget();
+});
diff --git a/nginx/html/js/dashboard.js b/nginx/html/js/dashboard.js
new file mode 100644
index 0000000..0f13b9f
--- /dev/null
+++ b/nginx/html/js/dashboard.js
@@ -0,0 +1,127 @@
+let eggChart = null;
+
+function buildChart(eggs) {
+ const today = new Date();
+ const labels = [];
+ const data = [];
+
+ // Build a lookup map from date string → egg count
+ const eggMap = {};
+ eggs.forEach(e => { eggMap[e.date] = e.eggs; });
+
+ // Generate the last 30 days in chronological order
+ for (let i = 29; i >= 0; i--) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - i);
+ const y = d.getFullYear();
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
+ const dy = String(d.getDate()).padStart(2, '0');
+ const dateStr = `${y}-${mo}-${dy}`;
+ labels.push(`${mo}/${dy}`);
+ data.push(eggMap[dateStr] ?? 0);
+ }
+
+ const ctx = document.getElementById('eggs-chart').getContext('2d');
+ const chartWrap = document.getElementById('chart-wrap');
+ const noDataMsg = document.getElementById('chart-no-data');
+ const hasData = data.some(v => v > 0);
+
+ if (!hasData) {
+ if (chartWrap) chartWrap.style.display = 'none';
+ if (noDataMsg) noDataMsg.style.display = 'block';
+ return;
+ }
+
+ if (chartWrap) chartWrap.style.display = 'block';
+ if (noDataMsg) noDataMsg.style.display = 'none';
+
+ if (eggChart) eggChart.destroy(); // prevent duplicate charts on re-render
+
+ eggChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ borderColor: '#3d6b4f',
+ backgroundColor: 'rgba(61,107,79,0.08)',
+ borderWidth: 2,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ fill: true,
+ tension: 0.3,
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: ctx => ` ${ctx.parsed.y} eggs`,
+ },
+ },
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ suggestedMax: 5,
+ ticks: { stepSize: 1, precision: 0 },
+ },
+ x: {
+ ticks: { maxTicksLimit: 10 },
+ },
+ },
+ },
+ });
+}
+
+async function loadDashboard() {
+ const msg = document.getElementById('msg');
+
+ try {
+ // Fetch stats and recent eggs in parallel
+ const [stats, budget, eggs] = await Promise.all([
+ API.get('/api/stats/dashboard'),
+ API.get('/api/stats/budget'),
+ API.get('/api/eggs'),
+ ]);
+
+ // Populate stat cards
+ document.getElementById('s-flock').textContent = stats.current_flock ?? '—';
+ document.getElementById('s-total').textContent = stats.total_eggs_alltime;
+ document.getElementById('s-7d').textContent = stats.total_eggs_7d;
+ document.getElementById('s-30d').textContent = stats.total_eggs_30d;
+ document.getElementById('s-avg-day').textContent = stats.avg_eggs_per_day_30d ?? '—';
+ document.getElementById('s-avg-hen').textContent = stats.avg_eggs_per_hen_day_30d ?? '—';
+ document.getElementById('s-cpe').textContent = fmtMoneyFull(budget.cost_per_egg);
+ document.getElementById('s-cpe-30d').textContent = fmtMoneyFull(budget.cost_per_egg_30d);
+ document.getElementById('s-cpd').textContent = fmtMoney(budget.cost_per_dozen);
+ document.getElementById('s-cpd-30d').textContent = fmtMoney(budget.cost_per_dozen_30d);
+
+ // Trend chart — uses all fetched eggs, filtered to last 30 days inside buildChart
+ buildChart(eggs);
+
+ // Recent 10 collections
+ const tbody = document.getElementById('recent-body');
+ const recent = eggs.slice(0, 10);
+
+ if (recent.length === 0) {
+ tbody.innerHTML = '| No eggs logged yet. Log your first collection → |
';
+ return;
+ }
+
+ tbody.innerHTML = recent.map(e => `
+
+ | ${fmtDate(e.date)} |
+ ${e.eggs} |
+ ${e.notes || ''} |
+
+ `).join('');
+
+ } catch (err) {
+ showMessage(msg, `Failed to load dashboard: ${err.message}`, 'error');
+ }
+}
+
+document.addEventListener('DOMContentLoaded', loadDashboard);
diff --git a/nginx/html/js/flock.js b/nginx/html/js/flock.js
new file mode 100644
index 0000000..ffc7165
--- /dev/null
+++ b/nginx/html/js/flock.js
@@ -0,0 +1,119 @@
+let flockData = [];
+
+async function loadFlock() {
+ const msg = document.getElementById('msg');
+ try {
+ const [current, history] = await Promise.all([
+ API.get('/api/flock/current'),
+ API.get('/api/flock'),
+ ]);
+
+ document.getElementById('current-count').textContent = current?.chicken_count ?? '—';
+ document.getElementById('current-date').textContent = current ? fmtDate(current.date) : 'No data';
+
+ flockData = history;
+ renderTable();
+ } catch (err) {
+ showMessage(msg, `Failed to load flock data: ${err.message}`, 'error');
+ }
+}
+
+function renderTable() {
+ const tbody = document.getElementById('flock-body');
+
+ if (flockData.length === 0) {
+ tbody.innerHTML = '| No flock history yet. |
';
+ return;
+ }
+
+ tbody.innerHTML = flockData.map(e => `
+
+ | ${fmtDate(e.date)} |
+ ${e.chicken_count} |
+ ${e.notes || ''} |
+
+
+
+ |
+
+ `).join('');
+}
+
+function startEdit(id) {
+ const entry = flockData.find(e => e.id === id);
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+
+ row.innerHTML = `
+ |
+ |
+ |
+
+
+
+ |
+ `;
+}
+
+async function saveEdit(id) {
+ const msg = document.getElementById('msg');
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+ const [dateInput, countInput, notesInput] = row.querySelectorAll('input');
+
+ try {
+ const updated = await API.put(`/api/flock/${id}`, {
+ date: dateInput.value,
+ chicken_count: parseInt(countInput.value, 10),
+ notes: notesInput.value.trim() || null,
+ });
+ const idx = flockData.findIndex(e => e.id === id);
+ flockData[idx] = updated;
+ renderTable();
+ loadFlock(); // refresh current-flock callout
+ showMessage(msg, 'Entry updated.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+async function deleteEntry(id) {
+ if (!confirm('Delete this flock entry?')) return;
+ const msg = document.getElementById('msg');
+ try {
+ await API.del(`/api/flock/${id}`);
+ flockData = flockData.filter(e => e.id !== id);
+ renderTable();
+ loadFlock();
+ showMessage(msg, 'Entry deleted.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.getElementById('flock-form');
+ const msg = document.getElementById('msg');
+
+ setToday(document.getElementById('date'));
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const data = {
+ date: document.getElementById('date').value,
+ chicken_count: parseInt(document.getElementById('count').value, 10),
+ notes: document.getElementById('notes').value.trim() || null,
+ };
+
+ try {
+ await API.post('/api/flock', data);
+ showMessage(msg, 'Flock change saved!');
+ form.reset();
+ setToday(document.getElementById('date'));
+ loadFlock();
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+ });
+
+ loadFlock();
+});
diff --git a/nginx/html/js/history.js b/nginx/html/js/history.js
new file mode 100644
index 0000000..eef8030
--- /dev/null
+++ b/nginx/html/js/history.js
@@ -0,0 +1,118 @@
+let currentData = [];
+
+async function loadHistory() {
+ const tbody = document.getElementById('history-body');
+ const tfoot = document.getElementById('history-foot');
+ const msg = document.getElementById('msg');
+ const start = document.getElementById('filter-start').value;
+ const end = document.getElementById('filter-end').value;
+
+ let url = '/api/eggs';
+ const params = new URLSearchParams();
+ if (start) params.set('start', start);
+ if (end) params.set('end', end);
+ if ([...params].length) url += '?' + params.toString();
+
+ try {
+ currentData = await API.get(url);
+
+ if (currentData.length === 0) {
+ tbody.innerHTML = '| No entries found. |
';
+ tfoot.innerHTML = '';
+ return;
+ }
+
+ renderTable();
+ } catch (err) {
+ showMessage(msg, `Failed to load history: ${err.message}`, 'error');
+ }
+}
+
+
+function renderTable() {
+ const tbody = document.getElementById('history-body');
+ const tfoot = document.getElementById('history-foot');
+
+ // Update result count label
+ const total = currentData.reduce((sum, e) => sum + e.eggs, 0);
+ const countEl = document.getElementById('result-count');
+ if (countEl) countEl.textContent = `${currentData.length} entries · ${total} eggs`;
+
+ tbody.innerHTML = currentData.map(e => `
+
+ | ${fmtDate(e.date)} |
+ ${e.eggs} |
+ ${e.notes || ''} |
+
+
+
+ |
+
+ `).join('');
+
+ // Total row in footer
+ tfoot.innerHTML = `
+
+ | Total |
+ ${total} |
+ ${currentData.length} entries |
+
+ `;
+}
+
+function startEdit(id) {
+ const entry = currentData.find(e => e.id === id);
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+
+ row.innerHTML = `
+ |
+ |
+ |
+
+
+
+ |
+ `;
+}
+
+async function saveEdit(id) {
+ const msg = document.getElementById('msg');
+ const row = document.querySelector(`tr[data-id="${id}"]`);
+ const [dateInput, eggsInput, notesInput] = row.querySelectorAll('input');
+
+ try {
+ const updated = await API.put(`/api/eggs/${id}`, {
+ date: dateInput.value,
+ eggs: parseInt(eggsInput.value, 10),
+ notes: notesInput.value.trim() || null,
+ });
+ // Update local data and re-render
+ const idx = currentData.findIndex(e => e.id === id);
+ currentData[idx] = updated;
+ renderTable();
+ showMessage(msg, 'Entry updated.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+async function deleteEntry(id) {
+ if (!confirm('Delete this entry?')) return;
+ const msg = document.getElementById('msg');
+ try {
+ await API.del(`/api/eggs/${id}`);
+ currentData = currentData.filter(e => e.id !== id);
+ renderTable();
+ showMessage(msg, 'Entry deleted.');
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+}
+
+function clearFilter() {
+ document.getElementById('filter-start').value = '';
+ document.getElementById('filter-end').value = '';
+ loadHistory();
+}
+
+document.addEventListener('DOMContentLoaded', loadHistory);
diff --git a/nginx/html/js/log.js b/nginx/html/js/log.js
new file mode 100644
index 0000000..3fe8c23
--- /dev/null
+++ b/nginx/html/js/log.js
@@ -0,0 +1,52 @@
+async function loadRecent() {
+ const tbody = document.getElementById('recent-body');
+ try {
+ const eggs = await API.get('/api/eggs');
+ const recent = eggs.slice(0, 7);
+
+ if (recent.length === 0) {
+ tbody.innerHTML = '| No entries yet. |
';
+ return;
+ }
+
+ tbody.innerHTML = recent.map(e => `
+
+ | ${fmtDate(e.date)} |
+ ${e.eggs} |
+ ${e.notes || ''} |
+
+ `).join('');
+ } catch (err) {
+ tbody.innerHTML = '| Could not load recent entries. |
';
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const form = document.getElementById('log-form');
+ const msg = document.getElementById('msg');
+
+ // Default date to today
+ setToday(document.getElementById('date'));
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const data = {
+ date: document.getElementById('date').value,
+ eggs: parseInt(document.getElementById('eggs').value, 10),
+ notes: document.getElementById('notes').value.trim() || null,
+ };
+
+ try {
+ await API.post('/api/eggs', data);
+ showMessage(msg, 'Entry saved!');
+ form.reset();
+ setToday(document.getElementById('date'));
+ loadRecent();
+ } catch (err) {
+ showMessage(msg, `Error: ${err.message}`, 'error');
+ }
+ });
+
+ loadRecent();
+});
diff --git a/nginx/html/js/summary.js b/nginx/html/js/summary.js
new file mode 100644
index 0000000..ca2a538
--- /dev/null
+++ b/nginx/html/js/summary.js
@@ -0,0 +1,366 @@
+let monthlyChart = null;
+
+function buildChart(rows) {
+ const chartWrap = document.getElementById('chart-wrap');
+ const noDataMsg = document.getElementById('chart-no-data');
+
+ if (rows.length === 0) {
+ chartWrap.style.display = 'none';
+ noDataMsg.style.display = 'block';
+ return;
+ }
+
+ chartWrap.style.display = 'block';
+ noDataMsg.style.display = 'none';
+
+ // Show up to last 24 months, oldest → newest for the chart
+ const display = [...rows].reverse().slice(-24);
+ const labels = display.map(r => r.month_label);
+ const data = display.map(r => r.total_eggs);
+
+ const ctx = document.getElementById('monthly-chart').getContext('2d');
+ if (monthlyChart) monthlyChart.destroy();
+
+ monthlyChart = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [{
+ data,
+ backgroundColor: 'rgba(61,107,79,0.75)',
+ borderColor: '#3d6b4f',
+ borderWidth: 1,
+ borderRadius: 4,
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: ctx => ` ${ctx.parsed.y} eggs`,
+ },
+ },
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ suggestedMax: 10,
+ ticks: { stepSize: 1, precision: 0 },
+ },
+ },
+ },
+ });
+}
+
+async function loadSummary() {
+ const msg = document.getElementById('msg');
+ const tbody = document.getElementById('summary-body');
+
+ try {
+ const rows = await API.get('/api/stats/monthly');
+
+ buildChart(rows);
+
+ if (rows.length === 0) {
+ tbody.innerHTML = '| No data yet. |
';
+ return;
+ }
+
+ tbody.innerHTML = rows.map(r => `
+
+ | ${r.month_label} |
+ ${r.total_eggs} |
+ ${r.days_logged} |
+ ${r.avg_eggs_per_day ?? '—'} |
+ ${r.flock_at_month_end ?? '—'} |
+ ${r.avg_eggs_per_hen_per_day ?? '—'} |
+ ${fmtMoney(r.feed_cost)} |
+ ${fmtMoneyFull(r.cost_per_egg)} |
+ ${fmtMoney(r.cost_per_dozen)} |
+
+ `).join('');
+
+ } catch (err) {
+ showMessage(msg, `Failed to load summary: ${err.message}`, 'error');
+ }
+}
+
+document.addEventListener('DOMContentLoaded', loadSummary);
+
+// ── CSV Export ────────────────────────────────────────────────────────────────
+
+function csvEscape(str) {
+ return `"${String(str == null ? '' : str).replace(/"/g, '""')}"`;
+}
+
+async function exportCSV() {
+ const msg = document.getElementById('msg');
+
+ try {
+ const [eggsData, flockAll, feedData] = await Promise.all([
+ API.get('/api/eggs'),
+ API.get('/api/flock'),
+ API.get('/api/feed'),
+ ]);
+
+ if (eggsData.length === 0 && flockAll.length === 0 && feedData.length === 0) {
+ showMessage(msg, 'No data to export.', 'error');
+ return;
+ }
+
+ // Sort flock ascending for effective-count lookup
+ const flockSorted = [...flockAll].sort((a, b) => a.date.localeCompare(b.date));
+
+ function effectiveFlockAt(dateStr) {
+ let result = null;
+ for (const f of flockSorted) {
+ if (f.date <= dateStr) result = f;
+ else break;
+ }
+ return result;
+ }
+
+ // Date-keyed lookups
+ const eggsByDate = Object.fromEntries(eggsData.map(e => [e.date, e]));
+ const flockByDate = Object.fromEntries(flockAll.map(f => [f.date, f]));
+ const feedByDate = {};
+ for (const f of feedData) {
+ if (!feedByDate[f.date]) feedByDate[f.date] = [];
+ feedByDate[f.date].push(f);
+ }
+
+ // Union of all dates
+ const allDates = [...new Set([
+ ...Object.keys(eggsByDate),
+ ...Object.keys(flockByDate),
+ ...Object.keys(feedByDate),
+ ])].sort((a, b) => b.localeCompare(a));
+
+ const csvRows = [[
+ 'Date', 'Eggs Collected', 'Egg Notes',
+ 'Flock Size', 'Flock Notes',
+ 'Feed Bags', 'Feed Price/Bag', 'Feed Total', 'Feed Notes',
+ ]];
+
+ for (const dateStr of allDates) {
+ const egg = eggsByDate[dateStr];
+ const feeds = feedByDate[dateStr] || [];
+ const flock = effectiveFlockAt(dateStr);
+ const flockChg = flockByDate[dateStr];
+
+ const flockCount = flock ? flock.chicken_count : '';
+ const flockNotes = flockChg ? csvEscape(flockChg.notes) : '';
+
+ if (feeds.length === 0) {
+ csvRows.push([
+ dateStr,
+ egg ? egg.eggs : '',
+ egg ? csvEscape(egg.notes) : '',
+ flockCount,
+ flockNotes,
+ '', '', '', '',
+ ]);
+ } else {
+ feeds.forEach((feed, i) => {
+ const total = (parseFloat(feed.bags) * parseFloat(feed.price_per_bag)).toFixed(2);
+ csvRows.push([
+ dateStr,
+ i === 0 && egg ? egg.eggs : '',
+ i === 0 && egg ? csvEscape(egg.notes) : '',
+ i === 0 ? flockCount : '',
+ i === 0 ? flockNotes : '',
+ parseFloat(feed.bags),
+ parseFloat(feed.price_per_bag),
+ total,
+ csvEscape(feed.notes),
+ ]);
+ });
+ }
+ }
+
+ const csv = csvRows.map(r => r.join(',')).join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ const now = new Date();
+ const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
+ a.download = `egg-tracker-${fileDate}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+
+ } catch (err) {
+ showMessage(msg, `Export failed: ${err.message}`, 'error');
+ }
+}
+
+// ── CSV Import ────────────────────────────────────────────────────────────────
+
+function parseCSVText(text) {
+ const rows = [];
+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ const fields = [];
+ let field = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (inQuotes) {
+ if (ch === '"' && line[i + 1] === '"') {
+ field += '"';
+ i++;
+ } else if (ch === '"') {
+ inQuotes = false;
+ } else {
+ field += ch;
+ }
+ } else if (ch === '"') {
+ inQuotes = true;
+ } else if (ch === ',') {
+ fields.push(field);
+ field = '';
+ } else {
+ field += ch;
+ }
+ }
+ fields.push(field);
+ rows.push(fields);
+ }
+ return rows;
+}
+
+async function handleImportFile(input) {
+ const file = input.files[0];
+ if (!file) return;
+ input.value = ''; // allow re-selecting the same file
+
+ const msg = document.getElementById('msg');
+
+ let text;
+ try {
+ text = await file.text();
+ } catch (err) {
+ showMessage(msg, `Could not read file: ${err.message}`, 'error');
+ return;
+ }
+
+ const rows = parseCSVText(text);
+ if (rows.length < 2) {
+ showMessage(msg, 'CSV has no data rows.', 'error');
+ return;
+ }
+
+ const headers = rows[0].map(h => h.trim());
+ const expected = [
+ 'Date', 'Eggs Collected', 'Egg Notes',
+ 'Flock Size', 'Flock Notes',
+ 'Feed Bags', 'Feed Price/Bag', 'Feed Total', 'Feed Notes',
+ ];
+ const missing = expected.filter(h => !headers.includes(h));
+ if (missing.length > 0) {
+ showMessage(msg, `CSV is missing columns: ${missing.join(', ')}`, 'error');
+ return;
+ }
+
+ const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
+
+ // Sort ascending so flock-change detection is chronologically correct
+ const dataRows = rows.slice(1)
+ .filter(r => r.length > 1)
+ .sort((a, b) => (a[idx['Date']] || '').localeCompare(b[idx['Date']] || ''));
+
+ let eggsCreated = 0, eggsSkipped = 0;
+ let flockCreated = 0;
+ let feedCreated = 0;
+ let errors = 0;
+
+ let lastFlockSize = null;
+ let lastFlockDate = null;
+
+ for (const row of dataRows) {
+ const get = col => (row[idx[col]] ?? '').trim();
+
+ const date = get('Date');
+ const eggsStr = get('Eggs Collected');
+ const eggNotes = get('Egg Notes');
+ const flockStr = get('Flock Size');
+ const flockNotes = get('Flock Notes');
+ const bagsStr = get('Feed Bags');
+ const priceStr = get('Feed Price/Bag');
+ const feedNotes = get('Feed Notes');
+
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
+
+ // ── Egg entry ────────────────────────────────────────────────────────
+ if (eggsStr !== '' && !isNaN(parseInt(eggsStr, 10))) {
+ try {
+ await API.post('/api/eggs', {
+ date,
+ eggs: parseInt(eggsStr, 10),
+ notes: eggNotes || null,
+ });
+ eggsCreated++;
+ } catch (err) {
+ if (err.message.toLowerCase().includes('already exists')) {
+ eggsSkipped++;
+ } else {
+ errors++;
+ }
+ }
+ }
+
+ // ── Flock entry ──────────────────────────────────────────────────────
+ if (flockStr !== '' && !isNaN(parseInt(flockStr, 10))) {
+ const flockSize = parseInt(flockStr, 10);
+ const sizeChanged = flockSize !== lastFlockSize;
+ const hasNotes = flockNotes !== '';
+ if (date !== lastFlockDate && (sizeChanged || hasNotes)) {
+ try {
+ await API.post('/api/flock', {
+ date,
+ chicken_count: flockSize,
+ notes: flockNotes || null,
+ });
+ flockCreated++;
+ lastFlockSize = flockSize;
+ lastFlockDate = date;
+ } catch (err) {
+ errors++;
+ }
+ }
+ }
+
+ // ── Feed entry ───────────────────────────────────────────────────────
+ if (bagsStr !== '' && priceStr !== '' &&
+ !isNaN(parseFloat(bagsStr)) && !isNaN(parseFloat(priceStr))) {
+ try {
+ await API.post('/api/feed', {
+ date,
+ bags: parseFloat(bagsStr),
+ price_per_bag: parseFloat(priceStr),
+ notes: feedNotes || null,
+ });
+ feedCreated++;
+ } catch (err) {
+ errors++;
+ }
+ }
+ }
+
+ const parts = [];
+ if (eggsCreated) parts.push(`${eggsCreated} egg ${eggsCreated === 1 ? 'entry' : 'entries'}`);
+ if (eggsSkipped) parts.push(`${eggsSkipped} skipped (duplicate date)`);
+ if (flockCreated) parts.push(`${flockCreated} flock ${flockCreated === 1 ? 'change' : 'changes'}`);
+ if (feedCreated) parts.push(`${feedCreated} feed ${feedCreated === 1 ? 'purchase' : 'purchases'}`);
+
+ const summary = parts.length ? `Imported: ${parts.join(', ')}.` : 'Nothing new to import.';
+ const errNote = errors > 0 ? ` (${errors} row${errors === 1 ? '' : 's'} failed)` : '';
+ showMessage(msg, summary + errNote, errors > 0 ? 'error' : 'success');
+
+ loadSummary();
+}
diff --git a/nginx/html/log.html b/nginx/html/log.html
new file mode 100644
index 0000000..e1df167
--- /dev/null
+++ b/nginx/html/log.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Log Eggs — Eggtracker
+
+
+
+
+
+
+
+ Log Eggs
+
+
+
+
+
+
+
+
+
+ | Date |
+ Eggs |
+ Notes |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
diff --git a/nginx/html/summary.html b/nginx/html/summary.html
new file mode 100644
index 0000000..49ce818
--- /dev/null
+++ b/nginx/html/summary.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Summary — Eggtracker
+
+
+
+
+
+
+
+ Monthly Summary
+
+
+
+
+
+
+
+
Monthly Egg Totals
+
No egg data yet.
+
+
+
+
+
+
+
+
+
+
+ | Month |
+ Total Eggs |
+ Days Logged |
+ Avg / Day |
+ Flock |
+ Avg / Hen / Day |
+ Feed Cost |
+ Cost / Egg |
+ Cost / Dozen |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+
+
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
new file mode 100644
index 0000000..9d6434c
--- /dev/null
+++ b/nginx/nginx.conf
@@ -0,0 +1,58 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+
+ # ── Gzip compression ──────────────────────────────────────────────────────
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json;
+ gzip_min_length 1000;
+
+ server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # ── Static files ──────────────────────────────────────────────────────
+ location / {
+ try_files $uri $uri.html $uri/ =404;
+ }
+
+ # Cache static assets aggressively and suppress access log noise
+ location ~* \.(css|js|svg|ico|png|jpg|webp|woff2?)$ {
+ expires 7d;
+ add_header Cache-Control "public, immutable";
+ access_log off;
+ }
+
+ # ── API reverse proxy ─────────────────────────────────────────────────
+ # All /api/* requests are forwarded to the FastAPI container.
+ # The container is reachable by its service name on the Docker network.
+ location /api/ {
+ proxy_pass http://api:8000;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Don't cache API responses
+ add_header Cache-Control "no-store";
+ }
+
+ # ── Custom error pages ────────────────────────────────────────────────
+ error_page 404 /404.html;
+ error_page 502 503 504 /50x.html;
+
+ location = /404.html { internal; }
+ location = /50x.html { internal; }
+ }
+}