From 0d60b1b516832ad00d82150d2a4835d9007fc810 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Fri, 20 Feb 2026 10:59:55 -0600 Subject: [PATCH] Overhaul dashboard to use Overall lists with focused views Switch from get_tasks_from_space (all lists) to get_tasks_from_overall_lists (only "Overall" list per folder) to reduce noise. Add tags and date_done fields to ClickUpTask. Redesign Overview tab with Due Soon, This Month, and Cora Reports Needed sections. Add Recently Completed and In Progress Not Started sections to Link Building tab. Co-Authored-By: Claude Opus 4.6 --- cheddahbot/api.py | 101 +++++++++++++++++++++--- cheddahbot/clickup.py | 93 +++++++++++++++++++++- dashboard/index.html | 174 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 336 insertions(+), 32 deletions(-) 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++;