diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 9c604e7..22200c1 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -1,4 +1,6 @@ +import asyncio import logging +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request @@ -16,6 +18,7 @@ from dashboard.routes.voice import router as voice_router from dashboard.routes.voice_enhanced import router as voice_enhanced_router from dashboard.routes.mobile import router as mobile_router from dashboard.routes.swarm_ws import router as swarm_ws_router +from dashboard.routes.briefing import router as briefing_router logging.basicConfig( level=logging.INFO, @@ -27,9 +30,50 @@ logger = logging.getLogger(__name__) BASE_DIR = Path(__file__).parent PROJECT_ROOT = BASE_DIR.parent.parent +_BRIEFING_INTERVAL_HOURS = 6 + + +async def _briefing_scheduler() -> None: + """Background task: regenerate Timmy's briefing every 6 hours. + + Runs once at startup (after a short delay to let the server settle), + then on a 6-hour cadence. Skips generation if a fresh briefing already + exists (< 30 min old). + """ + from timmy.briefing import engine as briefing_engine + from notifications.push import notify_briefing_ready + + await asyncio.sleep(2) # Let server finish starting before first run + + while True: + try: + if briefing_engine.needs_refresh(): + logger.info("Generating morning briefing…") + briefing = briefing_engine.generate() + await notify_briefing_ready(briefing) + else: + logger.info("Briefing is fresh; skipping generation.") + except Exception as exc: + logger.error("Briefing scheduler error: %s", exc) + + await asyncio.sleep(_BRIEFING_INTERVAL_HOURS * 3600) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + task = asyncio.create_task(_briefing_scheduler()) + yield + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + app = FastAPI( title="Timmy Time — Mission Control", version="1.0.0", + lifespan=lifespan, # Docs disabled unless DEBUG=true in env / .env docs_url="/docs" if settings.debug else None, redoc_url="/redoc" if settings.debug else None, @@ -47,6 +91,7 @@ app.include_router(voice_router) app.include_router(voice_enhanced_router) app.include_router(mobile_router) app.include_router(swarm_ws_router) +app.include_router(briefing_router) @app.get("/", response_class=HTMLResponse) diff --git a/src/dashboard/routes/briefing.py b/src/dashboard/routes/briefing.py new file mode 100644 index 0000000..8368d5e --- /dev/null +++ b/src/dashboard/routes/briefing.py @@ -0,0 +1,70 @@ +"""Briefing routes — Morning briefing and approval queue. + +GET /briefing — render the briefing page +GET /briefing/approvals — HTMX partial: pending approval cards +POST /briefing/approvals/{id}/approve — approve an item (HTMX) +POST /briefing/approvals/{id}/reject — reject an item (HTMX) +""" + +import logging +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from timmy.briefing import engine as briefing_engine +from timmy import approvals as approval_store + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/briefing", tags=["briefing"]) +templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) + + +@router.get("", response_class=HTMLResponse) +async def get_briefing(request: Request): + """Return today's briefing page (generated or cached).""" + briefing = briefing_engine.get_or_generate() + return templates.TemplateResponse( + request, + "briefing.html", + {"briefing": briefing}, + ) + + +@router.get("/approvals", response_class=HTMLResponse) +async def get_approvals(request: Request): + """Return HTMX partial with all pending approval items.""" + items = approval_store.list_pending() + return templates.TemplateResponse( + request, + "partials/approval_cards.html", + {"items": items}, + ) + + +@router.post("/approvals/{item_id}/approve", response_class=HTMLResponse) +async def approve_item(request: Request, item_id: str): + """Approve an approval item; return the updated card via HTMX.""" + item = approval_store.approve(item_id) + if item is None: + return HTMLResponse("
Item not found.
", status_code=404) + return templates.TemplateResponse( + request, + "partials/approval_card_single.html", + {"item": item}, + ) + + +@router.post("/approvals/{item_id}/reject", response_class=HTMLResponse) +async def reject_item(request: Request, item_id: str): + """Reject an approval item; return the updated card via HTMX.""" + item = approval_store.reject(item_id) + if item is None: + return HTMLResponse("Item not found.
", status_code=404) + return templates.TemplateResponse( + request, + "partials/approval_card_single.html", + {"item": item}, + ) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index adb4dfc..c954297 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -21,6 +21,7 @@ MISSION CONTROL