diff --git a/cheddahbot/api.py b/cheddahbot/api.py index 6b56337..c3bbd98 100644 --- a/cheddahbot/api.py +++ b/cheddahbot/api.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from .agent_registry import AgentRegistry from .config import Config from .db import Database + from .scheduler import Scheduler log = logging.getLogger(__name__) @@ -30,6 +31,7 @@ router = APIRouter(prefix="/api") _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] = {} @@ -38,13 +40,17 @@ CACHE_TTL = 300 # 5 minutes def create_api_router( - config: Config, db: Database, registry: AgentRegistry + config: Config, + db: Database, + registry: AgentRegistry, + scheduler: Scheduler | None = None, ) -> APIRouter: """Wire dependencies and return the router.""" - global _config, _db, _registry + global _config, _db, _registry, _scheduler _config = config _db = db _registry = registry + _scheduler = scheduler return router @@ -251,6 +257,96 @@ async def get_link_building_tasks(): 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("Customer") 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.""" @@ -448,6 +544,24 @@ async def get_kv_states(): 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("/cache/clear") async def clear_cache(): """Clear the ClickUp data cache.""" diff --git a/dashboard/index.html b/dashboard/index.html index 214a90f..b9b31a8 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -125,6 +125,18 @@ + +
Loading...
+Loading...
-No Cora reports needed.
'; + return; + } + + const PAGE_SIZE = 10; + let showAll = false; + + const TYPE_BADGES = { + 'Link Building': { label: 'LB', color: '#3b82f6' }, + 'On Page Optimization': { label: 'OPO', color: '#f59e0b' }, + 'Content Creation': { label: 'Content', color: '#10b981' }, + }; + + function render() { + const visible = showAll ? keywords : keywords.slice(0, PAGE_SIZE); + let html = `| Keyword | +Company | +Types | + +
|---|---|---|
| ${kwHtml}${countNote} | +${esc(k.company)} | +${badges} | + +