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() client = _get_clickup_client()
try: 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 = [] tasks = []
for t in raw_tasks: for t in raw_tasks:
tasks.append( tasks.append(
@ -95,7 +95,9 @@ async def get_tasks():
"task_type": t.task_type, "task_type": t.task_type,
"url": t.url, "url": t.url,
"due_date": t.due_date, "due_date": t.due_date,
"date_done": t.date_done,
"list_name": t.list_name, "list_name": t.list_name,
"tags": t.tags,
"custom_fields": t.custom_fields, "custom_fields": t.custom_fields,
} }
) )
@ -115,7 +117,7 @@ async def get_tasks_by_company():
data = await get_tasks() data = await get_tasks()
by_company: dict[str, list] = {} by_company: dict[str, list] = {}
for task in data.get("tasks", []): 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) by_company.setdefault(company, []).append(task)
# Sort companies by task count descending # Sort companies by task count descending
@ -131,8 +133,41 @@ async def get_tasks_by_company():
@router.get("/tasks/link-building") @router.get("/tasks/link-building")
async def get_link_building_tasks(): async def get_link_building_tasks():
"""Link building tasks with KV state merged in.""" """Link building tasks with KV state merged in."""
data = await get_tasks() cached = _get_cached("lb_tasks")
lb_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Link Building"] 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 # Merge KV state
if _db: if _db:
@ -147,20 +182,64 @@ async def get_link_building_tasks():
else: else:
task["kv_state"] = None 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] = {} by_company: dict[str, list] = {}
for task in lb_tasks: for task in active_lb:
company = task["custom_fields"].get("Client") or "Unassigned" company = task["custom_fields"].get("Customer") or "Unassigned"
by_company.setdefault(company, []).append(task) by_company.setdefault(company, []).append(task)
return { result = {
"total": len(lb_tasks), "total": len(active_lb),
"need_cora": need_cora,
"recently_completed": recently_completed,
"in_progress_not_started": in_progress_not_started,
"companies": [ "companies": [
{"name": name, "tasks": tasks, "count": len(tasks)} {"name": name, "tasks": tasks, "count": len(tasks)}
for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) 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") @router.get("/tasks/press-releases")
@ -183,7 +262,7 @@ async def get_press_release_tasks():
by_company: dict[str, list] = {} by_company: dict[str, list] = {}
for task in pr_tasks: 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) by_company.setdefault(company, []).append(task)
return { return {

View File

@ -29,6 +29,8 @@ class ClickUpTask:
custom_fields: dict[str, Any] = field(default_factory=dict) custom_fields: dict[str, Any] = field(default_factory=dict)
list_id: str = "" list_id: str = ""
list_name: str = "" list_name: str = ""
tags: list[str] = field(default_factory=list)
date_done: str = ""
@classmethod @classmethod
def from_api(cls, data: dict, task_type_field_name: str = "Task Type") -> ClickUpTask: 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") raw_due = data.get("due_date")
due_date = str(raw_due) if raw_due else "" 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( return cls(
id=data["id"], id=data["id"],
name=data.get("name", ""), name=data.get("name", ""),
@ -71,6 +78,8 @@ class ClickUpTask:
custom_fields=custom_fields, custom_fields=custom_fields,
list_id=data.get("list", {}).get("id", ""), list_id=data.get("list", {}).get("id", ""),
list_name=data.get("list", {}).get("name", ""), list_name=data.get("list", {}).get("name", ""),
tags=tags,
date_done=date_done,
) )
@ -100,9 +109,13 @@ class ClickUpClient:
statuses: list[str] | None = None, statuses: list[str] | None = None,
due_date_lt: int | None = None, due_date_lt: int | None = None,
custom_fields: str | None = None, custom_fields: str | None = None,
include_closed: bool = False,
) -> list[ClickUpTask]: ) -> list[ClickUpTask]:
"""Fetch tasks from a specific list, optionally filtered by status/due date/fields.""" """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: if statuses:
for s in statuses: for s in statuses:
params.setdefault("statuses[]", []) params.setdefault("statuses[]", [])
@ -170,6 +183,49 @@ class ClickUpClient:
) )
return all_tasks 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) ── # ── Write (with retry) ──
@staticmethod @staticmethod
@ -282,6 +338,41 @@ class ClickUpClient:
return list_ids 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]: def get_custom_fields(self, list_id: str) -> list[dict]:
"""Get custom fields for a list.""" """Get custom fields for a list."""
try: try:

View File

@ -125,24 +125,35 @@
</div> </div>
</div> </div>
<!-- Top Link Building Tasks --> <!-- Due Soon (next 14 days) -->
<div class="section section--tight"> <div class="section section--tight">
<div class="section__header"> <div class="section__header">
<h2 class="section__title"><span class="icon">&#128279;</span> Link Building Tasks</h2> <h2 class="section__title"><span class="icon">&#9200;</span> Due Soon</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View all</a></span> <span class="section__badge" id="due-soon-count">-</span>
</div> </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> <p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div> </div>
</div> </div>
<!-- Top Press Release Tasks --> <!-- This Month (tagged with current month) -->
<div class="section section--tight"> <div class="section section--tight">
<div class="section__header"> <div class="section__header">
<h2 class="section__title"><span class="icon">&#128240;</span> Press Release Tasks</h2> <h2 class="section__title"><span class="icon">&#128197;</span> This Month</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="pressreleases">View all</a></span> <span class="section__badge" id="this-month-count">-</span>
</div> </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> <p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div> </div>
</div> </div>
@ -166,6 +177,28 @@
<div class="stats-row" id="lb-stats"></div> <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 --> <!-- Company Breakdown -->
<div class="section"> <div class="section">
<div class="section__header"> <div class="section__header">
@ -350,7 +383,7 @@ async function loadOverview() {
document.getElementById('badge-pr').textContent = pr.total || 0; document.getElementById('badge-pr').textContent = pr.total || 0;
} }
if (tasks && tasks.tasks) { 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; document.getElementById('stat-companies').textContent = companies.size;
const names = [...companies].filter(c => c !== 'Unassigned').slice(0, 4).join(', '); const names = [...companies].filter(c => c !== 'Unassigned').slice(0, 4).join(', ');
document.getElementById('stat-companies-detail').textContent = names || 'None'; document.getElementById('stat-companies-detail').textContent = names || 'None';
@ -360,16 +393,36 @@ async function loadOverview() {
document.getElementById('stat-agents-detail').textContent = 'Configured'; document.getElementById('stat-agents-detail').textContent = 'Configured';
} }
// Top LB tasks (first 5) // -- Due Soon: tasks due within 14 days --
if (lb && lb.companies) { if (tasks && tasks.tasks) {
const allTasks = lb.companies.flatMap(c => c.tasks); const now = Date.now();
renderTaskTable('overview-lb-table', allTasks.slice(0, 8), true); 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 // -- Cora Reports Needed --
if (pr && pr.companies) { if (lb && lb.need_cora) {
const allPR = pr.companies.flatMap(c => c.tasks); renderOverviewTable('overview-cora', lb.need_cora, false);
renderPRCards('overview-pr-cards', allPR.slice(0, 3));
} }
// Health inline // Health inline
@ -395,7 +448,7 @@ function renderTaskTable(containerId, tasks, compact) {
</tr></thead><tbody>`; </tr></thead><tbody>`;
tasks.forEach((t, i) => { 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 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); 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> html += `<tr>
@ -411,6 +464,77 @@ function renderTaskTable(containerId, tasks, compact) {
container.innerHTML = html; 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) { function renderPRCards(containerId, tasks) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!tasks || tasks.length === 0) { if (!tasks || tasks.length === 0) {
@ -420,7 +544,7 @@ function renderPRCards(containerId, tasks) {
let html = ''; let html = '';
tasks.forEach(t => { 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 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; const kvState = t.kv_state;
let stateInfo = ''; let stateInfo = '';
@ -495,6 +619,16 @@ async function loadLinkBuilding() {
`; `;
document.getElementById('lb-stats').innerHTML = statsHtml; 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 // Company breakdown
const grid = document.getElementById('lb-company-grid'); const grid = document.getElementById('lb-company-grid');
if (data.companies && data.companies.length > 0) { if (data.companies && data.companies.length > 0) {
@ -619,7 +753,7 @@ async function loadByCompany() {
function groupByCompany(tasks) { function groupByCompany(tasks) {
const map = {}; const map = {};
tasks.forEach(t => { 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 }; if (!map[co]) map[co] = { name: co, tasks: [], count: 0 };
map[co].tasks.push(t); map[co].tasks.push(t);
map[co].count++; map[co].count++;