From f67f1b9124d53aab2436bb329920a5cf745b0045 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Fri, 20 Feb 2026 11:20:19 -0600 Subject: [PATCH] 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 --- CLAUDE.md | 4 +- cheddahbot/tools/press_release.py | 2 +- config.yaml | 2 +- dashboard/index.html | 64 ++++-- tests/test_scheduler.py | 327 ++++++++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/CLAUDE.md b/CLAUDE.md index 6b05849..5a7735c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ uv add --group test - **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) diff --git a/cheddahbot/tools/press_release.py b/cheddahbot/tools/press_release.py index 23f51bc..921fbc2 100644 --- a/cheddahbot/tools/press_release.py +++ b/cheddahbot/tools/press_release.py @@ -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) diff --git a/config.yaml b/config.yaml index 1eb03b4..21435fb 100644 --- a/config.yaml +++ b/config.yaml @@ -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": diff --git a/dashboard/index.html b/dashboard/index.html index 4fdeb91..042d6d7 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -125,13 +125,13 @@ - +
-

Due Soon

- - +

Up Next

+ -
-
+

Loading...

@@ -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); } diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..1df3c8e --- /dev/null +++ b/tests/test_scheduler.py @@ -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()