Rename Client field to Customer and refine Overview sections

ClickUp "Client" custom field was deleted; switch all references to
"Customer" across config, tools, tests, and docs. Refine Overview tab:
rename Due Soon to Up Next (today/tomorrow, fallback next 5), expand
This Month to include both month tag and due-date-in-month matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-20 11:20:19 -06:00
parent 0d60b1b516
commit f67f1b9124
5 changed files with 376 additions and 23 deletions

View File

@ -105,7 +105,7 @@ uv add --group test <package>
- **Memory scoping**: Agents with `memory_scope` set use `memory/{scope}/` subdirectory. Empty scope = shared `memory/` root. Fallback search checks both scoped and shared directories.
- **Database**: SQLite with WAL mode, thread-local connections via `threading.local()`
- **KV store**: Task state stored as JSON at `clickup:task:{id}:state` keys
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Client` field (not `Company`) holds the client name.
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Customer` field holds the client name.
- **Notifications**: All scheduler events go through `NotificationBus.push()`, never directly to a UI
- **Tests**: Use `respx` to mock httpx calls, `tmp_db` fixture for isolated SQLite instances
- **ClickUp attachments**: `ClickUpClient.upload_attachment()` uses module-level `httpx.post()` (not the shared client) for multipart uploads
@ -175,7 +175,7 @@ skill_map:
auto_execute: true
field_mapping:
topic: "task_name" # uses ClickUp task name
company_name: "Client" # looks up "Client" custom field
company_name: "Customer" # looks up "Customer" custom field
```
Task lifecycle: `to do` → discovered → approved/awaiting_approval → executing → completed/failed (+ attachments uploaded)

View File

@ -88,7 +88,7 @@ def _find_clickup_task(ctx: dict, company_name: str) -> str:
if task.task_type != "Press Release":
continue
client_field = task.custom_fields.get("Client", "")
client_field = task.custom_fields.get("Customer", "")
if not (
_fuzzy_company_match(company_name, task.name)
or _fuzzy_company_match(company_name, client_field)

View File

@ -53,7 +53,7 @@ clickup:
auto_execute: true
field_mapping:
topic: "task_name"
company_name: "Client"
company_name: "Customer"
target_url: "IMSURL"
branded_url: "SocialURL"
"Link Building":

View File

@ -125,13 +125,13 @@
</div>
</div>
<!-- Due Soon (next 14 days) -->
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#9200;</span> Due Soon</h2>
<span class="section__badge" id="due-soon-count">-</span>
<h2 class="section__title"><span class="icon">&#9200;</span> Up Next</h2>
<span class="section__badge" id="up-next-count">-</span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-due-soon">
<div class="task-table-wrap task-table-wrap--compact" id="overview-up-next">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
@ -393,29 +393,55 @@ async function loadOverview() {
document.getElementById('stat-agents-detail').textContent = 'Configured';
}
// -- Due Soon: tasks due within 14 days --
// -- Up Next: tasks due today/tomorrow, or next 5 by due date --
if (tasks && tasks.tasks) {
const now = Date.now();
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
const dueSoon = tasks.tasks
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const endOfTomorrow = startOfToday + 2 * 24 * 60 * 60 * 1000;
// Only consider open tasks
const openTasks = tasks.tasks.filter(t => {
const s = (t.status || '').toLowerCase();
return !(s.includes('complete') || s.includes('done') || s.includes('closed'));
});
let upNext = openTasks
.filter(t => {
if (!t.due_date) return false;
const due = parseInt(t.due_date, 10);
return due > now && due <= now + fourteenDays;
return due >= startOfToday && due < endOfTomorrow;
})
.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 --
// Fallback: if nothing due today/tomorrow, show next 5 with a due date
if (upNext.length === 0) {
upNext = openTasks
.filter(t => t.due_date && parseInt(t.due_date, 10) >= startOfToday)
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date))
.slice(0, 5);
}
document.getElementById('up-next-count').textContent = upNext.length;
renderOverviewTable('overview-up-next', upNext, true);
// -- This Month: tagged with month tag OR due date this month --
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);
});
const monthTag = monthNames[now.getMonth()] + String(now.getFullYear()).slice(2);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
const thisMonthSet = new Set();
const thisMonth = [];
for (const t of openTasks) {
if (thisMonthSet.has(t.id)) continue;
const hasTag = (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
const dueThisMonth = t.due_date &&
parseInt(t.due_date, 10) >= startOfMonth &&
parseInt(t.due_date, 10) < startOfNextMonth;
if (hasTag || dueThisMonth) {
thisMonth.push(t);
thisMonthSet.add(t.id);
}
}
document.getElementById('this-month-count').textContent = thisMonth.length;
renderOverviewTable('overview-this-month', thisMonth, false);
}

View File

@ -0,0 +1,327 @@
"""Tests for the simplified ClickUp scheduler flow."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from unittest.mock import MagicMock
from cheddahbot.clickup import ClickUpTask
from cheddahbot.scheduler import Scheduler
# ── Fixtures ──
_PR_MAPPING = {
"tool": "write_press_releases",
"auto_execute": True,
"field_mapping": {
"topic": "task_name",
"company_name": "Customer",
},
}
@dataclass
class _FakeClickUpConfig:
api_token: str = "pk_test"
workspace_id: str = "ws_1"
space_id: str = "space_1"
poll_interval_minutes: int = 20
poll_statuses: list[str] = field(default_factory=lambda: ["to do"])
review_status: str = "internal review"
in_progress_status: str = "in progress"
task_type_field_name: str = "Work Category"
default_auto_execute: bool = True
skill_map: dict = field(default_factory=lambda: {"Press Release": _PR_MAPPING})
enabled: bool = True
@dataclass
class _FakeSchedulerConfig:
poll_interval_seconds: int = 60
heartbeat_interval_minutes: int = 30
@dataclass
class _FakeConfig:
clickup: _FakeClickUpConfig = field(default_factory=_FakeClickUpConfig)
scheduler: _FakeSchedulerConfig = field(default_factory=_FakeSchedulerConfig)
identity_dir: str = "identity"
def _make_task(task_id, name, task_type, due_date="", custom_fields=None):
"""Build a ClickUpTask for testing."""
return ClickUpTask(
id=task_id,
name=name,
status="to do",
task_type=task_type,
due_date=due_date,
custom_fields=custom_fields or {},
)
def _now_ms():
return int(datetime.now(UTC).timestamp() * 1000)
_FIELDS = {"Customer": "Acme"}
# ── Tests ──
class TestPollClickup:
"""Test the simplified _poll_clickup method."""
def test_skips_task_with_no_space_id(self, tmp_db):
config = _FakeConfig()
config.clickup.space_id = ""
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
# Should not raise — just log warning and return
scheduler._poll_clickup()
def test_skips_task_with_empty_skill_map(self, tmp_db):
config = _FakeConfig()
config.clickup.skill_map = {}
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
# Should skip without calling any API
scheduler._poll_clickup()
def _make_mock_client(self, tasks=None, field_filter=None):
"""Create a mock ClickUp client with proper return types."""
mock_client = MagicMock()
mock_client.get_tasks_from_space.return_value = tasks or []
mock_client.get_list_ids_from_space.return_value = {"list_1"}
mock_client.discover_field_filter.return_value = field_filter
return mock_client
def test_skips_task_already_completed(self, tmp_db):
"""Tasks with completed state should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
state = {"state": "completed", "clickup_task_id": "t1"}
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
def test_skips_task_already_failed(self, tmp_db):
"""Tasks with failed state should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
state = {"state": "failed", "clickup_task_id": "t1"}
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
def test_skips_task_with_no_due_date(self, tmp_db):
"""Tasks with no due date should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date="",
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
def test_skips_unmapped_task_type(self, tmp_db):
"""Tasks with unmapped task_type should be skipped."""
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"Content task",
"Content Creation",
due_date=due,
)
scheduler._clickup_client = self._make_mock_client(
tasks=[task],
)
scheduler._poll_clickup()
scheduler._clickup_client.update_task_status.assert_not_called()
class TestExecuteTask:
"""Test the simplified _execute_task method."""
def test_success_flow(self, tmp_db):
"""Successful execution: state=completed."""
config = _FakeConfig()
agent = MagicMock()
agent._tools = MagicMock()
agent._tools.execute.return_value = "## ClickUp Sync\nDone"
scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock()
mock_client.update_task_status.return_value = True
scheduler._clickup_client = mock_client
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._execute_task(task)
mock_client.update_task_status.assert_any_call(
"t1",
"in progress",
)
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "completed"
def test_success_fallback_path(self, tmp_db):
"""Scheduler uploads docx and sets review status."""
config = _FakeConfig()
agent = MagicMock()
agent._tools = MagicMock()
agent._tools.execute.return_value = "Press releases done.\n**Docx:** `output/pr.docx`"
scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock()
mock_client.update_task_status.return_value = True
mock_client.upload_attachment.return_value = True
mock_client.add_comment.return_value = True
scheduler._clickup_client = mock_client
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._execute_task(task)
mock_client.update_task_status.assert_any_call(
"t1",
"internal review",
)
mock_client.upload_attachment.assert_called_once_with(
"t1",
"output/pr.docx",
)
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "completed"
assert "output/pr.docx" in state["deliverable_paths"]
def test_failure_flow(self, tmp_db):
"""Failed: state=failed, error comment, back to 'to do'."""
config = _FakeConfig()
agent = MagicMock()
agent._tools = MagicMock()
agent._tools.execute.side_effect = RuntimeError("API timeout")
scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock()
mock_client.update_task_status.return_value = True
mock_client.add_comment.return_value = True
scheduler._clickup_client = mock_client
due = str(_now_ms() + 86400000)
task = _make_task(
"t1",
"PR for Acme",
"Press Release",
due_date=due,
custom_fields=_FIELDS,
)
scheduler._execute_task(task)
mock_client.update_task_status.assert_any_call("t1", "to do")
mock_client.add_comment.assert_called_once()
comment_text = mock_client.add_comment.call_args[0][1]
assert "failed" in comment_text.lower()
raw = tmp_db.kv_get("clickup:task:t1:state")
state = json.loads(raw)
assert state["state"] == "failed"
assert "API timeout" in state["error"]
class TestFieldFilterDiscovery:
"""Test _discover_field_filter caching."""
def test_caches_after_first_call(self, tmp_db):
config = _FakeConfig()
agent = MagicMock()
scheduler = Scheduler(config, tmp_db, agent)
mock_client = MagicMock()
mock_client.get_list_ids_from_space.return_value = {"list_1"}
mock_client.discover_field_filter.return_value = {
"field_id": "cf_wc",
"options": {"Press Release": "opt_pr"},
}
mock_client.get_tasks_from_space.return_value = []
scheduler._clickup_client = mock_client
# First poll should discover
scheduler._poll_clickup()
assert scheduler._field_filter_cache is not None
assert scheduler._field_filter_cache["field_id"] == "cf_wc"
# Second poll reuses cache
mock_client.discover_field_filter.reset_mock()
scheduler._poll_clickup()
mock_client.discover_field_filter.assert_not_called()