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 @@ + +
+
+

📋 Cora Reports Needed

+ Z:\cora-inbox + View LB +
+
+

Loading...

+
+
+
@@ -147,18 +159,6 @@
- -
-
-

📋 Cora Reports Needed

- Z:\cora-inbox - View LB -
-
-

Loading...

-
-
-
@@ -472,12 +472,12 @@ async function loadOverview() { document.getElementById('this-month-count').textContent = thisMonth.length; renderOverviewTable('overview-this-month', thisMonth, true); - // -- Cora Reports Needed: LB tasks with LB Method = Cora Backlinks -- - const needCora = openTasks.filter(t => - t.task_type === 'Link Building' - && t.custom_fields?.['LB Method'] === 'Cora Backlinks' - ); - renderOverviewTable('overview-cora', needCora, false); + // -- Cora Reports Needed: fetch from dedicated endpoint (all work categories, deduplicated) -- + fetchJSON('/tasks/need-cora').then(coraData => { + if (coraData && coraData.keywords) { + renderCoraKeywords('overview-cora', coraData.keywords); + } + }); } // Health inline @@ -616,6 +616,91 @@ function renderCoraQueue(containerId, tasks) { render(); } +function renderCoraKeywords(containerId, keywords) { + const container = document.getElementById(containerId); + if (!keywords || keywords.length === 0) { + container.innerHTML = '

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 = ` + + + + + + + `; + + visible.forEach((k, i) => { + // Build type badges from the task list + const types = [...new Set(k.tasks.map(t => t.task_type))]; + const badges = types.map(tp => { + const b = TYPE_BADGES[tp] || { label: tp, color: '#888' }; + return `${b.label}`; + }).join(''); + + // Link the keyword to the first task's ClickUp URL + const firstUrl = k.tasks.find(t => t.url)?.url; + const kwHtml = firstUrl + ? `${esc(k.keyword)}` + : esc(k.keyword); + + let dueStr = '-'; + if (k.due_date) { + const d = new Date(parseInt(k.due_date, 10)); + dueStr = d.toLocaleDateString('en-US', {month:'short', day:'numeric'}); + } + + // Show task count if more than 1 task shares this keyword + const countNote = k.tasks.length > 1 + ? ` (${k.tasks.length} tasks)` + : ''; + + html += ` + + + + + + `; + }); + + html += '
#KeywordCompanyTypesDue Date
${i + 1}${kwHtml}${countNote}${esc(k.company)}${badges}${esc(dueStr)}
'; + + if (keywords.length > PAGE_SIZE) { + const remaining = keywords.length - PAGE_SIZE; + const label = showAll ? 'Show less' : `Show more (${remaining} remaining)`; + html += `
+ +
`; + } + + container.innerHTML = html; + + const btn = document.getElementById('cora-kw-toggle-btn'); + if (btn) { + btn.addEventListener('click', () => { + showAll = !showAll; + render(); + }); + } + } + + render(); +} + function renderOverviewTable(containerId, tasks, showDueDate) { const container = document.getElementById(containerId); if (!tasks || tasks.length === 0) { @@ -733,10 +818,13 @@ async function loadLinkBuilding() { `; document.getElementById('lb-stats').innerHTML = statsHtml; - // Cora Reports to Run - const coraQueue = data.need_cora || []; - document.getElementById('lb-cora-count').textContent = coraQueue.length; - renderCoraQueue('lb-cora-table', coraQueue); + // Cora Reports to Run (combined view from dedicated endpoint) + fetchJSON('/tasks/need-cora').then(coraData => { + if (coraData && coraData.keywords) { + document.getElementById('lb-cora-count').textContent = coraData.count || 0; + renderCoraKeywords('lb-cora-table', coraData.keywords); + } + }); // Recently Completed const recent = data.recently_completed || [];