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