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 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-20 10:59:55 -06:00
parent 76a0200192
commit 0d60b1b516
3 changed files with 336 additions and 32 deletions

View File

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

View File

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

View File

@ -125,24 +125,35 @@
</div>
</div>
<!-- Top Link Building Tasks -->
<!-- Due Soon (next 14 days) -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128279;</span> Link Building Tasks</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View all</a></span>
<h2 class="section__title"><span class="icon">&#9200;</span> Due Soon</h2>
<span class="section__badge" id="due-soon-count">-</span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-lb-table">
<div class="task-table-wrap task-table-wrap--compact" id="overview-due-soon">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- Top Press Release Tasks -->
<!-- This Month (tagged with current month) -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128240;</span> Press Release Tasks</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="pressreleases">View all</a></span>
<h2 class="section__title"><span class="icon">&#128197;</span> This Month</h2>
<span class="section__badge" id="this-month-count">-</span>
</div>
<div id="overview-pr-cards">
<div class="task-table-wrap task-table-wrap--compact" id="overview-this-month">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- Cora Reports Needed -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128203;</span> Cora Reports Needed</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View LB</a></span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-cora">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
@ -166,6 +177,28 @@
<div class="stats-row" id="lb-stats"></div>
<!-- Recently Completed (Past 7 Days) -->
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#9989;</span> Recently Completed (Past 7 Days)</h2>
<span class="section__badge" id="lb-recent-count">-</span>
</div>
<div class="task-table-wrap" id="lb-recent-table">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- In Progress - Not Started -->
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#9888;</span> In Progress &mdash; Not Started</h2>
<span class="section__badge" id="lb-not-started-count">-</span>
</div>
<div class="task-table-wrap" id="lb-not-started-table">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- Company Breakdown -->
<div class="section">
<div class="section__header">
@ -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) {
</tr></thead><tbody>`;
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 ? `<a href="${esc(t.url)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(t.name)}</a>` : esc(t.name);
html += `<tr>
@ -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 = '<p style="padding:1rem;color:var(--text-muted);">No recently completed tasks.</p>';
return;
}
let html = `<table class="task-table">
<thead><tr>
<th>#</th><th>Task</th><th>Company</th><th>Keyword</th><th>Completed</th>
</tr></thead><tbody>`;
tasks.forEach((t, i) => {
const company = t.custom_fields?.Customer || 'Unassigned';
const keyword = t.custom_fields?.Keyword || '';
const link = t.url ? `<a href="${esc(t.url)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(t.name)}</a>` : 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 += `<tr>
<td class="task-table__num">${i + 1}</td>
<td class="task-table__title">${link}</td>
<td class="task-table__company">${esc(company)}</td>
<td class="task-table__keyword">${esc(keyword)}</td>
<td style="white-space:nowrap;">${esc(doneDate)}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
function renderOverviewTable(containerId, tasks, showDueDate) {
const container = document.getElementById(containerId);
if (!tasks || tasks.length === 0) {
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">None right now.</p>';
return;
}
const dueDateHeader = showDueDate ? '<th>Due</th>' : '';
let html = `<table class="task-table task-table--dense">
<thead><tr>
<th>#</th><th>Task</th><th>Company</th><th>Type</th><th>Status</th>${dueDateHeader}
</tr></thead><tbody>`;
tasks.forEach((t, i) => {
const company = t.custom_fields?.Customer || 'Unassigned';
const link = t.url ? `<a href="${esc(t.url)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(t.name)}</a>` : esc(t.name);
let dueDateCell = '';
if (showDueDate && t.due_date) {
const d = new Date(parseInt(t.due_date, 10));
dueDateCell = `<td style="white-space:nowrap;">${d.toLocaleDateString('en-US', {month:'short', day:'numeric'})}</td>`;
} else if (showDueDate) {
dueDateCell = '<td>-</td>';
}
html += `<tr>
<td class="task-table__num">${i + 1}</td>
<td class="task-table__title">${link}</td>
<td class="task-table__company">${esc(company)}</td>
<td class="task-table__company">${esc(t.task_type || '-')}</td>
<td class="task-table__status">${statusPill(t.status)}</td>
${dueDateCell}
</tr>`;
});
html += '</tbody></table>';
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 ? ` <a href="${esc(t.url)}" target="_blank" style="color:var(--gold-light);font-size:0.75rem;">[ClickUp]</a>` : '';
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++;