Initial commit: Sproutly plant tracking app
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
mysql_data/
|
||||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
23
backend/database.py
Normal file
23
backend/database.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "sproutly")
|
||||||
|
DB_USER = os.getenv("DB_USER", "sproutly")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "sproutly_secret")
|
||||||
|
|
||||||
|
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
86
backend/main.py
Normal file
86
backend/main.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Settings, NotificationLog
|
||||||
|
from routers import varieties, batches, dashboard, settings, notifications
|
||||||
|
from routers.notifications import build_daily_summary, send_ntfy
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("sproutly")
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
|
async def scheduled_daily_notification():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
if not s or not s.ntfy_topic:
|
||||||
|
logger.info("Daily notification skipped: ntfy not configured")
|
||||||
|
return
|
||||||
|
summary = build_daily_summary(db)
|
||||||
|
ok, detail = await send_ntfy(s, "Sproutly Daily Summary", summary, db)
|
||||||
|
logger.info(f"Daily notification: {detail}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daily notification error: {e}")
|
||||||
|
log = NotificationLog(message="scheduler error", status="failed", error=str(e))
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_schedule(db) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
if s and s.notification_time:
|
||||||
|
h, m = s.notification_time.split(":")
|
||||||
|
return int(h), int(m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 7, 0
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
db = SessionLocal()
|
||||||
|
hour, minute = get_notification_schedule(db)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
scheduled_daily_notification,
|
||||||
|
CronTrigger(hour=hour, minute=minute),
|
||||||
|
id="daily_summary",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info(f"Scheduler started — daily notification at {hour:02d}:{minute:02d}")
|
||||||
|
yield
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Sproutly API", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(varieties.router)
|
||||||
|
app.include_router(batches.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(settings.router)
|
||||||
|
app.include_router(notifications.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "sproutly"}
|
||||||
98
backend/models.py
Normal file
98
backend/models.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import enum
|
||||||
|
from sqlalchemy import Column, Integer, String, Date, Boolean, Text, DateTime, Enum, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Category(str, enum.Enum):
|
||||||
|
vegetable = "vegetable"
|
||||||
|
herb = "herb"
|
||||||
|
flower = "flower"
|
||||||
|
fruit = "fruit"
|
||||||
|
|
||||||
|
|
||||||
|
class SunRequirement(str, enum.Enum):
|
||||||
|
full_sun = "full_sun"
|
||||||
|
part_shade = "part_shade"
|
||||||
|
full_shade = "full_shade"
|
||||||
|
|
||||||
|
|
||||||
|
class WaterNeeds(str, enum.Enum):
|
||||||
|
low = "low"
|
||||||
|
medium = "medium"
|
||||||
|
high = "high"
|
||||||
|
|
||||||
|
|
||||||
|
class BatchStatus(str, enum.Enum):
|
||||||
|
planned = "planned"
|
||||||
|
germinating = "germinating"
|
||||||
|
seedling = "seedling"
|
||||||
|
potted_up = "potted_up"
|
||||||
|
hardening = "hardening"
|
||||||
|
garden = "garden"
|
||||||
|
harvested = "harvested"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class Variety(Base):
|
||||||
|
__tablename__ = "varieties"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
variety_name = Column(String(100))
|
||||||
|
category = Column(Enum(Category), default=Category.vegetable)
|
||||||
|
weeks_to_start = Column(Integer)
|
||||||
|
weeks_to_greenhouse = Column(Integer)
|
||||||
|
weeks_to_garden = Column(Integer)
|
||||||
|
days_to_germinate = Column(Integer, default=7)
|
||||||
|
direct_sow_ok = Column(Boolean, default=False)
|
||||||
|
frost_tolerant = Column(Boolean, default=False)
|
||||||
|
sun_requirement = Column(Enum(SunRequirement), default=SunRequirement.full_sun)
|
||||||
|
water_needs = Column(Enum(WaterNeeds), default=WaterNeeds.medium)
|
||||||
|
color = Column(String(7), default="#52b788")
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
batches = relationship("Batch", back_populates="variety", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class Batch(Base):
|
||||||
|
__tablename__ = "batches"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
variety_id = Column(Integer, ForeignKey("varieties.id"), nullable=False)
|
||||||
|
label = Column(String(100))
|
||||||
|
quantity = Column(Integer, default=1)
|
||||||
|
sow_date = Column(Date)
|
||||||
|
germination_date = Column(Date)
|
||||||
|
greenhouse_date = Column(Date)
|
||||||
|
garden_date = Column(Date)
|
||||||
|
status = Column(Enum(BatchStatus), default=BatchStatus.planned)
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
variety = relationship("Variety", back_populates="batches")
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(Base):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
last_frost_date = Column(Date)
|
||||||
|
first_frost_fall_date = Column(Date)
|
||||||
|
ntfy_topic = Column(String(200))
|
||||||
|
ntfy_server = Column(String(200), default="https://ntfy.sh")
|
||||||
|
notification_time = Column(String(5), default="07:00")
|
||||||
|
timezone = Column(String(50), default="UTC")
|
||||||
|
location_name = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationLog(Base):
|
||||||
|
__tablename__ = "notification_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sent_at = Column(DateTime, server_default=func.now())
|
||||||
|
message = Column(Text)
|
||||||
|
status = Column(String(20))
|
||||||
|
error = Column(Text)
|
||||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
sqlalchemy==2.0.30
|
||||||
|
pymysql==1.1.1
|
||||||
|
cryptography==42.0.7
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
httpx==0.27.0
|
||||||
|
apscheduler==3.10.4
|
||||||
|
pydantic==2.7.1
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
58
backend/routers/batches.py
Normal file
58
backend/routers/batches.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from typing import List
|
||||||
|
from database import get_db
|
||||||
|
from models import Batch, Variety
|
||||||
|
from schemas import BatchCreate, BatchUpdate, BatchOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/batches", tags=["batches"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[BatchOut])
|
||||||
|
def list_batches(db: Session = Depends(get_db)):
|
||||||
|
return (
|
||||||
|
db.query(Batch)
|
||||||
|
.options(joinedload(Batch.variety))
|
||||||
|
.order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{batch_id}", response_model=BatchOut)
|
||||||
|
def get_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||||
|
b = db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == batch_id).first()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=BatchOut, status_code=201)
|
||||||
|
def create_batch(data: BatchCreate, db: Session = Depends(get_db)):
|
||||||
|
if not db.query(Variety).filter(Variety.id == data.variety_id).first():
|
||||||
|
raise HTTPException(status_code=404, detail="Variety not found")
|
||||||
|
b = Batch(**data.model_dump())
|
||||||
|
db.add(b)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(b)
|
||||||
|
return db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == b.id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{batch_id}", response_model=BatchOut)
|
||||||
|
def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db)):
|
||||||
|
b = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
|
for field, value in data.model_dump().items():
|
||||||
|
setattr(b, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(b)
|
||||||
|
return db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == b.id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{batch_id}", status_code=204)
|
||||||
|
def delete_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||||
|
b = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
|
db.delete(b)
|
||||||
|
db.commit()
|
||||||
210
backend/routers/dashboard.py
Normal file
210
backend/routers/dashboard.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from database import get_db
|
||||||
|
from models import Variety, Batch, Settings, BatchStatus
|
||||||
|
from schemas import DashboardOut, Task, TimelineEntry, BatchOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
ACTIVE_STATUSES = [
|
||||||
|
BatchStatus.planned, BatchStatus.germinating, BatchStatus.seedling,
|
||||||
|
BatchStatus.potted_up, BatchStatus.hardening, BatchStatus.garden,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def urgency(days_away: int) -> str:
|
||||||
|
if days_away < 0:
|
||||||
|
return "overdue"
|
||||||
|
if days_away == 0:
|
||||||
|
return "today"
|
||||||
|
if days_away <= 7:
|
||||||
|
return "week"
|
||||||
|
return "month"
|
||||||
|
|
||||||
|
|
||||||
|
def make_task(task_type: str, title: str, detail: str, due: date, color: str,
|
||||||
|
batch_id=None, variety_id=None) -> Task:
|
||||||
|
days_away = (due - date.today()).days
|
||||||
|
return Task(
|
||||||
|
type=task_type,
|
||||||
|
title=title,
|
||||||
|
detail=detail,
|
||||||
|
due_date=due,
|
||||||
|
days_away=days_away,
|
||||||
|
urgency=urgency(days_away),
|
||||||
|
variety_color=color,
|
||||||
|
batch_id=batch_id,
|
||||||
|
variety_id=variety_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def day_of_year(d: date) -> int:
|
||||||
|
return d.timetuple().tm_yday
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=DashboardOut)
|
||||||
|
def get_dashboard(db: Session = Depends(get_db)):
|
||||||
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
today = date.today()
|
||||||
|
last_frost = None
|
||||||
|
|
||||||
|
if settings and settings.last_frost_date:
|
||||||
|
last_frost = settings.last_frost_date.replace(year=today.year)
|
||||||
|
# If last frost has passed this year, use next year's date for planning
|
||||||
|
# but keep this year for historical display
|
||||||
|
planning_frost = last_frost
|
||||||
|
if last_frost < today - timedelta(days=60):
|
||||||
|
planning_frost = last_frost.replace(year=today.year + 1)
|
||||||
|
else:
|
||||||
|
planning_frost = None
|
||||||
|
|
||||||
|
all_tasks: List[Task] = []
|
||||||
|
timeline: List[TimelineEntry] = []
|
||||||
|
|
||||||
|
varieties = db.query(Variety).all()
|
||||||
|
|
||||||
|
for v in varieties:
|
||||||
|
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
|
||||||
|
|
||||||
|
# --- Timeline entry ---
|
||||||
|
entry = TimelineEntry(
|
||||||
|
variety_id=v.id,
|
||||||
|
name=v.name,
|
||||||
|
full_name=full_name,
|
||||||
|
color=v.color or "#52b788",
|
||||||
|
)
|
||||||
|
|
||||||
|
if planning_frost:
|
||||||
|
if v.weeks_to_start:
|
||||||
|
sd = planning_frost - timedelta(weeks=v.weeks_to_start)
|
||||||
|
entry.start_day = day_of_year(sd)
|
||||||
|
if v.weeks_to_greenhouse:
|
||||||
|
gd = planning_frost - timedelta(weeks=v.weeks_to_greenhouse)
|
||||||
|
entry.greenhouse_day = day_of_year(gd)
|
||||||
|
if v.weeks_to_garden is not None:
|
||||||
|
if v.weeks_to_garden >= 0:
|
||||||
|
td = planning_frost + timedelta(weeks=v.weeks_to_garden)
|
||||||
|
else:
|
||||||
|
td = planning_frost - timedelta(weeks=abs(v.weeks_to_garden))
|
||||||
|
entry.garden_day = day_of_year(td)
|
||||||
|
entry.end_day = day_of_year(td) + 80 # rough season length
|
||||||
|
|
||||||
|
# --- Tasks from variety schedule (only within 30 days) ---
|
||||||
|
if v.weeks_to_start:
|
||||||
|
start_date = planning_frost - timedelta(weeks=v.weeks_to_start)
|
||||||
|
days = (start_date - today).days
|
||||||
|
if -7 <= days <= 30:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"start_seeds",
|
||||||
|
f"Start {full_name} seeds",
|
||||||
|
f"Sow indoors — {v.days_to_germinate} days to germinate",
|
||||||
|
start_date, v.color or "#52b788",
|
||||||
|
variety_id=v.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
if v.weeks_to_greenhouse:
|
||||||
|
gh_date = planning_frost - timedelta(weeks=v.weeks_to_greenhouse)
|
||||||
|
days = (gh_date - today).days
|
||||||
|
if -7 <= days <= 30:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"pot_up",
|
||||||
|
f"Pot up {full_name}",
|
||||||
|
"Move seedlings to larger containers / greenhouse",
|
||||||
|
gh_date, v.color or "#52b788",
|
||||||
|
variety_id=v.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
if v.weeks_to_garden is not None:
|
||||||
|
if v.weeks_to_garden >= 0:
|
||||||
|
garden_date = planning_frost + timedelta(weeks=v.weeks_to_garden)
|
||||||
|
else:
|
||||||
|
garden_date = planning_frost - timedelta(weeks=abs(v.weeks_to_garden))
|
||||||
|
days = (garden_date - today).days
|
||||||
|
if -7 <= days <= 30:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"transplant",
|
||||||
|
f"Transplant {full_name} to garden",
|
||||||
|
"Harden off first if starting from indoors",
|
||||||
|
garden_date, v.color or "#52b788",
|
||||||
|
variety_id=v.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
timeline.append(entry)
|
||||||
|
|
||||||
|
# --- Tasks from active batches ---
|
||||||
|
batches = (
|
||||||
|
db.query(Batch)
|
||||||
|
.options(joinedload(Batch.variety))
|
||||||
|
.filter(Batch.status.in_(ACTIVE_STATUSES))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for b in batches:
|
||||||
|
v = b.variety
|
||||||
|
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
|
||||||
|
color = v.color or "#52b788"
|
||||||
|
label = b.label or full_name
|
||||||
|
|
||||||
|
if b.status == BatchStatus.planned and b.sow_date:
|
||||||
|
days = (b.sow_date - today).days
|
||||||
|
if days <= 0:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"check_batch", f"Start batch: {label}",
|
||||||
|
"Sow date has arrived — time to plant seeds!",
|
||||||
|
b.sow_date, color, batch_id=b.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
if b.status == BatchStatus.germinating and b.sow_date:
|
||||||
|
expected = b.sow_date + timedelta(days=v.days_to_germinate or 7)
|
||||||
|
days = (expected - today).days
|
||||||
|
if -14 <= days <= 7:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"check_batch", f"Check germination: {label}",
|
||||||
|
"Germination window reached — check for sprouts",
|
||||||
|
expected, color, batch_id=b.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
if b.status == BatchStatus.hardening:
|
||||||
|
all_tasks.append(make_task(
|
||||||
|
"check_batch", f"Continue hardening off: {label}",
|
||||||
|
"Gradually increase outdoor exposure each day",
|
||||||
|
today, color, batch_id=b.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Deduplicate and filter to -7 to +30 day window
|
||||||
|
seen = set()
|
||||||
|
filtered = []
|
||||||
|
for t in all_tasks:
|
||||||
|
key = (t.type, t.title, str(t.due_date))
|
||||||
|
if key not in seen and -7 <= t.days_away <= 30:
|
||||||
|
seen.add(key)
|
||||||
|
filtered.append(t)
|
||||||
|
|
||||||
|
filtered.sort(key=lambda t: t.days_away)
|
||||||
|
|
||||||
|
# Active batches for display
|
||||||
|
active_batch_objs = [b for b in batches if b.status != BatchStatus.garden]
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
all_batches = db.query(Batch).all()
|
||||||
|
stats = {
|
||||||
|
"total_varieties": len(varieties),
|
||||||
|
"total_batches": len(all_batches),
|
||||||
|
"active_batches": sum(1 for b in all_batches if b.status in ACTIVE_STATUSES),
|
||||||
|
"in_garden": sum(1 for b in all_batches if b.status == BatchStatus.garden),
|
||||||
|
"tasks_count": len(filtered),
|
||||||
|
}
|
||||||
|
|
||||||
|
return DashboardOut(
|
||||||
|
tasks_overdue=[t for t in filtered if t.urgency == "overdue"],
|
||||||
|
tasks_today=[t for t in filtered if t.urgency == "today"],
|
||||||
|
tasks_week=[t for t in filtered if t.urgency == "week"],
|
||||||
|
tasks_month=[t for t in filtered if t.urgency == "month"],
|
||||||
|
active_batches=active_batch_objs,
|
||||||
|
timeline=timeline,
|
||||||
|
stats=stats,
|
||||||
|
last_frost_date=last_frost,
|
||||||
|
location_name=settings.location_name if settings else None,
|
||||||
|
)
|
||||||
152
backend/routers/notifications.py
Normal file
152
backend/routers/notifications.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import httpx
|
||||||
|
from database import get_db
|
||||||
|
from models import Settings, Variety, Batch, NotificationLog, BatchStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
ACTIVE_STATUSES = [
|
||||||
|
BatchStatus.planned, BatchStatus.germinating, BatchStatus.seedling,
|
||||||
|
BatchStatus.potted_up, BatchStatus.hardening, BatchStatus.garden,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_summary(db: Session) -> str:
|
||||||
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
today = date.today()
|
||||||
|
lines = [f"Sproutly Daily Summary — {today.strftime('%A, %B %d')}"]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
if settings and settings.last_frost_date:
|
||||||
|
last_frost = settings.last_frost_date.replace(year=today.year)
|
||||||
|
if last_frost < today - timedelta(days=60):
|
||||||
|
last_frost = last_frost.replace(year=today.year + 1)
|
||||||
|
|
||||||
|
days_to_frost = (last_frost - today).days
|
||||||
|
if 0 <= days_to_frost <= 14:
|
||||||
|
lines.append(f"Last frost in {days_to_frost} days ({last_frost.strftime('%B %d')})!")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
varieties = db.query(Variety).all()
|
||||||
|
for v in varieties:
|
||||||
|
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
|
||||||
|
|
||||||
|
if v.weeks_to_start:
|
||||||
|
sd = last_frost - timedelta(weeks=v.weeks_to_start)
|
||||||
|
d = (sd - today).days
|
||||||
|
if -1 <= d <= 3:
|
||||||
|
tasks.append(f"Start seeds: {full_name} (due {sd.strftime('%b %d')})")
|
||||||
|
|
||||||
|
if v.weeks_to_greenhouse:
|
||||||
|
gd = last_frost - timedelta(weeks=v.weeks_to_greenhouse)
|
||||||
|
d = (gd - today).days
|
||||||
|
if -1 <= d <= 3:
|
||||||
|
tasks.append(f"Pot up: {full_name} (due {gd.strftime('%b %d')})")
|
||||||
|
|
||||||
|
if v.weeks_to_garden is not None:
|
||||||
|
if v.weeks_to_garden >= 0:
|
||||||
|
td = last_frost + timedelta(weeks=v.weeks_to_garden)
|
||||||
|
else:
|
||||||
|
td = last_frost - timedelta(weeks=abs(v.weeks_to_garden))
|
||||||
|
d = (td - today).days
|
||||||
|
if -1 <= d <= 3:
|
||||||
|
tasks.append(f"Transplant to garden: {full_name} (due {td.strftime('%b %d')})")
|
||||||
|
|
||||||
|
batches = (
|
||||||
|
db.query(Batch)
|
||||||
|
.filter(Batch.status.in_(ACTIVE_STATUSES))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for b in batches:
|
||||||
|
v = b.variety
|
||||||
|
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
|
||||||
|
label = b.label or full_name
|
||||||
|
|
||||||
|
if b.status == BatchStatus.hardening:
|
||||||
|
tasks.append(f"Harden off: {label}")
|
||||||
|
|
||||||
|
if b.status == BatchStatus.germinating and b.sow_date:
|
||||||
|
expected = b.sow_date + timedelta(days=v.days_to_germinate or 7)
|
||||||
|
d = (expected - today).days
|
||||||
|
if -2 <= d <= 1:
|
||||||
|
tasks.append(f"Check germination: {label}")
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
lines.append("Today's tasks:")
|
||||||
|
for t in tasks:
|
||||||
|
lines.append(f" - {t}")
|
||||||
|
else:
|
||||||
|
lines.append("No urgent tasks today. Keep it up!")
|
||||||
|
|
||||||
|
active_count = sum(1 for b in batches if b.status not in [BatchStatus.harvested, BatchStatus.failed])
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Active batches: {active_count}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_ntfy(settings: Settings, title: str, message: str, db: Session, priority: str = "default"):
|
||||||
|
if not settings or not settings.ntfy_topic:
|
||||||
|
return False, "ntfy topic not configured"
|
||||||
|
|
||||||
|
server = (settings.ntfy_server or "https://ntfy.sh").rstrip("/")
|
||||||
|
url = f"{server}/{settings.ntfy_topic}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
content=message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Title": title,
|
||||||
|
"Priority": priority,
|
||||||
|
"Tags": "seedling",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
log = NotificationLog(message=message, status="sent")
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
return True, "sent"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log = NotificationLog(message=message, status="failed", error=str(e))
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def send_test_notification(db: Session = Depends(get_db)):
|
||||||
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
ok, detail = await send_ntfy(
|
||||||
|
settings,
|
||||||
|
"Sproutly Test",
|
||||||
|
"Your Sproutly notifications are working!",
|
||||||
|
db,
|
||||||
|
priority="default",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=detail)
|
||||||
|
return {"status": "sent"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/daily")
|
||||||
|
async def send_daily_summary(db: Session = Depends(get_db)):
|
||||||
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
summary = build_daily_summary(db)
|
||||||
|
ok, detail = await send_ntfy(settings, "Sproutly Daily Summary", summary, db)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=detail)
|
||||||
|
return {"status": "sent", "message": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log")
|
||||||
|
def get_notification_log(db: Session = Depends(get_db)):
|
||||||
|
logs = db.query(NotificationLog).order_by(NotificationLog.sent_at.desc()).limit(50).all()
|
||||||
|
return [{"id": l.id, "sent_at": l.sent_at, "status": l.status, "message": l.message, "error": l.error} for l in logs]
|
||||||
31
backend/routers/settings.py
Normal file
31
backend/routers/settings.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from database import get_db
|
||||||
|
from models import Settings
|
||||||
|
from schemas import SettingsUpdate, SettingsOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=SettingsOut)
|
||||||
|
def get_settings(db: Session = Depends(get_db)):
|
||||||
|
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
if not s:
|
||||||
|
s = Settings(id=1)
|
||||||
|
db.add(s)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=SettingsOut)
|
||||||
|
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db)):
|
||||||
|
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||||
|
if not s:
|
||||||
|
s = Settings(id=1)
|
||||||
|
db.add(s)
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(s, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s)
|
||||||
|
return s
|
||||||
51
backend/routers/varieties.py
Normal file
51
backend/routers/varieties.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from database import get_db
|
||||||
|
from models import Variety
|
||||||
|
from schemas import VarietyCreate, VarietyUpdate, VarietyOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/varieties", tags=["varieties"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[VarietyOut])
|
||||||
|
def list_varieties(db: Session = Depends(get_db)):
|
||||||
|
return db.query(Variety).order_by(Variety.category, Variety.name).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{variety_id}", response_model=VarietyOut)
|
||||||
|
def get_variety(variety_id: int, db: Session = Depends(get_db)):
|
||||||
|
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||||
|
if not v:
|
||||||
|
raise HTTPException(status_code=404, detail="Variety not found")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VarietyOut, status_code=201)
|
||||||
|
def create_variety(data: VarietyCreate, db: Session = Depends(get_db)):
|
||||||
|
v = Variety(**data.model_dump())
|
||||||
|
db.add(v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{variety_id}", response_model=VarietyOut)
|
||||||
|
def update_variety(variety_id: int, data: VarietyUpdate, db: Session = Depends(get_db)):
|
||||||
|
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||||
|
if not v:
|
||||||
|
raise HTTPException(status_code=404, detail="Variety not found")
|
||||||
|
for field, value in data.model_dump().items():
|
||||||
|
setattr(v, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{variety_id}", status_code=204)
|
||||||
|
def delete_variety(variety_id: int, db: Session = Depends(get_db)):
|
||||||
|
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||||
|
if not v:
|
||||||
|
raise HTTPException(status_code=404, detail="Variety not found")
|
||||||
|
db.delete(v)
|
||||||
|
db.commit()
|
||||||
121
backend/schemas.py
Normal file
121
backend/schemas.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from models import Category, SunRequirement, WaterNeeds, BatchStatus
|
||||||
|
|
||||||
|
|
||||||
|
# --- Variety ---
|
||||||
|
|
||||||
|
class VarietyBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
variety_name: Optional[str] = None
|
||||||
|
category: Category = Category.vegetable
|
||||||
|
weeks_to_start: Optional[int] = None
|
||||||
|
weeks_to_greenhouse: Optional[int] = None
|
||||||
|
weeks_to_garden: Optional[int] = None
|
||||||
|
days_to_germinate: int = 7
|
||||||
|
direct_sow_ok: bool = False
|
||||||
|
frost_tolerant: bool = False
|
||||||
|
sun_requirement: SunRequirement = SunRequirement.full_sun
|
||||||
|
water_needs: WaterNeeds = WaterNeeds.medium
|
||||||
|
color: str = "#52b788"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VarietyCreate(VarietyBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VarietyUpdate(VarietyBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VarietyOut(VarietyBase):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Batch ---
|
||||||
|
|
||||||
|
class BatchBase(BaseModel):
|
||||||
|
variety_id: int
|
||||||
|
label: Optional[str] = None
|
||||||
|
quantity: int = 1
|
||||||
|
sow_date: Optional[date] = None
|
||||||
|
germination_date: Optional[date] = None
|
||||||
|
greenhouse_date: Optional[date] = None
|
||||||
|
garden_date: Optional[date] = None
|
||||||
|
status: BatchStatus = BatchStatus.planned
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCreate(BatchBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BatchUpdate(BatchBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BatchOut(BatchBase):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
variety: Optional[VarietyOut] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Settings ---
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
last_frost_date: Optional[date] = None
|
||||||
|
first_frost_fall_date: Optional[date] = None
|
||||||
|
ntfy_topic: Optional[str] = None
|
||||||
|
ntfy_server: Optional[str] = "https://ntfy.sh"
|
||||||
|
notification_time: Optional[str] = "07:00"
|
||||||
|
timezone: Optional[str] = "UTC"
|
||||||
|
location_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsOut(SettingsUpdate):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dashboard ---
|
||||||
|
|
||||||
|
class Task(BaseModel):
|
||||||
|
type: str # "start_seeds", "pot_up", "transplant", "check_batch"
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
due_date: date
|
||||||
|
days_away: int # negative = overdue
|
||||||
|
urgency: str # "overdue", "today", "week", "month"
|
||||||
|
variety_color: str
|
||||||
|
batch_id: Optional[int] = None
|
||||||
|
variety_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineEntry(BaseModel):
|
||||||
|
variety_id: int
|
||||||
|
name: str
|
||||||
|
full_name: str
|
||||||
|
color: str
|
||||||
|
start_day: Optional[int] = None # day of year
|
||||||
|
greenhouse_day: Optional[int] = None
|
||||||
|
garden_day: Optional[int] = None
|
||||||
|
end_day: Optional[int] = None # approx harvest / end of season
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOut(BaseModel):
|
||||||
|
tasks_overdue: List[Task]
|
||||||
|
tasks_today: List[Task]
|
||||||
|
tasks_week: List[Task]
|
||||||
|
tasks_month: List[Task]
|
||||||
|
active_batches: List[BatchOut]
|
||||||
|
timeline: List[TimelineEntry]
|
||||||
|
stats: dict
|
||||||
|
last_frost_date: Optional[date]
|
||||||
|
location_name: Optional[str]
|
||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: sproutly_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: sproutly
|
||||||
|
MYSQL_USER: ${MYSQL_USER}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
networks:
|
||||||
|
- sproutly_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: sproutly_api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: sproutly
|
||||||
|
DB_USER: ${MYSQL_USER}
|
||||||
|
DB_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- sproutly_net
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build: ./nginx
|
||||||
|
container_name: sproutly_web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8053:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- sproutly_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sproutly_net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
71
mysql/init.sql
Normal file
71
mysql/init.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- Sproutly Database Schema
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS varieties (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
variety_name VARCHAR(100),
|
||||||
|
category ENUM('vegetable', 'herb', 'flower', 'fruit') DEFAULT 'vegetable',
|
||||||
|
weeks_to_start INT COMMENT 'Weeks before last frost to start seeds indoors',
|
||||||
|
weeks_to_greenhouse INT COMMENT 'Weeks before last frost to pot up or move to greenhouse',
|
||||||
|
weeks_to_garden INT COMMENT 'Weeks after last frost to transplant outdoors (negative = before frost)',
|
||||||
|
days_to_germinate INT DEFAULT 7,
|
||||||
|
direct_sow_ok BOOLEAN DEFAULT FALSE,
|
||||||
|
frost_tolerant BOOLEAN DEFAULT FALSE,
|
||||||
|
sun_requirement ENUM('full_sun', 'part_shade', 'full_shade') DEFAULT 'full_sun',
|
||||||
|
water_needs ENUM('low', 'medium', 'high') DEFAULT 'medium',
|
||||||
|
color VARCHAR(7) DEFAULT '#52b788',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
variety_id INT NOT NULL,
|
||||||
|
label VARCHAR(100),
|
||||||
|
quantity INT DEFAULT 1,
|
||||||
|
sow_date DATE,
|
||||||
|
germination_date DATE,
|
||||||
|
greenhouse_date DATE,
|
||||||
|
garden_date DATE,
|
||||||
|
status ENUM('planned','germinating','seedling','potted_up','hardening','garden','harvested','failed') DEFAULT 'planned',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (variety_id) REFERENCES varieties(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
last_frost_date DATE,
|
||||||
|
first_frost_fall_date DATE,
|
||||||
|
ntfy_topic VARCHAR(200),
|
||||||
|
ntfy_server VARCHAR(200) DEFAULT 'https://ntfy.sh',
|
||||||
|
notification_time VARCHAR(5) DEFAULT '07:00',
|
||||||
|
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||||
|
location_name VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
message TEXT,
|
||||||
|
status VARCHAR(20),
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default settings row
|
||||||
|
INSERT INTO settings (id) VALUES (1);
|
||||||
|
|
||||||
|
-- Sample plant varieties
|
||||||
|
INSERT INTO varieties (name, variety_name, category, weeks_to_start, weeks_to_greenhouse, weeks_to_garden, days_to_germinate, frost_tolerant, sun_requirement, water_needs, color, notes) VALUES
|
||||||
|
('Tomato', 'Roma', 'vegetable', 8, 2, 2, 7, FALSE, 'full_sun', 'medium', '#e76f51', 'Start indoors 6-8 weeks before last frost. Needs warm soil to transplant.'),
|
||||||
|
('Tomato', 'Cherry', 'vegetable', 8, 2, 2, 7, FALSE, 'full_sun', 'medium', '#f4a261', 'Great in containers. Very prolific producer.'),
|
||||||
|
('Pepper', 'Bell', 'vegetable', 10, 2, 2, 10, FALSE, 'full_sun', 'medium', '#e9c46a', 'Slow to germinate, start early. Needs heat.'),
|
||||||
|
('Pepper', 'Hot Banana', 'vegetable', 10, 2, 2, 12, FALSE, 'full_sun', 'low', '#f4a261', 'Very slow to germinate. Keep soil warm (80F+).'),
|
||||||
|
('Broccoli', 'Calabrese', 'vegetable', 6, 2, -2, 5, TRUE, 'full_sun', 'medium', '#2d6a4f', 'Can tolerate light frost. Start indoors for spring or direct sow in summer for fall crop.'),
|
||||||
|
('Lettuce', 'Butterhead', 'vegetable', 4, 1, -4, 3, TRUE, 'part_shade', 'medium', '#74c69d', 'Cold tolerant. Can direct sow early in spring. Bolts in heat.'),
|
||||||
|
('Cucumber', 'Straight Eight', 'vegetable', 3, 0, 2, 5, FALSE, 'full_sun', 'high', '#52b788', 'Direct sow after last frost or start indoors 2-3 weeks before. Hates root disturbance.'),
|
||||||
|
('Basil', 'Sweet', 'herb', 6, 1, 2, 7, FALSE, 'full_sun', 'medium', '#40916c', 'Very frost sensitive. Start indoors, transplant after all danger of frost.'),
|
||||||
|
('Marigold', 'French', 'flower', 6, 1, 0, 5, FALSE, 'full_sun', 'low', '#f4a261', 'Great companion plant for tomatoes. Deters pests.'),
|
||||||
|
('Zinnia', 'Cut & Come Again', 'flower', 4, 0, 1, 5, FALSE, 'full_sun', 'low', '#e76f51', 'Can direct sow after last frost. Easy and prolific.'),
|
||||||
|
('Kale', 'Lacinato', 'vegetable', 6, 2, -4, 5, TRUE, 'full_sun', 'medium', '#1b4332', 'Very cold hardy. Start early for spring or late summer for fall/winter harvest.'),
|
||||||
|
('Squash', 'Zucchini', 'vegetable', 3, 0, 2, 5, FALSE, 'full_sun', 'high', '#95d5b2', 'Direct sow or start indoors 2-3 weeks before last frost. Fast growing.');
|
||||||
3
nginx/Dockerfile
Normal file
3
nginx/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY html /usr/share/nginx/html
|
||||||
630
nginx/html/css/style.css
Normal file
630
nginx/html/css/style.css
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
/* ===== CSS Custom Properties ===== */
|
||||||
|
:root {
|
||||||
|
--green-darkest: #1b4332;
|
||||||
|
--green-dark: #2d6a4f;
|
||||||
|
--green-mid: #40916c;
|
||||||
|
--green-light: #52b788;
|
||||||
|
--green-pale: #95d5b2;
|
||||||
|
--green-ghost: #d8f3dc;
|
||||||
|
--green-bg: #f0f9f4;
|
||||||
|
|
||||||
|
--amber: #f4a261;
|
||||||
|
--amber-dark: #e76f51;
|
||||||
|
--yellow: #e9c46a;
|
||||||
|
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #f8fafb;
|
||||||
|
--border: #e2ece7;
|
||||||
|
--text: #1b4332;
|
||||||
|
--text-muted: #5f7a6e;
|
||||||
|
--text-light: #94b5a5;
|
||||||
|
|
||||||
|
--danger: #e63946;
|
||||||
|
--blue: #2d9cdb;
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--shadow: 0 2px 8px rgba(27,67,50,0.08);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(27,67,50,0.12);
|
||||||
|
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Reset ===== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 15px; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--green-bg);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a { color: var(--green-mid); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ===== Sidebar ===== */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
background: var(--green-darkest);
|
||||||
|
color: #d8f3dc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1.4rem 1.2rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.brand-icon { font-size: 1.6rem; }
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8rem 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--green-pale);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-link:hover { background: rgba(255,255,255,0.07); color: #fff; text-decoration: none; }
|
||||||
|
.nav-link.active { background: var(--green-dark); color: #fff; }
|
||||||
|
.nav-icon { font-size: 1rem; width: 1.2rem; text-align: center; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Main Content ===== */
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-w);
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green-darkest);
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Stats Row ===== */
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-left: 4px solid var(--green-light);
|
||||||
|
}
|
||||||
|
.stat-card.accent { border-left-color: var(--amber); }
|
||||||
|
.stat-card.green { border-left-color: var(--green-mid); }
|
||||||
|
.stat-card.warn { border-left-color: var(--amber-dark); }
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green-darkest);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Section Header ===== */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.section-header h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
.hint-text { font-size: 0.8rem; color: var(--text-light); }
|
||||||
|
|
||||||
|
/* ===== Tasks ===== */
|
||||||
|
.tasks-group { margin-bottom: 1rem; }
|
||||||
|
.tasks-group-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tasks-group-label.overdue { background: #fde8e8; color: var(--danger); }
|
||||||
|
.tasks-group-label.today { background: #fff3e0; color: #c85000; }
|
||||||
|
.tasks-group-label.week { background: #e8f5e9; color: var(--green-dark); }
|
||||||
|
.tasks-group-label.month { background: var(--green-ghost); color: var(--green-dark); }
|
||||||
|
|
||||||
|
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
border-left: 4px solid var(--green-light);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.task-card:hover { transform: translateX(2px); }
|
||||||
|
.task-card.overdue { border-left-color: var(--danger); }
|
||||||
|
.task-card.today { border-left-color: var(--amber); }
|
||||||
|
.task-card.week { border-left-color: var(--green-light); }
|
||||||
|
.task-card.month { border-left-color: var(--green-ghost); }
|
||||||
|
|
||||||
|
.task-dot {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-info { flex: 1; }
|
||||||
|
.task-title { font-weight: 600; font-size: 0.92rem; }
|
||||||
|
.task-detail { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
.task-date {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.task-date.overdue { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== Frost Badge ===== */
|
||||||
|
.badge-frost {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Timeline ===== */
|
||||||
|
.timeline-wrapper {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.timeline-months {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px repeat(12, 1fr);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
.timeline-months > span:first-child { color: transparent; }
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px repeat(365, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
position: relative;
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
.timeline-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
grid-column: 1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.timeline-bar-area {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
position: relative;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.timeline-segment {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.timeline-today-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--amber-dark);
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.timeline-today-line::after {
|
||||||
|
content: 'Today';
|
||||||
|
position: absolute;
|
||||||
|
top: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--amber-dark);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Batch Cards ===== */
|
||||||
|
.batch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.batch-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.batch-card:hover { box-shadow: var(--shadow-lg); }
|
||||||
|
.batch-card-top {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.batch-card-body { padding: 1rem; }
|
||||||
|
.batch-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.batch-card-variety {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.batch-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.batch-meta-item {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--green-bg);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.batch-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge-status {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.status-planned { background: #f1f3f5; color: #6c757d; }
|
||||||
|
.status-germinating{ background: #fff3cd; color: #856404; }
|
||||||
|
.status-seedling { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-potted_up { background: #bbf7d0; color: #14532d; }
|
||||||
|
.status-hardening { background: #dbeafe; color: #1e3a8a; }
|
||||||
|
.status-garden { background: #dcfce7; color: #166534; }
|
||||||
|
.status-harvested { background: #ede9fe; color: #4c1d95; }
|
||||||
|
.status-failed { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ===== Variety Table ===== */
|
||||||
|
.variety-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.variety-table th {
|
||||||
|
background: var(--green-darkest);
|
||||||
|
color: var(--green-ghost);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.variety-table td {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.variety-table tr:last-child td { border-bottom: none; }
|
||||||
|
.variety-table tr:hover td { background: var(--green-bg); }
|
||||||
|
.variety-color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.weeks-chip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--green-ghost);
|
||||||
|
color: var(--green-dark);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Buttons ===== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.btn-primary { background: var(--green-dark); color: #fff; }
|
||||||
|
.btn-secondary { background: var(--green-ghost); color: var(--green-dark); }
|
||||||
|
.btn-danger { background: #fee2e2; color: var(--danger); }
|
||||||
|
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.78rem; }
|
||||||
|
.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ===== Filter Bar ===== */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search-input, .select-input {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-input { min-width: 220px; flex: 1; }
|
||||||
|
.search-input:focus, .select-input:focus { border-color: var(--green-light); }
|
||||||
|
|
||||||
|
/* ===== Settings ===== */
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.4rem;
|
||||||
|
}
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green-dark);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.settings-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--green-mid);
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Forms ===== */
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
box-shadow: 0 0 0 3px rgba(82,183,136,0.15);
|
||||||
|
}
|
||||||
|
.form-textarea { resize: vertical; min-height: 80px; }
|
||||||
|
.form-hint { font-size: 0.76rem; color: var(--text-light); margin-top: 0.25rem; display: block; }
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Modal ===== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(27,67,50,0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal-overlay.hidden { display: none; }
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 580px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-header h3 { font-size: 1.05rem; font-weight: 700; color: var(--green-darkest); }
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--danger); }
|
||||||
|
#modal-body { padding: 1.4rem; }
|
||||||
|
|
||||||
|
/* ===== Empty State ===== */
|
||||||
|
.empty-state {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Toast ===== */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: var(--green-darkest);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.7rem 1.2rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
z-index: 2000;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: fadeUp 0.3s ease;
|
||||||
|
}
|
||||||
|
.toast.hidden { display: none; }
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Category Badges ===== */
|
||||||
|
.cat-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.cat-vegetable { background: #dcfce7; color: #166534; }
|
||||||
|
.cat-herb { background: #d1fae5; color: #065f46; }
|
||||||
|
.cat-flower { background: #fce7f3; color: #9d174d; }
|
||||||
|
.cat-fruit { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* ===== Responsive ===== */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stats-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
.settings-grid { grid-template-columns: 1fr; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root { --sidebar-w: 0px; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.main-content { margin-left: 0; padding: 1rem; }
|
||||||
|
.stats-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
216
nginx/html/index.html
Normal file
216
nginx/html/index.html
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sproutly</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<span class="brand-icon">🌿</span>
|
||||||
|
<span class="brand-name">Sproutly</span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="#dashboard" class="nav-link active" data-page="dashboard">
|
||||||
|
<span class="nav-icon">📈</span> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="#varieties" class="nav-link" data-page="varieties">
|
||||||
|
<span class="nav-icon">🌱</span> Seed Library
|
||||||
|
</a>
|
||||||
|
<a href="#garden" class="nav-link" data-page="garden">
|
||||||
|
<span class="nav-icon">🌿</span> My Garden
|
||||||
|
</a>
|
||||||
|
<a href="#settings" class="nav-link" data-page="settings">
|
||||||
|
<span class="nav-icon">⚙</span> Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span id="sidebar-date"></span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<section id="page-dashboard" class="page active">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Dashboard</h1>
|
||||||
|
<p class="page-subtitle" id="dash-subtitle">Loading your garden...</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="App.showAddBatchModal()">+ Log Batch</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row" id="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="stat-varieties">—</div>
|
||||||
|
<div class="stat-label">Seed Varieties</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card accent">
|
||||||
|
<div class="stat-value" id="stat-active">—</div>
|
||||||
|
<div class="stat-label">Active Batches</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card green">
|
||||||
|
<div class="stat-value" id="stat-garden">—</div>
|
||||||
|
<div class="stat-label">In Garden</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warn">
|
||||||
|
<div class="stat-value" id="stat-tasks">—</div>
|
||||||
|
<div class="stat-label">Tasks This Month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Action Needed</h2>
|
||||||
|
<span class="badge badge-frost" id="frost-badge"></span>
|
||||||
|
</div>
|
||||||
|
<div id="tasks-container">
|
||||||
|
<div class="empty-state">Loading tasks...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header" style="margin-top:2rem">
|
||||||
|
<h2>Year Planting Timeline</h2>
|
||||||
|
<span class="hint-text">Based on your last frost date</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-wrapper" id="timeline-container">
|
||||||
|
<div class="empty-state">Configure your last frost date in Settings to see the timeline.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header" style="margin-top:2rem">
|
||||||
|
<h2>Active Batches</h2>
|
||||||
|
</div>
|
||||||
|
<div class="batch-grid" id="active-batches-container">
|
||||||
|
<div class="empty-state">No active batches. Start tracking by logging a batch!</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SEED LIBRARY -->
|
||||||
|
<section id="page-varieties" class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Seed Library</h1>
|
||||||
|
<p class="page-subtitle">Manage your plant varieties and growing schedules</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="App.showAddVarietyModal()">+ Add Variety</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input type="text" id="variety-search" class="search-input" placeholder="Search varieties..." oninput="App.filterVarieties()" />
|
||||||
|
<select id="variety-cat-filter" class="select-input" onchange="App.filterVarieties()">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<option value="vegetable">Vegetables</option>
|
||||||
|
<option value="herb">Herbs</option>
|
||||||
|
<option value="flower">Flowers</option>
|
||||||
|
<option value="fruit">Fruit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="varieties-container">
|
||||||
|
<div class="empty-state">Loading varieties...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MY GARDEN -->
|
||||||
|
<section id="page-garden" class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">My Garden</h1>
|
||||||
|
<p class="page-subtitle">Track your growing batches from seed to harvest</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="App.showAddBatchModal()">+ Log Batch</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="garden-status-filter" class="select-input" onchange="App.filterBatches()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="germinating">Germinating</option>
|
||||||
|
<option value="seedling">Seedling</option>
|
||||||
|
<option value="potted_up">Potted Up</option>
|
||||||
|
<option value="hardening">Hardening Off</option>
|
||||||
|
<option value="garden">In Garden</option>
|
||||||
|
<option value="harvested">Harvested</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="batch-grid" id="garden-container">
|
||||||
|
<div class="empty-state">Loading batches...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SETTINGS -->
|
||||||
|
<section id="page-settings" class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Settings</h1>
|
||||||
|
<p class="page-subtitle">Configure your growing zone and notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-section-title">Growing Zone</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Location Name</label>
|
||||||
|
<input type="text" id="s-location" class="form-input" placeholder="e.g. Backyard Garden" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Last Spring Frost Date</label>
|
||||||
|
<input type="date" id="s-last-frost" class="form-input" />
|
||||||
|
<span class="form-hint">Used to calculate all seed starting dates</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">First Fall Frost Date</label>
|
||||||
|
<input type="date" id="s-first-frost" class="form-input" />
|
||||||
|
<span class="form-hint">Used for fall planting planning</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Timezone</label>
|
||||||
|
<input type="text" id="s-timezone" class="form-input" placeholder="e.g. America/New_York" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-section-title">Ntfy Notifications</h3>
|
||||||
|
<p class="settings-desc">
|
||||||
|
Get daily summaries on your phone via <a href="https://ntfy.sh" target="_blank">ntfy.sh</a> (free, open source push notifications).
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ntfy Server</label>
|
||||||
|
<input type="text" id="s-ntfy-server" class="form-input" placeholder="https://ntfy.sh" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ntfy Topic</label>
|
||||||
|
<input type="text" id="s-ntfy-topic" class="form-input" placeholder="my-garden-alerts" />
|
||||||
|
<span class="form-hint">Subscribe to this topic in the ntfy app</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Daily Summary Time</label>
|
||||||
|
<input type="time" id="s-notif-time" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary" onclick="App.sendTestNotification()">Send Test</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.sendDailySummary()">Send Summary Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.saveSettings()">Save Settings</button>
|
||||||
|
<span id="settings-status" class="settings-status"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- MODALS -->
|
||||||
|
<div id="modal-overlay" class="modal-overlay hidden" onclick="App.closeModal(event)">
|
||||||
|
<div class="modal" id="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title">Modal</h3>
|
||||||
|
<button class="modal-close" onclick="App.closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
786
nginx/html/js/app.js
Normal file
786
nginx/html/js/app.js
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
/* Sproutly Frontend — Vanilla JS SPA */
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
// ===== API Helpers =====
|
||||||
|
async function apiFetch(path, opts = {}) {
|
||||||
|
const res = await fetch(API + path, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
get: (p) => apiFetch(p),
|
||||||
|
post: (p, body) => apiFetch(p, { method: 'POST', body }),
|
||||||
|
put: (p, body) => apiFetch(p, { method: 'PUT', body }),
|
||||||
|
delete: (p) => apiFetch(p, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== State =====
|
||||||
|
let state = {
|
||||||
|
varieties: [],
|
||||||
|
batches: [],
|
||||||
|
settings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Toast =====
|
||||||
|
let toastTimer;
|
||||||
|
function toast(msg, isError = false) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'toast' + (isError ? ' error' : '');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => el.classList.add('hidden'), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Utility =====
|
||||||
|
function fmt(dateStr) {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAway(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
const today = new Date(); today.setHours(0,0,0,0);
|
||||||
|
return Math.round((d - today) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relDate(dateStr) {
|
||||||
|
const d = daysAway(dateStr);
|
||||||
|
if (d === null) return '';
|
||||||
|
if (d < -1) return `${Math.abs(d)} days ago`;
|
||||||
|
if (d === -1) return 'Yesterday';
|
||||||
|
if (d === 0) return 'Today';
|
||||||
|
if (d === 1) return 'Tomorrow';
|
||||||
|
if (d <= 7) return `In ${d} days`;
|
||||||
|
return fmt(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
return {
|
||||||
|
planned: 'Planned', germinating: 'Germinating', seedling: 'Seedling',
|
||||||
|
potted_up: 'Potted Up', hardening: 'Hardening Off', garden: 'In Garden',
|
||||||
|
harvested: 'Harvested', failed: 'Failed',
|
||||||
|
}[s] || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sunLabel(s) {
|
||||||
|
return { full_sun: 'Full Sun', part_shade: 'Partial Shade', full_shade: 'Full Shade' }[s] || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Navigation =====
|
||||||
|
function navigate(page) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||||
|
document.getElementById('page-' + page)?.classList.add('active');
|
||||||
|
document.querySelector(`[data-page="${page}"]`)?.classList.add('active');
|
||||||
|
|
||||||
|
if (page === 'dashboard') loadDashboard();
|
||||||
|
if (page === 'varieties') loadVarieties();
|
||||||
|
if (page === 'garden') loadGarden();
|
||||||
|
if (page === 'settings') loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Dashboard =====
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/dashboard/');
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
const sub = document.getElementById('dash-subtitle');
|
||||||
|
const today = new Date().toLocaleDateString('en-US', { weekday:'long', month:'long', day:'numeric', year:'numeric' });
|
||||||
|
sub.textContent = data.location_name
|
||||||
|
? `${data.location_name} — ${today}`
|
||||||
|
: today;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
document.getElementById('stat-varieties').textContent = data.stats.total_varieties;
|
||||||
|
document.getElementById('stat-active').textContent = data.stats.active_batches;
|
||||||
|
document.getElementById('stat-garden').textContent = data.stats.in_garden;
|
||||||
|
document.getElementById('stat-tasks').textContent = data.stats.tasks_count;
|
||||||
|
|
||||||
|
// Frost badge
|
||||||
|
const fb = document.getElementById('frost-badge');
|
||||||
|
if (data.last_frost_date) {
|
||||||
|
const d = daysAway(data.last_frost_date);
|
||||||
|
if (d !== null && d >= 0 && d <= 60) {
|
||||||
|
fb.textContent = `Last frost in ${d} days (${fmt(data.last_frost_date)})`;
|
||||||
|
fb.style.display = '';
|
||||||
|
} else if (d !== null && d < 0) {
|
||||||
|
fb.textContent = `Last frost was ${fmt(data.last_frost_date)}`;
|
||||||
|
fb.style.display = '';
|
||||||
|
} else {
|
||||||
|
fb.textContent = `Last frost: ${fmt(data.last_frost_date)}`;
|
||||||
|
fb.style.display = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fb.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
renderTasks(data);
|
||||||
|
|
||||||
|
// Timeline
|
||||||
|
renderTimeline(data.timeline, data.last_frost_date);
|
||||||
|
|
||||||
|
// Active batches
|
||||||
|
renderActiveBatches(data.active_batches);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
document.getElementById('tasks-container').innerHTML = `<div class="empty-state">Error loading dashboard: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks(data) {
|
||||||
|
const container = document.getElementById('tasks-container');
|
||||||
|
const allGroups = [
|
||||||
|
{ key: 'overdue', label: 'Overdue', tasks: data.tasks_overdue },
|
||||||
|
{ key: 'today', label: 'Today', tasks: data.tasks_today },
|
||||||
|
{ key: 'week', label: 'This Week', tasks: data.tasks_week },
|
||||||
|
{ key: 'month', label: 'This Month', tasks: data.tasks_month },
|
||||||
|
].filter(g => g.tasks.length > 0);
|
||||||
|
|
||||||
|
if (!allGroups.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No upcoming tasks in the next 30 days. Check your settings to set a last frost date.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = allGroups.map(g => `
|
||||||
|
<div class="tasks-group">
|
||||||
|
<span class="tasks-group-label ${g.key}">${g.label}</span>
|
||||||
|
<div class="task-list">
|
||||||
|
${g.tasks.map(t => renderTask(t)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTask(t) {
|
||||||
|
const dateClass = t.urgency === 'overdue' ? 'overdue' : '';
|
||||||
|
const dateText = t.urgency === 'overdue'
|
||||||
|
? `${Math.abs(t.days_away)} days overdue`
|
||||||
|
: t.days_away === 0 ? 'Today' : relDate(t.due_date);
|
||||||
|
const typeIcon = {
|
||||||
|
start_seeds: '🌱', pot_up: '🪴', transplant: '🌿', check_batch: '🔎'
|
||||||
|
}[t.type] || '📋';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-card ${t.urgency}">
|
||||||
|
<div class="task-dot" style="background:${esc(t.variety_color)}"></div>
|
||||||
|
<div class="task-info">
|
||||||
|
<div class="task-title">${typeIcon} ${esc(t.title)}</div>
|
||||||
|
<div class="task-detail">${esc(t.detail)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-date ${dateClass}">${dateText}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Timeline =====
|
||||||
|
function renderTimeline(entries, lastFrostDate) {
|
||||||
|
const container = document.getElementById('timeline-container');
|
||||||
|
if (!entries || !entries.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No varieties configured.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
|
const todayDoy = Math.floor((today - startOfYear) / 86400000) + 1;
|
||||||
|
const daysInYear = 365;
|
||||||
|
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
const monthStarts = [1,32,60,91,121,152,182,213,244,274,305,335];
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="position:relative; min-width:700px">
|
||||||
|
<div class="timeline-months">
|
||||||
|
<span></span>
|
||||||
|
${months.map((m, i) => {
|
||||||
|
const pct = ((monthStarts[i] - 1) / daysInYear * 100).toFixed(2);
|
||||||
|
return `<span>${m}</span>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Today marker (positioned in bar area)
|
||||||
|
const todayPct = ((todayDoy - 1) / daysInYear * 100).toFixed(2);
|
||||||
|
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (!e.start_day && !e.greenhouse_day && !e.garden_day) return;
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
// Indoor start to greenhouse
|
||||||
|
if (e.start_day && e.greenhouse_day) {
|
||||||
|
segments.push({ from: e.start_day, to: e.greenhouse_day, opacity: '0.5', label: 'start' });
|
||||||
|
} else if (e.start_day && e.garden_day) {
|
||||||
|
segments.push({ from: e.start_day, to: e.garden_day, opacity: '0.45', label: 'start' });
|
||||||
|
}
|
||||||
|
// Greenhouse to garden
|
||||||
|
if (e.greenhouse_day && e.garden_day) {
|
||||||
|
segments.push({ from: e.greenhouse_day, to: e.garden_day, opacity: '0.7', label: 'greenhouse' });
|
||||||
|
}
|
||||||
|
// Garden onwards
|
||||||
|
if (e.garden_day) {
|
||||||
|
const endDay = Math.min(e.end_day || e.garden_day + 70, daysInYear);
|
||||||
|
segments.push({ from: e.garden_day, to: endDay, opacity: '1', label: 'garden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="timeline-row">
|
||||||
|
<div class="timeline-label" title="${esc(e.full_name)}">${esc(e.name)}</div>
|
||||||
|
<div class="timeline-bar-area">
|
||||||
|
<div class="timeline-today-line" style="left:${todayPct}%"></div>
|
||||||
|
${segments.map(s => {
|
||||||
|
const left = ((Math.max(s.from, 1) - 1) / daysInYear * 100).toFixed(2);
|
||||||
|
const width = ((Math.min(s.to, daysInYear) - Math.max(s.from, 1)) / daysInYear * 100).toFixed(2);
|
||||||
|
return `<div class="timeline-segment" style="left:${left}%;width:${width}%;background:${e.color};opacity:${s.opacity}" title="${e.full_name}: ${s.label}"></div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Active Batches =====
|
||||||
|
function renderActiveBatches(batches) {
|
||||||
|
const container = document.getElementById('active-batches-container');
|
||||||
|
if (!batches || !batches.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No active batches yet. Log a batch to get started!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = batches.map(b => batchCard(b, true)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchCard(b, compact = false) {
|
||||||
|
const v = b.variety || {};
|
||||||
|
const color = v.color || '#52b788';
|
||||||
|
const name = b.label || `${v.name}${v.variety_name ? ' ('+v.variety_name+')' : ''}`;
|
||||||
|
const meta = [];
|
||||||
|
if (b.quantity > 1) meta.push(`${b.quantity} plants`);
|
||||||
|
if (b.sow_date) meta.push(`Sown ${fmt(b.sow_date)}`);
|
||||||
|
if (b.garden_date) meta.push(`Garden ${fmt(b.garden_date)}`);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="batch-card">
|
||||||
|
<div class="batch-card-top" style="background:${color}"></div>
|
||||||
|
<div class="batch-card-body">
|
||||||
|
<div class="badge-status status-${b.status}">${statusLabel(b.status)}</div>
|
||||||
|
<div class="batch-card-name">${esc(name)}</div>
|
||||||
|
<div class="batch-card-variety">${esc(v.name || '')}${v.variety_name ? ' — ' + esc(v.variety_name) : ''}</div>
|
||||||
|
<div class="batch-card-meta">${meta.map(m => `<span class="batch-meta-item">${esc(m)}</span>`).join('')}</div>
|
||||||
|
${b.notes ? `<div class="batch-card-variety" style="margin-bottom:.5rem">${esc(b.notes)}</div>` : ''}
|
||||||
|
<div class="batch-card-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="App.showEditBatchModal(${b.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="App.deleteBatch(${b.id})">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Varieties =====
|
||||||
|
async function loadVarieties() {
|
||||||
|
try {
|
||||||
|
state.varieties = await api.get('/varieties/');
|
||||||
|
renderVarieties();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('varieties-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVarieties() {
|
||||||
|
const search = (document.getElementById('variety-search')?.value || '').toLowerCase();
|
||||||
|
const cat = document.getElementById('variety-cat-filter')?.value || '';
|
||||||
|
const list = state.varieties.filter(v =>
|
||||||
|
(!search || `${v.name} ${v.variety_name || ''}`.toLowerCase().includes(search)) &&
|
||||||
|
(!cat || v.category === cat)
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById('varieties-container');
|
||||||
|
if (!list.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No varieties found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="variety-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Plant</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Start Seeds</th>
|
||||||
|
<th>Greenhouse</th>
|
||||||
|
<th>Transplant</th>
|
||||||
|
<th>Germination</th>
|
||||||
|
<th>Sun</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${list.map(v => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="variety-color-dot" style="background:${v.color}"></span>
|
||||||
|
<strong>${esc(v.name)}</strong>
|
||||||
|
${v.variety_name ? `<br><small style="color:var(--text-muted)">${esc(v.variety_name)}</small>` : ''}
|
||||||
|
</td>
|
||||||
|
<td><span class="cat-badge cat-${v.category}">${v.category}</span></td>
|
||||||
|
<td>${v.weeks_to_start ? `<span class="weeks-chip">${v.weeks_to_start}wk before frost</span>` : '—'}</td>
|
||||||
|
<td>${v.weeks_to_greenhouse ? `<span class="weeks-chip">${v.weeks_to_greenhouse}wk before frost</span>` : '—'}</td>
|
||||||
|
<td>${v.weeks_to_garden != null
|
||||||
|
? `<span class="weeks-chip">${v.weeks_to_garden >= 0 ? v.weeks_to_garden+'wk after frost' : Math.abs(v.weeks_to_garden)+'wk before frost'}</span>`
|
||||||
|
: '—'}</td>
|
||||||
|
<td>${v.days_to_germinate ? `${v.days_to_germinate}d` : '—'}</td>
|
||||||
|
<td>${sunLabel(v.sun_requirement)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="App.showEditVarietyModal(${v.id})">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="App.deleteVariety(${v.id})">Del</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVarieties() { renderVarieties(); }
|
||||||
|
|
||||||
|
// ===== Garden =====
|
||||||
|
async function loadGarden() {
|
||||||
|
try {
|
||||||
|
state.batches = await api.get('/batches/');
|
||||||
|
renderGarden();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('garden-container').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGarden() {
|
||||||
|
const statusFilter = document.getElementById('garden-status-filter')?.value || '';
|
||||||
|
const list = state.batches.filter(b => !statusFilter || b.status === statusFilter);
|
||||||
|
const container = document.getElementById('garden-container');
|
||||||
|
if (!list.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No batches found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = list.map(b => batchCard(b)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterBatches() { renderGarden(); }
|
||||||
|
|
||||||
|
// ===== Settings =====
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
state.settings = await api.get('/settings/');
|
||||||
|
const s = state.settings;
|
||||||
|
document.getElementById('s-location').value = s.location_name || '';
|
||||||
|
document.getElementById('s-last-frost').value = s.last_frost_date || '';
|
||||||
|
document.getElementById('s-first-frost').value = s.first_frost_fall_date || '';
|
||||||
|
document.getElementById('s-timezone').value = s.timezone || 'UTC';
|
||||||
|
document.getElementById('s-ntfy-server').value = s.ntfy_server || 'https://ntfy.sh';
|
||||||
|
document.getElementById('s-ntfy-topic').value = s.ntfy_topic || '';
|
||||||
|
document.getElementById('s-notif-time').value = s.notification_time || '07:00';
|
||||||
|
} catch (e) {
|
||||||
|
toast('Failed to load settings: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
location_name: document.getElementById('s-location').value || null,
|
||||||
|
last_frost_date: document.getElementById('s-last-frost').value || null,
|
||||||
|
first_frost_fall_date: document.getElementById('s-first-frost').value || null,
|
||||||
|
timezone: document.getElementById('s-timezone').value || 'UTC',
|
||||||
|
ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh',
|
||||||
|
ntfy_topic: document.getElementById('s-ntfy-topic').value || null,
|
||||||
|
notification_time: document.getElementById('s-notif-time').value || '07:00',
|
||||||
|
};
|
||||||
|
await api.put('/settings/', payload);
|
||||||
|
toast('Settings saved!');
|
||||||
|
document.getElementById('settings-status').textContent = 'Saved!';
|
||||||
|
setTimeout(() => document.getElementById('settings-status').textContent = '', 3000);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Save failed: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestNotification() {
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/test', {});
|
||||||
|
toast('Test notification sent!');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Failed: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDailySummary() {
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/daily', {});
|
||||||
|
toast('Daily summary sent!');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Failed: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Modals =====
|
||||||
|
function openModal(title, bodyHtml) {
|
||||||
|
document.getElementById('modal-title').textContent = title;
|
||||||
|
document.getElementById('modal-body').innerHTML = bodyHtml;
|
||||||
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(e) {
|
||||||
|
if (e && e.target !== document.getElementById('modal-overlay')) return;
|
||||||
|
document.getElementById('modal-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function varietyFormHtml(v = {}) {
|
||||||
|
const colorOpts = ['#e76f51','#f4a261','#e9c46a','#52b788','#40916c','#2d6a4f','#95d5b2','#2d9cdb','#a8dadc','#e63946'];
|
||||||
|
return `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Plant Name *</label>
|
||||||
|
<input type="text" id="f-name" class="form-input" value="${esc(v.name||'')}" placeholder="e.g. Tomato" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Variety Name</label>
|
||||||
|
<input type="text" id="f-variety-name" class="form-input" value="${esc(v.variety_name||'')}" placeholder="e.g. Roma" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<select id="f-category" class="form-select">
|
||||||
|
${['vegetable','herb','flower','fruit'].map(c => `<option value="${c}" ${v.category===c?'selected':''}>${c}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Color Tag</label>
|
||||||
|
<select id="f-color" class="form-select">
|
||||||
|
${colorOpts.map(c => `<option value="${c}" ${v.color===c?'selected':''}>${c}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Weeks before last frost to start indoors</label>
|
||||||
|
<input type="number" id="f-wks-start" class="form-input" value="${v.weeks_to_start||''}" placeholder="e.g. 8" min="0" max="20" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Weeks before last frost to pot up / greenhouse</label>
|
||||||
|
<input type="number" id="f-wks-gh" class="form-input" value="${v.weeks_to_greenhouse||''}" placeholder="e.g. 2" min="0" max="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Weeks to transplant (+ after frost, - before frost)</label>
|
||||||
|
<input type="number" id="f-wks-garden" class="form-input" value="${v.weeks_to_garden!=null?v.weeks_to_garden:''}" placeholder="e.g. 2 or -2" min="-8" max="12" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Days to germinate</label>
|
||||||
|
<input type="number" id="f-germinate" class="form-input" value="${v.days_to_germinate||7}" min="1" max="60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Sun Requirement</label>
|
||||||
|
<select id="f-sun" class="form-select">
|
||||||
|
${['full_sun','part_shade','full_shade'].map(s => `<option value="${s}" ${v.sun_requirement===s?'selected':''}>${sunLabel(s)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Water Needs</label>
|
||||||
|
<select id="f-water" class="form-select">
|
||||||
|
${['low','medium','high'].map(w => `<option value="${w}" ${v.water_needs===w?'selected':''}>${w}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="f-direct-sow" ${v.direct_sow_ok?'checked':''} />
|
||||||
|
Can Direct Sow Outdoors
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="f-frost-tolerant" ${v.frost_tolerant?'checked':''} />
|
||||||
|
Frost Tolerant
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea id="f-notes" class="form-textarea">${esc(v.notes||'')}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVarietyForm() {
|
||||||
|
const wg = document.getElementById('f-wks-garden').value;
|
||||||
|
return {
|
||||||
|
name: document.getElementById('f-name').value.trim(),
|
||||||
|
variety_name: document.getElementById('f-variety-name').value.trim() || null,
|
||||||
|
category: document.getElementById('f-category').value,
|
||||||
|
color: document.getElementById('f-color').value,
|
||||||
|
weeks_to_start: parseInt(document.getElementById('f-wks-start').value) || null,
|
||||||
|
weeks_to_greenhouse: parseInt(document.getElementById('f-wks-gh').value) || null,
|
||||||
|
weeks_to_garden: wg !== '' ? parseInt(wg) : null,
|
||||||
|
days_to_germinate: parseInt(document.getElementById('f-germinate').value) || 7,
|
||||||
|
sun_requirement: document.getElementById('f-sun').value,
|
||||||
|
water_needs: document.getElementById('f-water').value,
|
||||||
|
direct_sow_ok: document.getElementById('f-direct-sow').checked,
|
||||||
|
frost_tolerant: document.getElementById('f-frost-tolerant').checked,
|
||||||
|
notes: document.getElementById('f-notes').value.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddVarietyModal() {
|
||||||
|
openModal('Add Seed Variety', `
|
||||||
|
${varietyFormHtml()}
|
||||||
|
<div class="btn-row" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.submitAddVariety()">Add Variety</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddVariety() {
|
||||||
|
try {
|
||||||
|
const data = collectVarietyForm();
|
||||||
|
if (!data.name) { toast('Plant name is required', true); return; }
|
||||||
|
await api.post('/varieties/', data);
|
||||||
|
closeModal();
|
||||||
|
toast('Variety added!');
|
||||||
|
await loadVarieties();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditVarietyModal(id) {
|
||||||
|
const v = state.varieties.find(x => x.id === id);
|
||||||
|
if (!v) return;
|
||||||
|
openModal('Edit ' + v.name, `
|
||||||
|
${varietyFormHtml(v)}
|
||||||
|
<div class="btn-row" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.submitEditVariety(${id})">Save Changes</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditVariety(id) {
|
||||||
|
try {
|
||||||
|
const data = collectVarietyForm();
|
||||||
|
if (!data.name) { toast('Plant name is required', true); return; }
|
||||||
|
await api.put(`/varieties/${id}`, data);
|
||||||
|
closeModal();
|
||||||
|
toast('Variety updated!');
|
||||||
|
await loadVarieties();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVariety(id) {
|
||||||
|
const v = state.varieties.find(x => x.id === id);
|
||||||
|
if (!confirm(`Delete ${v ? v.name : 'this variety'}? This will also delete all associated batches.`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/varieties/${id}`);
|
||||||
|
toast('Variety deleted');
|
||||||
|
await loadVarieties();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Batch Modals =====
|
||||||
|
function batchFormHtml(b = {}) {
|
||||||
|
const varOpts = state.varieties.map(v =>
|
||||||
|
`<option value="${v.id}" ${b.variety_id===v.id?'selected':''}>${v.name}${v.variety_name?' ('+v.variety_name+')':''}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Plant Variety *</label>
|
||||||
|
<select id="bf-variety" class="form-select">${varOpts}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Batch Label</label>
|
||||||
|
<input type="text" id="bf-label" class="form-input" value="${esc(b.label||'')}" placeholder="e.g. Main Crop" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quantity (seeds/plants)</label>
|
||||||
|
<input type="number" id="bf-qty" class="form-input" value="${b.quantity||1}" min="1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select id="bf-status" class="form-select">
|
||||||
|
${['planned','germinating','seedling','potted_up','hardening','garden','harvested','failed']
|
||||||
|
.map(s => `<option value="${s}" ${b.status===s?'selected':''}>${statusLabel(s)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Sow Date</label>
|
||||||
|
<input type="date" id="bf-sow" class="form-input" value="${b.sow_date||''}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Germination Date</label>
|
||||||
|
<input type="date" id="bf-germ" class="form-input" value="${b.germination_date||''}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Greenhouse / Pot Up Date</label>
|
||||||
|
<input type="date" id="bf-gh" class="form-input" value="${b.greenhouse_date||''}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Garden Transplant Date</label>
|
||||||
|
<input type="date" id="bf-garden" class="form-input" value="${b.garden_date||''}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea id="bf-notes" class="form-textarea">${esc(b.notes||'')}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBatchForm() {
|
||||||
|
return {
|
||||||
|
variety_id: parseInt(document.getElementById('bf-variety').value),
|
||||||
|
label: document.getElementById('bf-label').value.trim() || null,
|
||||||
|
quantity: parseInt(document.getElementById('bf-qty').value) || 1,
|
||||||
|
status: document.getElementById('bf-status').value,
|
||||||
|
sow_date: document.getElementById('bf-sow').value || null,
|
||||||
|
germination_date: document.getElementById('bf-germ').value || null,
|
||||||
|
greenhouse_date: document.getElementById('bf-gh').value || null,
|
||||||
|
garden_date: document.getElementById('bf-garden').value || null,
|
||||||
|
notes: document.getElementById('bf-notes').value.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddBatchModal() {
|
||||||
|
if (!state.varieties.length) {
|
||||||
|
// Load varieties first if not loaded
|
||||||
|
api.get('/varieties/').then(v => { state.varieties = v; showAddBatchModal(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal('Log a Batch', `
|
||||||
|
${batchFormHtml()}
|
||||||
|
<div class="btn-row" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.submitAddBatch()">Log Batch</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddBatch() {
|
||||||
|
try {
|
||||||
|
const data = collectBatchForm();
|
||||||
|
await api.post('/batches/', data);
|
||||||
|
closeModal();
|
||||||
|
toast('Batch logged!');
|
||||||
|
state.batches = await api.get('/batches/');
|
||||||
|
renderGarden();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEditBatchModal(id) {
|
||||||
|
try {
|
||||||
|
if (!state.varieties.length) state.varieties = await api.get('/varieties/');
|
||||||
|
const b = state.batches.find(x => x.id === id) || await api.get(`/batches/${id}`);
|
||||||
|
openModal('Edit Batch', `
|
||||||
|
${batchFormHtml(b)}
|
||||||
|
<div class="btn-row" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.submitEditBatch(${id})">Save Changes</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditBatch(id) {
|
||||||
|
try {
|
||||||
|
const data = collectBatchForm();
|
||||||
|
await api.put(`/batches/${id}`, data);
|
||||||
|
closeModal();
|
||||||
|
toast('Batch updated!');
|
||||||
|
state.batches = await api.get('/batches/');
|
||||||
|
renderGarden();
|
||||||
|
renderActiveBatches(state.batches.filter(b =>
|
||||||
|
!['harvested','failed'].includes(b.status)
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBatch(id) {
|
||||||
|
if (!confirm('Delete this batch?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/batches/${id}`);
|
||||||
|
toast('Batch deleted');
|
||||||
|
state.batches = await api.get('/batches/');
|
||||||
|
renderGarden();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
function init() {
|
||||||
|
// Sidebar date
|
||||||
|
document.getElementById('sidebar-date').textContent =
|
||||||
|
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
|
// Navigation via hash
|
||||||
|
function handleNav() {
|
||||||
|
const page = (location.hash.replace('#','') || 'dashboard');
|
||||||
|
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', handleNav);
|
||||||
|
handleNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Public API =====
|
||||||
|
window.App = {
|
||||||
|
showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety,
|
||||||
|
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
|
||||||
|
filterVarieties, filterBatches,
|
||||||
|
saveSettings, sendTestNotification, sendDailySummary,
|
||||||
|
closeModal: (e) => closeModal(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
21
nginx/nginx.conf
Normal file
21
nginx/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve static frontend files
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to FastAPI backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
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_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user