"""Dashboard API endpoints. Provides JSON data for the standalone HTML dashboard. All ClickUp data is cached for 5 minutes to avoid hammering the API. """ from __future__ import annotations import calendar import json import logging import shutil import threading import time from datetime import UTC, datetime from typing import TYPE_CHECKING from fastapi import APIRouter if TYPE_CHECKING: from .agent_registry import AgentRegistry from .config import Config from .db import Database from .scheduler import Scheduler log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Module-level refs wired at startup by create_api_router() _config: Config | None = None _db: Database | None = None _registry: AgentRegistry | None = None _scheduler: Scheduler | None = None # Simple in-memory cache for ClickUp data _cache: dict[str, dict] = {} _cache_lock = threading.Lock() CACHE_TTL = 300 # 5 minutes def create_api_router( config: Config, db: Database, registry: AgentRegistry, scheduler: Scheduler | None = None, ) -> APIRouter: """Wire dependencies and return the router.""" global _config, _db, _registry, _scheduler _config = config _db = db _registry = registry _scheduler = scheduler return router def _get_cached(key: str) -> dict | None: with _cache_lock: entry = _cache.get(key) if entry and time.time() - entry["ts"] < CACHE_TTL: return entry["data"] return None def _set_cached(key: str, data): with _cache_lock: _cache[key] = {"data": data, "ts": time.time()} def _get_clickup_client(): from .clickup import ClickUpClient return ClickUpClient( api_token=_config.clickup.api_token, workspace_id=_config.clickup.workspace_id, task_type_field_name=_config.clickup.task_type_field_name, ) # ── Tasks ── @router.get("/tasks") async def get_tasks(): """All ClickUp tasks grouped by work category.""" cached = _get_cached("tasks") if cached is not None: return cached if not _config or not _config.clickup.enabled: return {"tasks": [], "error": "ClickUp not configured"} client = _get_clickup_client() try: raw_tasks = client.get_tasks_from_overall_lists(_config.clickup.space_id) tasks = [] for t in raw_tasks: tasks.append( { "id": t.id, "name": t.name, "status": t.status, "task_type": t.task_type, "url": t.url, "due_date": t.due_date, "date_done": t.date_done, "list_name": t.list_name, "tags": t.tags, "custom_fields": t.custom_fields, } ) result = {"tasks": tasks, "count": len(tasks)} _set_cached("tasks", result) return result except Exception as e: log.error("Failed to fetch tasks for dashboard: %s", e) return {"tasks": [], "error": str(e)} finally: client.close() @router.get("/tasks/by-company") async def get_tasks_by_company(): """Tasks grouped by Client custom field.""" data = await get_tasks() by_company: dict[str, list] = {} for task in data.get("tasks", []): company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) # Sort companies by task count descending sorted_companies = sorted(by_company.items(), key=lambda x: -len(x[1])) return { "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted_companies ] } @router.get("/tasks/link-building") async def get_link_building_tasks(): """Link building tasks with KV state merged in.""" cached = _get_cached("lb_tasks") if cached is not None: return cached if not _config or not _config.clickup.enabled: return {"total": 0, "companies": [], "status_counts": {}} client = _get_clickup_client() try: raw_tasks = client.get_tasks_from_overall_lists( _config.clickup.space_id, include_closed=True ) except Exception as e: log.error("Failed to fetch LB tasks: %s", e) return {"total": 0, "companies": [], "status_counts": {}, "error": str(e)} finally: client.close() lb_tasks = [] for t in raw_tasks: if t.task_type != "Link Building": continue task_dict = { "id": t.id, "name": t.name, "status": t.status, "task_type": t.task_type, "url": t.url, "due_date": t.due_date, "date_done": t.date_done, "list_name": t.list_name, "tags": t.tags, "custom_fields": t.custom_fields, } lb_tasks.append(task_dict) # Merge KV state if _db: for task in lb_tasks: kv_key = f"clickup:task:{task['id']}:state" raw = _db.kv_get(kv_key) if raw: try: task["kv_state"] = json.loads(raw) except json.JSONDecodeError: task["kv_state"] = None else: task["kv_state"] = None # -- Build focused groups -- # Split active vs closed closed_statuses = {"complete", "closed", "done"} active_lb = [ t for t in lb_tasks if not any(kw in t["status"] for kw in closed_statuses) ] # need_cora: open LB tasks where LB Method = "Cora Backlinks" # Exclude tasks that automation already touched automation_touched = { "error", "automation underway", "complete", "closed", "done", "internal review", } need_cora = [ t for t in active_lb if t["custom_fields"].get("LB Method") == "Cora Backlinks" and t["status"] not in automation_touched ] need_cora.sort(key=lambda t: int(t.get("due_date") or "9999999999999")) # recently_completed: closed/complete tasks with date_done in last 7 days seven_days_ago_ms = (time.time() - 7 * 86400) * 1000 recently_completed = [] for t in lb_tasks: s = t["status"] if not (s.endswith("complete") or "closed" in s or "done" in s): continue if t["date_done"]: try: if int(t["date_done"]) >= seven_days_ago_ms: recently_completed.append(t) except (ValueError, TypeError): pass recently_completed.sort(key=lambda t: int(t.get("date_done") or "0"), reverse=True) # in_progress_not_started: status "in progress" but no meaningful KV state early_states = {"", "approved", "awaiting_approval"} in_progress_not_started = [] for t in lb_tasks: if t["status"] != "in progress": continue kv = t.get("kv_state") if kv is None or kv.get("state", "") in early_states: in_progress_not_started.append(t) by_company: dict[str, list] = {} for task in active_lb: company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) result = { "total": len(active_lb), "need_cora": need_cora, "recently_completed": recently_completed, "in_progress_not_started": in_progress_not_started, "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) ], "status_counts": _count_lb_statuses(active_lb), "next_month_count": _count_next_month(active_lb), } _set_cached("lb_tasks", result) return result @router.get("/tasks/need-cora") async def get_need_cora_tasks(): """Tasks that need a Cora report, deduplicated by keyword. Includes work categories: Link Building (Cora Backlinks), On Page Optimization, and Content Creation. Tasks sharing the same Keyword field are grouped into a single entry. """ cached = _get_cached("need_cora") if cached is not None: return cached if not _config or not _config.clickup.enabled: return {"count": 0, "keywords": []} # Reuse the cached /tasks data if available, otherwise fetch fresh tasks_data = await get_tasks() all_tasks = tasks_data.get("tasks", []) automation_touched = { "error", "automation underway", "complete", "closed", "done", "internal review", } # Work categories that need Cora reports cora_categories = {"Link Building", "On Page Optimization", "Content Creation"} # Only include tasks due within 30 days behind / 30 days ahead now_ms = time.time() * 1000 window_ms = 30 * 86400 * 1000 due_min = now_ms - window_ms due_max = now_ms + window_ms qualifying = [] for t in all_tasks: if t["task_type"] not in cora_categories: continue if t["status"] in automation_touched: continue # Filter by due date window (skip tasks with no due date) due = t.get("due_date") if not due: continue try: due_int = int(due) except (ValueError, TypeError): continue if due_int < due_min or due_int > due_max: continue # Link Building additionally requires LB Method = "Cora Backlinks" if t["task_type"] == "Link Building": if t["custom_fields"].get("LB Method") != "Cora Backlinks": continue qualifying.append(t) # Group by normalised Keyword by_keyword: dict[str, dict] = {} for t in qualifying: kw = (t["custom_fields"].get("Keyword") or t["name"]).strip() kw_lower = kw.lower() if kw_lower not in by_keyword: by_keyword[kw_lower] = { "keyword": kw, "company": t["custom_fields"].get("Client") or "Unassigned", "due_date": t.get("due_date"), "tasks": [], } by_keyword[kw_lower]["tasks"].append({ "id": t["id"], "name": t["name"], "task_type": t["task_type"], "status": t["status"], "url": t.get("url", ""), }) # Keep the earliest due date across grouped tasks existing_due = by_keyword[kw_lower]["due_date"] task_due = t.get("due_date") if task_due and (not existing_due or int(task_due) < int(existing_due)): by_keyword[kw_lower]["due_date"] = task_due keywords = sorted( by_keyword.values(), key=lambda k: int(k.get("due_date") or "9999999999999"), ) result = {"count": len(keywords), "keywords": keywords} _set_cached("need_cora", result) return result @router.get("/tasks/press-releases") async def get_press_release_tasks(): """Press release tasks with KV state merged in.""" data = await get_tasks() pr_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Press Release"] if _db: for task in pr_tasks: kv_key = f"clickup:task:{task['id']}:state" raw = _db.kv_get(kv_key) if raw: try: task["kv_state"] = json.loads(raw) except json.JSONDecodeError: task["kv_state"] = None else: task["kv_state"] = None by_company: dict[str, list] = {} for task in pr_tasks: company = task["custom_fields"].get("Client") or "Unassigned" by_company.setdefault(company, []).append(task) return { "total": len(pr_tasks), "companies": [ {"name": name, "tasks": tasks, "count": len(tasks)} for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) ], "status_counts": _count_statuses(pr_tasks), } def _count_statuses(tasks: list[dict]) -> dict[str, int]: counts: dict[str, int] = {} for t in tasks: s = t.get("status", "unknown") counts[s] = counts.get(s, 0) + 1 return counts def _count_lb_statuses(tasks: list[dict]) -> dict[str, int]: """Count only relevant statuses for tasks due in the current or previous month.""" now = datetime.now(UTC) # First day of current month cur_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) # First day of previous month if now.month == 1: prev_start = cur_start.replace(year=now.year - 1, month=12) else: prev_start = cur_start.replace(month=now.month - 1) prev_start_ms = int(prev_start.timestamp() * 1000) allowed = {"to do", "in progress", "error", "automation underway"} counts: dict[str, int] = {} for t in tasks: s = t.get("status", "unknown") if s not in allowed: continue due = t.get("due_date") if not due: continue try: if int(due) >= prev_start_ms: counts[s] = counts.get(s, 0) + 1 except (ValueError, TypeError): pass return counts def _count_next_month(tasks: list[dict]) -> int: """Count tasks due next month.""" now = datetime.now(UTC) if now.month == 12: next_start = now.replace( year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0, ) next_end_month = 1 next_end_year = now.year + 1 else: next_start = now.replace( month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0, ) next_end_month = now.month + 1 next_end_year = now.year last_day = calendar.monthrange(next_end_year, next_end_month)[1] next_end = next_start.replace(day=last_day, hour=23, minute=59, second=59) start_ms = int(next_start.timestamp() * 1000) end_ms = int(next_end.timestamp() * 1000) count = 0 for t in tasks: due = t.get("due_date") if not due: continue try: d = int(due) if start_ms <= d <= end_ms: count += 1 except (ValueError, TypeError): pass return count # ── Agents ── @router.get("/agents") async def get_agents(): """Agent configurations.""" if not _config: return {"agents": []} agents = [] for ac in _config.agents: agents.append( { "name": ac.name, "display_name": ac.display_name, "personality_file": ac.personality_file, "model": ac.model or _config.chat_model, "tools": ac.tools, "skills": ac.skills, "memory_scope": ac.memory_scope or "shared", } ) return {"agents": agents} # ── System ── @router.get("/system/health") async def get_system_health(): """System health: disk space, brains, integrations.""" health = { "execution_brain": shutil.which("claude") is not None, "clickup_enabled": _config.clickup.enabled if _config else False, "chat_model": _config.chat_model if _config else "unknown", "execution_model": _config.default_model if _config else "unknown", "disks": [], } # Disk space (Windows drives) for drive in ["C:", "D:", "E:", "Z:"]: try: usage = shutil.disk_usage(drive + "/") health["disks"].append( { "drive": drive, "total_gb": round(usage.total / (1024**3), 1), "free_gb": round(usage.free / (1024**3), 1), "percent_free": round(usage.free / usage.total * 100, 1), } ) except (FileNotFoundError, OSError): pass return health @router.get("/system/notifications") async def get_notifications(): """Recent notifications from the DB.""" if not _db: return {"notifications": []} rows = _db._conn.execute( "SELECT id, message, category, created_at FROM notifications" " ORDER BY id DESC LIMIT 50" ).fetchall() return {"notifications": [dict(r) for r in rows]} @router.get("/system/kv-states") async def get_kv_states(): """All ClickUp task states from KV store.""" if not _db: return {"states": []} pairs = _db.kv_scan("clickup:task:") states = [] for _key, val in pairs: try: state = json.loads(val) states.append(state) except json.JSONDecodeError: pass return {"states": states} @router.get("/system/loops") async def get_loop_timestamps(): """Last-run timestamps for scheduler loops.""" if not _scheduler: return {"loops": {}, "error": "Scheduler not available"} return {"loops": _scheduler.get_loop_timestamps()} @router.post("/system/loops/force") async def force_loop_run(): """Force the heartbeat and poll loops to run immediately.""" if not _scheduler: return {"status": "error", "message": "Scheduler not available"} _scheduler.force_heartbeat() _scheduler.force_poll() return {"status": "ok", "message": "Force pulse sent to heartbeat and poll loops"} @router.post("/system/briefing/force") async def force_briefing(): """Force the morning briefing to send now (won't block tomorrow's).""" if not _scheduler: return {"status": "error", "message": "Scheduler not available"} _scheduler.force_briefing() return {"status": "ok", "message": "Briefing force-triggered"} @router.post("/cache/clear") async def clear_cache(): """Clear the ClickUp data cache.""" with _cache_lock: _cache.clear() return {"status": "cleared"}