Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Credentials — never commit this
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.log
|
||||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
@@ -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"]
|
||||||
22
backend/database.py
Normal file
22
backend/database.py
Normal file
@@ -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()
|
||||||
24
backend/main.py
Normal file
24
backend/main.py
Normal file
@@ -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"}
|
||||||
35
backend/models.py
Normal file
35
backend/models.py
Normal file
@@ -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())
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -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
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
68
backend/routers/eggs.py
Normal file
68
backend/routers/eggs.py
Normal file
@@ -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()
|
||||||
59
backend/routers/feed.py
Normal file
59
backend/routers/feed.py
Normal file
@@ -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()
|
||||||
70
backend/routers/flock.py
Normal file
70
backend/routers/flock.py
Normal file
@@ -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()
|
||||||
207
backend/routers/stats.py
Normal file
207
backend/routers/stats.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
109
backend/schemas.py
Normal file
109
backend/schemas.py
Normal file
@@ -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]
|
||||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@@ -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
|
||||||
45
mysql/init.sql
Normal file
45
mysql/init.sql
Normal file
@@ -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;
|
||||||
35
nginx/html/404.html
Normal file
35
nginx/html/404.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>404 Not Found — Eggtracker</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<style>
|
||||||
|
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||||
|
.error-center .code { font-size: 5rem; font-weight: 700; color: var(--border); line-height: 1; }
|
||||||
|
.error-center p { color: var(--muted); margin: 1rem 0 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
<div class="error-center">
|
||||||
|
<div class="code">404</div>
|
||||||
|
<h1>Page not found</h1>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<a href="/" class="btn btn-primary">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
nginx/html/50x.html
Normal file
27
nginx/html/50x.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Server Error — Eggtracker</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<style>
|
||||||
|
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||||
|
.error-center .code { font-size: 5rem; font-weight: 700; color: var(--border); line-height: 1; }
|
||||||
|
.error-center p { color: var(--muted); margin: 1rem 0 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
<div class="error-center">
|
||||||
|
<div class="code">5xx</div>
|
||||||
|
<h1>Something went wrong</h1>
|
||||||
|
<p>The server ran into a problem. This usually means the API container is still starting up — wait a moment and try again.</p>
|
||||||
|
<a href="/" class="btn btn-primary">Try Again</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
nginx/html/budget.html
Normal file
103
nginx/html/budget.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Budget — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Budget & Feed Costs</h1>
|
||||||
|
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
|
||||||
|
<!-- Stats: all-time -->
|
||||||
|
<h2>All-Time</h2>
|
||||||
|
<div class="stats-grid" style="margin-bottom: 1rem;">
|
||||||
|
<div class="stat-card"><div class="label">Total Feed Cost</div><div class="value" id="b-cost-total">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Total Eggs</div><div class="value" id="b-eggs-total">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="b-cpe">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="b-cpd">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats: last 30 days -->
|
||||||
|
<h2>Last 30 Days</h2>
|
||||||
|
<div class="stats-grid" style="margin-bottom: 2rem;">
|
||||||
|
<div class="stat-card"><div class="label">Feed Cost (30d)</div><div class="value" id="b-cost-30d">—</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Eggs (30d)</div><div class="value" id="b-eggs-30d">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="b-cpe-30d">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="b-cpd-30d">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log a feed purchase -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Log Feed Purchase</h2>
|
||||||
|
<form id="feed-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input type="date" id="date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bags">Number of Bags</label>
|
||||||
|
<input type="number" id="bags" min="0.01" step="0.01" required placeholder="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="price">Price per Bag ($)</label>
|
||||||
|
<input type="number" id="price" min="0.01" step="0.01" required placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Total</label>
|
||||||
|
<input type="text" id="total-display" readonly placeholder="$0.00" style="background:#f7f4ef; color: var(--green); font-weight:600;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group span-full">
|
||||||
|
<label for="notes">Notes (optional)</label>
|
||||||
|
<textarea id="notes" placeholder="e.g. Brand, store, sale price…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Purchase</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Purchase history -->
|
||||||
|
<h2>Purchase History</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Bags</th>
|
||||||
|
<th>Price / Bag</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="feed-body">
|
||||||
|
<tr class="empty-row"><td colspan="6">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot id="feed-foot"></tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/budget.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
262
nginx/html/css/style.css
Normal file
262
nginx/html/css/style.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
3
nginx/html/favicon.svg
Normal file
3
nginx/html/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y=".9em" font-size="90">🥚</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 114 B |
86
nginx/html/flock.html
Normal file
86
nginx/html/flock.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Flock — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Flock Management</h1>
|
||||||
|
|
||||||
|
<!-- Current flock callout -->
|
||||||
|
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 240px));">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Current Flock Size</div>
|
||||||
|
<div class="value" id="current-count">—</div>
|
||||||
|
<div class="unit">chickens</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">As of</div>
|
||||||
|
<div class="value" style="font-size:1.1rem;" id="current-date">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log a change -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Log a Flock Change</h2>
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
<form id="flock-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date of Change</label>
|
||||||
|
<input type="date" id="date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="count">New Total Count</label>
|
||||||
|
<input type="number" id="count" min="0" required placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group span-full">
|
||||||
|
<label for="notes">Notes (optional)</label>
|
||||||
|
<textarea id="notes" placeholder="e.g. Added 3 new hens, lost 1 to predator…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Change</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flock history table -->
|
||||||
|
<h2>Flock History</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Count</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="flock-body">
|
||||||
|
<tr class="empty-row"><td colspan="4">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/flock.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
nginx/html/history.html
Normal file
69
nginx/html/history.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>History — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Egg Collection History</h1>
|
||||||
|
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
|
||||||
|
<!-- Date filter -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="filter-start">From</label>
|
||||||
|
<input type="date" id="filter-start">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="filter-end">To</label>
|
||||||
|
<input type="date" id="filter-end">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="loadHistory()">Filter</button>
|
||||||
|
<button class="btn btn-ghost" onclick="clearFilter()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="text-muted" id="result-count"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Eggs</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body">
|
||||||
|
<tr class="empty-row"><td colspan="4">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot id="history-foot"></tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/history.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
nginx/html/index.html
Normal file
79
nginx/html/index.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="stats-grid" id="stats-grid">
|
||||||
|
<div class="stat-card"><div class="label">Flock Size</div><div class="value" id="s-flock">—</div><div class="unit">chickens</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Last 7 Days</div><div class="value" id="s-7d">—</div><div class="unit">eggs</div></div>
|
||||||
|
<div class="stat-card"><div class="label">Last 30 Days</div><div class="value" id="s-30d">—</div><div class="unit">eggs</div></div>
|
||||||
|
<div class="stat-card"><div class="label">All-Time Eggs</div><div class="value" id="s-total">—</div><div class="unit">eggs</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Avg / Day (30d)</div><div class="value" id="s-avg-day">—</div><div class="unit">eggs/day</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Avg / Hen / Day (30d)</div><div class="value" id="s-avg-hen">—</div><div class="unit">eggs/hen</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Egg</div><div class="value" id="s-cpe">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Egg (30d)</div><div class="value" id="s-cpe-30d">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Dozen</div><div class="value" id="s-cpd">—</div></div>
|
||||||
|
<div class="stat-card accent"><div class="label">Cost / Dozen (30d)</div><div class="value" id="s-cpd-30d">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trend chart -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Eggs — Last 30 Days</h2>
|
||||||
|
<p id="chart-no-data" class="text-muted" style="display:none; padding: 1.5rem 0; text-align:center;">No egg data yet — log some collections to see the chart.</p>
|
||||||
|
<div id="chart-wrap">
|
||||||
|
<canvas id="eggs-chart" height="90"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent collections -->
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Recent Collections</h2>
|
||||||
|
<a href="/log" class="btn btn-primary btn-sm">+ Log Eggs</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Eggs</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-body">
|
||||||
|
<tr class="empty-row"><td colspan="3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted mt-1"><a href="/history">View full history →</a></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
67
nginx/html/js/api.js
Normal file
67
nginx/html/js/api.js
Normal file
@@ -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);
|
||||||
163
nginx/html/js/budget.js
Normal file
163
nginx/html/js/budget.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="6">No feed purchases logged yet.</td></tr>';
|
||||||
|
tfoot.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = feedData.map(e => {
|
||||||
|
const total = (parseFloat(e.bags) * parseFloat(e.price_per_bag)).toFixed(2);
|
||||||
|
return `
|
||||||
|
<tr data-id="${e.id}">
|
||||||
|
<td>${fmtDate(e.date)}</td>
|
||||||
|
<td>${parseFloat(e.bags)}</td>
|
||||||
|
<td>${fmtMoney(e.price_per_bag)}</td>
|
||||||
|
<td>${fmtMoney(total)}</td>
|
||||||
|
<td class="notes">${e.notes || ''}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
const grandTotal = feedData.reduce((sum, e) => sum + parseFloat(e.bags) * parseFloat(e.price_per_bag), 0);
|
||||||
|
tfoot.innerHTML = `
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3">Total</td>
|
||||||
|
<td>${fmtMoney(grandTotal)}</td>
|
||||||
|
<td colspan="2">${feedData.length} purchases</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(id) {
|
||||||
|
const entry = feedData.find(e => e.id === id);
|
||||||
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="date" value="${entry.date}"></td>
|
||||||
|
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.bags)}" style="width:80px;"></td>
|
||||||
|
<td><input type="number" min="0.01" step="0.01" value="${parseFloat(entry.price_per_bag)}" style="width:90px;"></td>
|
||||||
|
<td>—</td>
|
||||||
|
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
127
nginx/html/js/dashboard.js
Normal file
127
nginx/html/js/dashboard.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="3">No eggs logged yet. <a href="/log">Log your first collection →</a></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = recent.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmtDate(e.date)}</td>
|
||||||
|
<td>${e.eggs}</td>
|
||||||
|
<td class="notes">${e.notes || ''}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(msg, `Failed to load dashboard: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||||
119
nginx/html/js/flock.js
Normal file
119
nginx/html/js/flock.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="4">No flock history yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = flockData.map(e => `
|
||||||
|
<tr data-id="${e.id}">
|
||||||
|
<td>${fmtDate(e.date)}</td>
|
||||||
|
<td>${e.chicken_count}</td>
|
||||||
|
<td class="notes">${e.notes || ''}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(id) {
|
||||||
|
const entry = flockData.find(e => e.id === id);
|
||||||
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="date" value="${entry.date}"></td>
|
||||||
|
<td><input type="number" min="0" value="${entry.chicken_count}" style="width:80px;"></td>
|
||||||
|
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
118
nginx/html/js/history.js
Normal file
118
nginx/html/js/history.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="4">No entries found.</td></tr>';
|
||||||
|
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 => `
|
||||||
|
<tr data-id="${e.id}">
|
||||||
|
<td>${fmtDate(e.date)}</td>
|
||||||
|
<td>${e.eggs}</td>
|
||||||
|
<td class="notes">${e.notes || ''}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="startEdit(${e.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteEntry(${e.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Total row in footer
|
||||||
|
tfoot.innerHTML = `
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="1">Total</td>
|
||||||
|
<td>${total}</td>
|
||||||
|
<td colspan="2">${currentData.length} entries</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(id) {
|
||||||
|
const entry = currentData.find(e => e.id === id);
|
||||||
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="date" value="${entry.date}"></td>
|
||||||
|
<td><input type="number" min="0" value="${entry.eggs}" style="width:80px;"></td>
|
||||||
|
<td><input type="text" value="${entry.notes || ''}" placeholder="Notes"></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="renderTable()">Cancel</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
52
nginx/html/js/log.js
Normal file
52
nginx/html/js/log.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="3">No entries yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = recent.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmtDate(e.date)}</td>
|
||||||
|
<td>${e.eggs}</td>
|
||||||
|
<td class="notes">${e.notes || ''}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="3">Could not load recent entries.</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
366
nginx/html/js/summary.js
Normal file
366
nginx/html/js/summary.js
Normal file
@@ -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 = '<tr class="empty-row"><td colspan="9">No data yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${r.month_label}</strong></td>
|
||||||
|
<td>${r.total_eggs}</td>
|
||||||
|
<td>${r.days_logged}</td>
|
||||||
|
<td>${r.avg_eggs_per_day ?? '—'}</td>
|
||||||
|
<td>${r.flock_at_month_end ?? '—'}</td>
|
||||||
|
<td>${r.avg_eggs_per_hen_per_day ?? '—'}</td>
|
||||||
|
<td>${fmtMoney(r.feed_cost)}</td>
|
||||||
|
<td>${fmtMoneyFull(r.cost_per_egg)}</td>
|
||||||
|
<td>${fmtMoney(r.cost_per_dozen)}</td>
|
||||||
|
</tr>
|
||||||
|
`).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();
|
||||||
|
}
|
||||||
72
nginx/html/log.html
Normal file
72
nginx/html/log.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Log Eggs — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Log Eggs</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
<form id="log-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input type="date" id="date" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eggs">Eggs Collected</label>
|
||||||
|
<input type="number" id="eggs" min="0" required placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="form-group span-full">
|
||||||
|
<label for="notes">Notes (optional)</label>
|
||||||
|
<textarea id="notes" placeholder="Anything worth noting…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Entry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Recent Entries</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Eggs</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-body">
|
||||||
|
<tr class="empty-row"><td colspan="3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/log.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
nginx/html/summary.html
Normal file
74
nginx/html/summary.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Summary — Eggtracker</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nav">
|
||||||
|
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
<li><a href="/history">History</a></li>
|
||||||
|
<li><a href="/flock">Flock</a></li>
|
||||||
|
<li><a href="/budget">Budget</a></li>
|
||||||
|
<li><a href="/summary">Summary</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>Monthly Summary</h1>
|
||||||
|
|
||||||
|
<div id="msg" class="message"></div>
|
||||||
|
|
||||||
|
<div class="section-header" style="margin-bottom:1rem;">
|
||||||
|
<span></span>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="exportCSV()">Download CSV</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('csv-import-input').click()">Import CSV</button>
|
||||||
|
<input type="file" id="csv-import-input" accept=".csv" style="display:none"
|
||||||
|
onchange="handleImportFile(this)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bar chart -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Monthly Egg Totals</h2>
|
||||||
|
<p id="chart-no-data" class="text-muted" style="display:none; padding:1.5rem 0; text-align:center;">No egg data yet.</p>
|
||||||
|
<div id="chart-wrap">
|
||||||
|
<canvas id="monthly-chart" height="90"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly breakdown table -->
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th>Total Eggs</th>
|
||||||
|
<th>Days Logged</th>
|
||||||
|
<th>Avg / Day</th>
|
||||||
|
<th>Flock</th>
|
||||||
|
<th>Avg / Hen / Day</th>
|
||||||
|
<th>Feed Cost</th>
|
||||||
|
<th>Cost / Egg</th>
|
||||||
|
<th>Cost / Dozen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="summary-body">
|
||||||
|
<tr class="empty-row"><td colspan="9">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/summary.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
nginx/nginx.conf
Normal file
58
nginx/nginx.conf
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user