diff --git a/cheddahbot/api.py b/cheddahbot/api.py index 7b2c354..7489261 100644 --- a/cheddahbot/api.py +++ b/cheddahbot/api.py @@ -84,7 +84,7 @@ async def get_tasks(): client = _get_clickup_client() try: - raw_tasks = client.get_tasks_from_space(_config.clickup.space_id) + raw_tasks = client.get_tasks_from_overall_lists(_config.clickup.space_id) tasks = [] for t in raw_tasks: tasks.append( @@ -95,7 +95,9 @@ async def get_tasks(): "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, } ) @@ -115,7 +117,7 @@ async def get_tasks_by_company(): data = await get_tasks() by_company: dict[str, list] = {} for task in data.get("tasks", []): - company = task["custom_fields"].get("Client") or "Unassigned" + company = task["custom_fields"].get("Customer") or "Unassigned" by_company.setdefault(company, []).append(task) # Sort companies by task count descending @@ -131,8 +133,41 @@ async def get_tasks_by_company(): @router.get("/tasks/link-building") async def get_link_building_tasks(): """Link building tasks with KV state merged in.""" - data = await get_tasks() - lb_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Link Building"] + 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: @@ -147,20 +182,64 @@ async def get_link_building_tasks(): else: task["kv_state"] = None - # Group by company + # -- Build focused groups -- + + # need_cora: status "to do" AND LB Method = "Cora Backlinks" + need_cora = [ + t for t in lb_tasks + if t["status"] == "to do" + and t["custom_fields"].get("LB Method") == "Cora Backlinks" + ] + + # 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) + + # Group by company (exclude closed from the active list for the grid) + 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) + ] by_company: dict[str, list] = {} - for task in lb_tasks: - company = task["custom_fields"].get("Client") or "Unassigned" + for task in active_lb: + company = task["custom_fields"].get("Customer") or "Unassigned" by_company.setdefault(company, []).append(task) - return { - "total": len(lb_tasks), + 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_statuses(lb_tasks), + "status_counts": _count_statuses(active_lb), } + _set_cached("lb_tasks", result) + return result @router.get("/tasks/press-releases") @@ -183,7 +262,7 @@ async def get_press_release_tasks(): by_company: dict[str, list] = {} for task in pr_tasks: - company = task["custom_fields"].get("Client") or "Unassigned" + company = task["custom_fields"].get("Customer") or "Unassigned" by_company.setdefault(company, []).append(task) return { diff --git a/cheddahbot/clickup.py b/cheddahbot/clickup.py index 8e03311..181461f 100644 --- a/cheddahbot/clickup.py +++ b/cheddahbot/clickup.py @@ -29,6 +29,8 @@ class ClickUpTask: custom_fields: dict[str, Any] = field(default_factory=dict) list_id: str = "" list_name: str = "" + tags: list[str] = field(default_factory=list) + date_done: str = "" @classmethod def from_api(cls, data: dict, task_type_field_name: str = "Task Type") -> ClickUpTask: @@ -60,6 +62,11 @@ class ClickUpTask: raw_due = data.get("due_date") due_date = str(raw_due) if raw_due else "" + tags = [tag["name"] for tag in data.get("tags", [])] + + raw_done = data.get("date_done") or data.get("date_closed") + date_done = str(raw_done) if raw_done else "" + return cls( id=data["id"], name=data.get("name", ""), @@ -71,6 +78,8 @@ class ClickUpTask: custom_fields=custom_fields, list_id=data.get("list", {}).get("id", ""), list_name=data.get("list", {}).get("name", ""), + tags=tags, + date_done=date_done, ) @@ -100,9 +109,13 @@ class ClickUpClient: statuses: list[str] | None = None, due_date_lt: int | None = None, custom_fields: str | None = None, + include_closed: bool = False, ) -> list[ClickUpTask]: """Fetch tasks from a specific list, optionally filtered by status/due date/fields.""" - params: dict[str, Any] = {"include_closed": "false", "subtasks": "true"} + params: dict[str, Any] = { + "include_closed": "true" if include_closed else "false", + "subtasks": "true", + } if statuses: for s in statuses: params.setdefault("statuses[]", []) @@ -170,6 +183,49 @@ class ClickUpClient: ) return all_tasks + def get_tasks_from_overall_lists( + self, + space_id: str, + statuses: list[str] | None = None, + due_date_lt: int | None = None, + custom_fields: str | None = None, + include_closed: bool = False, + ) -> list[ClickUpTask]: + """Fetch tasks only from 'Overall' lists in each folder. + + This is the dashboard-specific alternative to get_tasks_from_space, + which hits every list and returns too much noise. + """ + all_tasks: list[ClickUpTask] = [] + overall_ids: list[str] = [] + + try: + folders = self.get_folders(space_id) + for folder in folders: + for lst in folder["lists"]: + if lst["name"].lower() == "overall": + overall_ids.append(lst["id"]) + except httpx.HTTPStatusError as e: + log.warning("Failed to fetch folders for space %s: %s", space_id, e) + return [] + + for list_id in overall_ids: + try: + tasks = self.get_tasks( + list_id, statuses, due_date_lt, custom_fields, include_closed + ) + all_tasks.extend(tasks) + except httpx.HTTPStatusError as e: + log.warning("Failed to fetch tasks from list %s: %s", list_id, e) + + log.info( + "Found %d tasks across %d Overall lists in space %s", + len(all_tasks), + len(overall_ids), + space_id, + ) + return all_tasks + # ── Write (with retry) ── @staticmethod @@ -282,6 +338,41 @@ class ClickUpClient: return list_ids + def get_folders(self, space_id: str) -> list[dict]: + """Return folders in a space with their lists. + + Each dict has keys: id, name, lists (list of {id, name}). + """ + resp = self._client.get(f"/space/{space_id}/folder") + resp.raise_for_status() + folders = [] + for f in resp.json().get("folders", []): + lists = [{"id": lst["id"], "name": lst["name"]} for lst in f.get("lists", [])] + folders.append({"id": f["id"], "name": f["name"], "lists": lists}) + return folders + + def set_custom_field_value(self, task_id: str, field_id: str, value: Any) -> bool: + """Set a custom field value on a task. + + For dropdowns, *value* should be the option UUID string. + """ + try: + + def _call(): + resp = self._client.post( + f"/task/{task_id}/field/{field_id}", + json={"value": value}, + ) + resp.raise_for_status() + return resp + + self._retry(_call) + log.info("Set field %s on task %s", field_id, task_id) + return True + except (httpx.TransportError, httpx.HTTPStatusError) as e: + log.error("Failed to set field %s on task %s: %s", field_id, task_id, e) + return False + def get_custom_fields(self, list_id: str) -> list[dict]: """Get custom fields for a list.""" try: diff --git a/dashboard/index.html b/dashboard/index.html index 6639ef7..4fdeb91 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -125,24 +125,35 @@ - +
-

🔗 Link Building Tasks

- View all +

Due Soon

+ -
-
+

Loading...

- +
-

📰 Press Release Tasks

- View all +

📅 This Month

+ -
-
+
+

Loading...

+
+
+ + +
+
+

📋 Cora Reports Needed

+ View LB +
+

Loading...

@@ -166,6 +177,28 @@
+ +
+
+

Recently Completed (Past 7 Days)

+ - +
+
+

Loading...

+
+
+ + +
+
+

In Progress — Not Started

+ - +
+
+

Loading...

+
+
+
@@ -350,7 +383,7 @@ async function loadOverview() { document.getElementById('badge-pr').textContent = pr.total || 0; } if (tasks && tasks.tasks) { - const companies = new Set(tasks.tasks.map(t => t.custom_fields?.Client || 'Unassigned')); + const companies = new Set(tasks.tasks.map(t => t.custom_fields?.Customer || 'Unassigned')); document.getElementById('stat-companies').textContent = companies.size; const names = [...companies].filter(c => c !== 'Unassigned').slice(0, 4).join(', '); document.getElementById('stat-companies-detail').textContent = names || 'None'; @@ -360,16 +393,36 @@ async function loadOverview() { document.getElementById('stat-agents-detail').textContent = 'Configured'; } - // Top LB tasks (first 5) - if (lb && lb.companies) { - const allTasks = lb.companies.flatMap(c => c.tasks); - renderTaskTable('overview-lb-table', allTasks.slice(0, 8), true); + // -- Due Soon: tasks due within 14 days -- + if (tasks && tasks.tasks) { + const now = Date.now(); + const fourteenDays = 14 * 24 * 60 * 60 * 1000; + const dueSoon = tasks.tasks + .filter(t => { + if (!t.due_date) return false; + const due = parseInt(t.due_date, 10); + return due > now && due <= now + fourteenDays; + }) + .sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date)); + const dueSoonIds = new Set(dueSoon.map(t => t.id)); + document.getElementById('due-soon-count').textContent = dueSoon.length; + renderOverviewTable('overview-due-soon', dueSoon, true); + + // -- This Month: tasks tagged with current month tag, excluding due-soon -- + const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; + const d = new Date(); + const monthTag = monthNames[d.getMonth()] + String(d.getFullYear()).slice(2); + const thisMonth = tasks.tasks.filter(t => { + if (dueSoonIds.has(t.id)) return false; + return (t.tags || []).some(tag => tag.toLowerCase() === monthTag); + }); + document.getElementById('this-month-count').textContent = thisMonth.length; + renderOverviewTable('overview-this-month', thisMonth, false); } - // Top PR tasks - if (pr && pr.companies) { - const allPR = pr.companies.flatMap(c => c.tasks); - renderPRCards('overview-pr-cards', allPR.slice(0, 3)); + // -- Cora Reports Needed -- + if (lb && lb.need_cora) { + renderOverviewTable('overview-cora', lb.need_cora, false); } // Health inline @@ -395,7 +448,7 @@ function renderTaskTable(containerId, tasks, compact) { `; tasks.forEach((t, i) => { - const company = t.custom_fields?.Client || 'Unassigned'; + const company = t.custom_fields?.Customer || 'Unassigned'; const keyword = t.custom_fields?.Keyword || ''; const link = t.url ? `${esc(t.name)}` : esc(t.name); html += ` @@ -411,6 +464,77 @@ function renderTaskTable(containerId, tasks, compact) { container.innerHTML = html; } +function renderRecentTable(containerId, tasks) { + const container = document.getElementById(containerId); + if (!tasks || tasks.length === 0) { + container.innerHTML = '

No recently completed tasks.

'; + return; + } + + let html = ` + + + `; + + tasks.forEach((t, i) => { + const company = t.custom_fields?.Customer || 'Unassigned'; + const keyword = t.custom_fields?.Keyword || ''; + const link = t.url ? `${esc(t.name)}` : esc(t.name); + let doneDate = '-'; + if (t.date_done) { + const d = new Date(parseInt(t.date_done, 10)); + doneDate = d.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + } + html += ` + + + + + + `; + }); + + html += '
#TaskCompanyKeywordCompleted
${i + 1}${link}${esc(company)}${esc(keyword)}${esc(doneDate)}
'; + container.innerHTML = html; +} + +function renderOverviewTable(containerId, tasks, showDueDate) { + const container = document.getElementById(containerId); + if (!tasks || tasks.length === 0) { + container.innerHTML = '

None right now.

'; + return; + } + + const dueDateHeader = showDueDate ? 'Due' : ''; + let html = ` + + ${dueDateHeader} + `; + + tasks.forEach((t, i) => { + const company = t.custom_fields?.Customer || 'Unassigned'; + const link = t.url ? `${esc(t.name)}` : esc(t.name); + let dueDateCell = ''; + if (showDueDate && t.due_date) { + const d = new Date(parseInt(t.due_date, 10)); + dueDateCell = ``; + } else if (showDueDate) { + dueDateCell = ''; + } + html += ` + + + + + + ${dueDateCell} + `; + }); + + html += '
#TaskCompanyTypeStatus
${d.toLocaleDateString('en-US', {month:'short', day:'numeric'})}-
${i + 1}${link}${esc(company)}${esc(t.task_type || '-')}${statusPill(t.status)}
'; + container.innerHTML = html; +} + function renderPRCards(containerId, tasks) { const container = document.getElementById(containerId); if (!tasks || tasks.length === 0) { @@ -420,7 +544,7 @@ function renderPRCards(containerId, tasks) { let html = ''; tasks.forEach(t => { - const company = t.custom_fields?.Client || 'Unassigned'; + const company = t.custom_fields?.Customer || 'Unassigned'; const link = t.url ? ` [ClickUp]` : ''; const kvState = t.kv_state; let stateInfo = ''; @@ -495,6 +619,16 @@ async function loadLinkBuilding() { `; document.getElementById('lb-stats').innerHTML = statsHtml; + // Recently Completed + const recent = data.recently_completed || []; + document.getElementById('lb-recent-count').textContent = recent.length; + renderRecentTable('lb-recent-table', recent); + + // In Progress - Not Started + const notStarted = data.in_progress_not_started || []; + document.getElementById('lb-not-started-count').textContent = notStarted.length; + renderTaskTable('lb-not-started-table', notStarted, false); + // Company breakdown const grid = document.getElementById('lb-company-grid'); if (data.companies && data.companies.length > 0) { @@ -619,7 +753,7 @@ async function loadByCompany() { function groupByCompany(tasks) { const map = {}; tasks.forEach(t => { - const co = t.custom_fields?.Client || 'Unassigned'; + const co = t.custom_fields?.Customer || 'Unassigned'; if (!map[co]) map[co] = { name: co, tasks: [], count: 0 }; map[co].tasks.push(t); map[co].count++;