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

+
+
Total Feed Cost
+
Total Eggs
+
Cost / Egg
+
Cost / Dozen
+
+ + +

Last 30 Days

+
+
Feed Cost (30d)
+
Eggs (30d)
+
Cost / Egg (30d)
+
Cost / Dozen (30d)
+
+ + +
+

Log Feed Purchase

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +

Purchase History

+
+ + + + + + + + + + + + + + + +
DateBagsPrice / BagTotalNotes
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
+
+
+
As of
+
+
+
+ + +
+

Log a Flock Change

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +

Flock History

+
+ + + + + + + + + + + + +
DateCountNotes
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

+ +
+ + +
+
+
+ + +
+
+ + +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + +
DateEggsNotes
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

+ +
+ + +
+
Flock Size
chickens
+
Last 7 Days
eggs
+
Last 30 Days
eggs
+
All-Time Eggs
eggs
+
Avg / Day (30d)
eggs/day
+
Avg / Hen / Day (30d)
eggs/hen
+
Cost / Egg
+
Cost / Egg (30d)
+
Cost / Dozen
+
Cost / Dozen (30d)
+
+ + +
+

Eggs — Last 30 Days

+ +
+ +
+
+ + +
+

Recent Collections

+ + Log Eggs +
+ +
+ + + + + + + + + + + +
DateEggsNotes
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

+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+

Recent Entries

+
+ +
+ + + + + + + + + + + +
DateEggsNotes
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

+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + +
MonthTotal EggsDays LoggedAvg / DayFlockAvg / Hen / DayFeed CostCost / EggCost / 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; } + } +}