diff --git a/frontend/my-work.js b/frontend/my-work.js index a75f3cf..10d354a 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -14,6 +14,25 @@ function issueDueState(dueDate, now) { }; } +function urgencyReason(item) { + if (!item?.is_assigned || item.kind !== 'issue') return ''; + const priorityLabels = ['p0', 'priority-high', 'critical']; + const priorityLabel = (item.labels || []).find(label => + priorityLabels.includes(String(label).toLowerCase()) + ); + if (priorityLabel) return priorityLabel + ' priority'; + return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : ''; +} + +function attentionReason(item) { + if (item?.has_update) return 'Unread update'; + return urgencyReason(item) || (item?.is_review ? 'Needs your review' : ''); +} + +function needsAttention(item) { + return Boolean(item && (item.has_update || item.is_review || urgencyReason(item))); +} + function buildMyWork(data, now = new Date()) { const login = data.user?.login || ''; const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' })); @@ -28,7 +47,7 @@ function buildMyWork(data, now = new Date()) { const assigned = (item.assignees || []).includes(login); const isReview = (item.work_reasons || []).includes('review_requested'); const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null; - return { + const normalized = { ...item, key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, @@ -41,6 +60,9 @@ function buildMyWork(data, now = new Date()) { _priority: priorityLabel ? 0 : (due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))), }; + normalized.needs_attention = needsAttention(normalized); + normalized.attention_reason = attentionReason(normalized); + return normalized; }); const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item])); @@ -50,6 +72,8 @@ function buildMyWork(data, now = new Date()) { const existing = byKey.get(subjectKind + ':' + key); if (existing) { existing.has_update = true; + existing.needs_attention = true; + existing.attention_reason = 'Unread update'; existing.notification_id = update.id; existing.url = update.url || existing.url; existing.updated_at = update.updated_at || existing.updated_at; @@ -64,6 +88,8 @@ function buildMyWork(data, now = new Date()) { is_review: false, is_assigned: false, has_update: true, + needs_attention: true, + attention_reason: 'Unread update', notification_id: update.id, reason: 'Unread update', _priority: 1, @@ -84,7 +110,10 @@ function acknowledgeNotification(items, notificationId) { if (item.notification_id !== notificationId) return [item]; if (item.kind === 'update') return []; const { notification_id, ...acknowledged } = item; - return [{ ...acknowledged, has_update: false }]; + const next = { ...acknowledged, has_update: false }; + if ('needs_attention' in item) next.needs_attention = needsAttention(next); + if ('attention_reason' in item) next.attention_reason = attentionReason(next); + return [next]; }); } @@ -405,7 +434,7 @@ function createNotificationReplier({ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { let filtered = items; - if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review); + if (selectedFilter === 'attention') filtered = items.filter(needsAttention); else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review); else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update); else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter); @@ -565,7 +594,7 @@ function summarizeMyWork(items) { function countMyWork(items) { return { all: items.length, - attention: items.filter((item) => item.has_update || item.is_review).length, + attention: items.filter(needsAttention).length, issue: items.filter((item) => item.kind === 'issue').length, pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length, review: items.filter((item) => item.is_review).length, diff --git a/frontend/service-worker.js b/frontend/service-worker.js index ceefae1..a643139 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v18'; +const CACHE = 'stackchain-dashboard-shell-v19'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 6608f60..6910fbb 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -313,6 +313,58 @@ process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason ] +def test_attention_includes_only_assigned_deadline_and_priority_critical_work(): + payload = { + "user": {"login": "timmy"}, + "issues": [ + {"number": 1, "title": "Assigned P0", "repository": "stackchain/api", + "labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"}, + {"number": 2, "title": "Assigned overdue", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"}, + {"number": 3, "title": "Assigned due today", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"}, + {"number": 4, "title": "Assigned future", "repository": "stackchain/api", + "labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"}, + {"number": 5, "title": "Unassigned P0", "repository": "stackchain/api", + "labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"}, + {"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api", + "labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"}, + ], + "pull_requests": [ + {"number": 7, "title": "Updated review", "repository": "stackchain/web", + "labels": [], "assignees": [], "work_reasons": ["review_requested"]}, + ], + "notifications": [ + {"id": 70, "number": 7, "repository": "stackchain/web", "unread": True, + "subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"}, + ], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z')); +const attention = buildMyWork.filterMyWork(items, 'attention'); +process.stdout.write(JSON.stringify({{ + titles: attention.map(item => item.title), + count: buildMyWork.countMyWork(items).attention, + reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])), +}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"], + "count": 4, + "reasons": { + "Assigned P0": "P0 priority", + "Updated review": "Unread update", + "Assigned overdue": "Overdue", + "Assigned due today": "Due today", + }, + } + + def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue(): payload = { "user": {"login": "timmy"}, diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index affb17f..bb5003c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_session_resume_flow_ships_in_a_new_shell_cache(): +def test_deadline_attention_flow_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v18" in source + assert "stackchain-dashboard-shell-v19" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():