211 lines
7.6 KiB
Python
211 lines
7.6 KiB
Python
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,
|
|
)
|