Initial commit: Sproutly plant tracking app

This commit is contained in:
2026-03-08 23:27:16 -07:00
commit 4f66102bbb
20 changed files with 2643 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
.venv/
venv/
mysql_data/

10
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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

View File

View 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()

View 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,
)

View 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]

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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">&#127807;</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">&#128200;</span> Dashboard
</a>
<a href="#varieties" class="nav-link" data-page="varieties">
<span class="nav-icon">&#127793;</span> Seed Library
</a>
<a href="#garden" class="nav-link" data-page="garden">
<span class="nav-icon">&#127807;</span> My Garden
</a>
<a href="#settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</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()">&times;</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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ===== 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: '&#127793;', pot_up: '&#129716;', transplant: '&#127807;', check_batch: '&#128270;'
}[t.type] || '&#128203;';
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
View 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;
}
}