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 @@ - +
Loading...
Loading...
+Loading...
Loading...
+Loading...
+No recently completed tasks.
'; + return; + } + + let html = `| # | Task | Company | Keyword | Completed | +
|---|---|---|---|---|
| ${i + 1} | +${link} | +${esc(company)} | +${esc(keyword)} | +${esc(doneDate)} | +
None right now.
'; + return; + } + + const dueDateHeader = showDueDate ? '| # | Task | Company | Type | Status | ${dueDateHeader} +${d.toLocaleDateString('en-US', {month:'short', day:'numeric'})} | `; + } else if (showDueDate) { + dueDateCell = '- | '; + } + html += `
|---|---|---|---|---|
| ${i + 1} | +${link} | +${esc(company)} | +${esc(t.task_type || '-')} | +${statusPill(t.status)} | + ${dueDateCell} +