From cc38b3d0f64f2881f652acd999e8093c1f0f092b Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 07:58:49 +0000 Subject: [PATCH] feat: add ordered mobile Today queue (#279) --- frontend/index.html | 66 ++++++++++++++++++++----- frontend/service-worker.js | 3 +- frontend/today-work.js | 91 ++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 3 +- tests/test_service_worker.py | 6 ++- tests/test_today_work.py | 96 ++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 frontend/today-work.js create mode 100644 tests/test_today_work.py diff --git a/frontend/index.html b/frontend/index.html index 26e473e..d27dc61 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -82,8 +82,9 @@ textarea { resize: vertical; min-height: 120px; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } .my-work-card:hover { border-color:var(--accent); } .my-work-card-title { display:block; margin:5px 0; font-weight:650; } -.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } +.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .later-actions button { min-height:44px; width:100%; } +.today-actions button { min-height:44px; width:100%; } .detail-defer { grid-column:1/-1; max-width:100%; } .detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; } .detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } @@ -315,6 +316,7 @@ textarea { resize: vertical; min-height: 120px; }
+ @@ -762,6 +764,7 @@ textarea { resize: vertical; min-height: 120px; } + @@ -804,7 +807,7 @@ textarea { resize: vertical; min-height: 120px; } const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')); function openMobileWork() { const counts = countMyWork(activeMyWork); - const filter = counts.attention ? 'attention' : 'all'; + const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all'); qs('[data-work-filter="' + filter + '"]').click(); qs('#my-work').scrollIntoView({block:'start'}); qs('#my-work').focus(); @@ -844,7 +847,7 @@ textarea { resize: vertical; min-height: 120px; } let selectedWorkMilestone = 'all'; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter; + if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter; const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY); if (savedMilestone) selectedWorkMilestone = savedMilestone; } catch (e) { @@ -886,6 +889,11 @@ textarea { resize: vertical; min-height: 120px; } let activeFlushLogin = ''; let activeMyWork = []; let laterMyWork = []; + let todayMyWork = []; + const todayWork = createTodayWork({ + storage: localStorage, + getLogin: () => confirmedOwnerLogin, + }); const laterWork = createLaterWork({ storage: localStorage, getLogin: () => confirmedOwnerLogin, @@ -1246,8 +1254,8 @@ textarea { resize: vertical; min-height: 120px; } } const workSession = createWorkSession({ - getItems: () => activeMyWork, - getFilter: () => selectedWorkFilter, + getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork, + getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter, getMilestone: () => selectedWorkMilestone, onOpen: openWorkSessionItem, onProgress: state => { @@ -1370,7 +1378,11 @@ textarea { resize: vertical; min-height: 120px; } }); activeMyWork = partitioned.active; laterMyWork = partitioned.later; + todayMyWork = todayWork.reconcile(lastMyWork, { + pruneMissing: !Object.values(workPagination).some(page => page?.has_more), + }); const counts = countMyWork(activeMyWork); + counts.today = todayMyWork.length; counts.later = laterMyWork.length; counts.draft = lastDrafts.length; Object.entries(counts).forEach(([filter, count]) => { @@ -1402,6 +1414,7 @@ textarea { resize: vertical; min-height: 120px; } } function activeWorkStreams() { + if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'issue') return ['issue']; if (selectedWorkFilter === 'pull') return ['pull']; @@ -1515,7 +1528,8 @@ textarea { resize: vertical; min-height: 120px; } qs('#load-more-notifications').hidden = true; return; } - const visible = selectedWorkFilter === 'later' ? + const visible = selectedWorkFilter === 'today' ? + filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ? filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone); const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); @@ -1539,16 +1553,22 @@ textarea { resize: vertical; min-height: 120px; } const laterActions = selectedWorkFilter === 'later' ? '
' : '
'; + const alreadyToday = todayWork.contains(item); + const todayPosition = todayWork.position(item); + const todayActions = selectedWorkFilter === 'today' ? + '
' : + '
'; + const planningActions = todayActions + laterActions; if (item.is_review) { - return ''; + return ''; } if (item.kind === 'issue') { - return ''; + return ''; } if (item.kind === 'pull') { - return ''; + return ''; } - return ''; + return ''; }).join('') : '
' + (incomplete ? 'More work is available. Load the next page.' : 'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '
'; @@ -1599,6 +1619,29 @@ textarea { resize: vertical; min-height: 120px; } refreshMyWorkView(); }); }); + document.querySelectorAll('[data-today-add]').forEach(button => { + button.addEventListener('click', () => { + const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]); + qs('#my-work-action-status').textContent = result === 'full' ? + 'Today is limited to 5 items. Remove one before adding more.' : + (result === 'added' ? 'Added to Today without changing Gitea.' : 'This item is already in Today.'); + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-today-remove]').forEach(button => { + button.addEventListener('click', () => { + todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]); + qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.'; + refreshMyWorkView(); + }); + }); + document.querySelectorAll('[data-today-move]').forEach(button => { + button.addEventListener('click', () => { + todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove); + refreshMyWorkView(); + document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus(); + }); + }); const allIds = notificationIds(visible); const ids = allIds.slice(0, Math.min(lastNotifications.length, 50)); const bulkBar = qs('#bulk-mark-read-bar'); @@ -3485,7 +3528,8 @@ textarea { resize: vertical; min-height: 120px; } qs('#refresh').addEventListener('click', load); qs('#start-work-session').addEventListener('click', () => { - if (!filterMyWork(lastMyWork, selectedWorkFilter).length) { + const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter); + if (!sessionItems.length) { qs('#my-work-action-status').textContent = 'No visible work to start.'; return; } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index a643139..28ef82a 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-v19'; +const CACHE = 'stackchain-dashboard-shell-v20'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -18,6 +18,7 @@ const SHELL = [ BASE + 'static/authored-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/my-work.js', + BASE + 'static/today-work.js', BASE + 'static/later-work.js', BASE + 'static/detail-defer.js', BASE + 'static/pick-work.js', diff --git a/frontend/today-work.js b/frontend/today-work.js new file mode 100644 index 0000000..60cab97 --- /dev/null +++ b/frontend/today-work.js @@ -0,0 +1,91 @@ +function createTodayWork({ storage, getLogin, limit = 5 }) { + const prefix = 'stackchain.today-work.v1.'; + + function identity(item) { + if (!item) return ''; + const kind = item.is_review ? 'review' : (item.kind || 'work'); + const number = Number.isInteger(item.number) ? item.number : ''; + const notification = Number.isInteger(item.notification_id) ? item.notification_id : ''; + return [kind, item.repository || '', number, notification].join(':'); + } + + function storageKey() { + const login = String(getLogin?.() || '').trim().toLowerCase(); + return login ? prefix + encodeURIComponent(login) : ''; + } + + function read() { + const key = storageKey(); + if (!key || !storage) return []; + try { + const value = JSON.parse(storage.getItem(key) || '[]'); + return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : []; + } catch (_error) { + return []; + } + } + + function write(ids) { + const key = storageKey(); + if (!key || !storage) return false; + try { + if (ids.length) storage.setItem(key, JSON.stringify(ids)); + else storage.removeItem(key); + return true; + } catch (_error) { + return false; + } + } + + function add(item) { + if (!storageKey()) return 'unavailable'; + const id = identity(item); + if (!id) return 'unavailable'; + const ids = read(); + if (ids.includes(id)) return 'exists'; + if (ids.length >= limit) return 'full'; + ids.push(id); + return write(ids) ? 'added' : 'unavailable'; + } + + function remove(item) { + const id = identity(item); + const ids = read(); + const next = ids.filter(candidate => candidate !== id); + return next.length !== ids.length && write(next); + } + + function move(item, direction) { + const ids = read(); + const index = ids.indexOf(identity(item)); + const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1; + if (index < 0 || target < 0 || target >= ids.length) return false; + [ids[index], ids[target]] = [ids[target], ids[index]]; + return write(ids); + } + + function reconcile(items, { pruneMissing = false } = {}) { + const available = new Map((items || []).map(item => [identity(item), item])); + const ids = read(); + const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids; + if (retained.length !== ids.length) write(retained); + return retained.flatMap(id => available.has(id) ? [available.get(id)] : []); + } + + function contains(item) { + return read().includes(identity(item)); + } + + function position(item) { + const ids = read(); + const index = ids.indexOf(identity(item)); + return { + can_up: index > 0, + can_down: index >= 0 && index < ids.length - 1, + }; + } + + return { identity, add, remove, move, reconcile, contains, position, limit }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork; diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a895dff..16e0887 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2989,13 +2989,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session(): assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html assert 'data-work-count="all"' in html + assert 'data-work-count="today"' in html assert 'data-work-count="attention"' in html assert 'data-work-count="issue"' in html assert 'data-work-count="pull"' in html assert 'data-work-count="review"' in html assert 'data-work-count="update"' in html assert 'data-work-count="later"' in html - assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html + assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html assert 'data-work-count="draft"' in html assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index bb5003c..8633001 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -91,10 +91,11 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_deadline_attention_flow_ships_in_a_new_shell_cache(): +def test_today_queue_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v19" in source + assert "stackchain-dashboard-shell-v20" in source + assert "BASE + 'static/today-work.js'" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): @@ -194,6 +195,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/authored-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/my-work.js", + "/dashboard/static/today-work.js", "/dashboard/static/later-work.js", "/dashboard/static/detail-defer.js", "/dashboard/static/pick-work.js", diff --git a/tests/test_today_work.py b/tests/test_today_work.py new file mode 100644 index 0000000..4c5b4da --- /dev/null +++ b/tests/test_today_work.py @@ -0,0 +1,96 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from src.views import dashboard + + +TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" + + +def run_node(script): + return subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout + + +def test_today_queue_is_account_scoped_ordered_unique_and_bounded(): + script = f""" +const createTodayWork = require({json.dumps(str(TODAY_WORK))}); +const values = new Map(); +const storage = {{ + getItem: key => values.has(key) ? values.get(key) : null, + setItem: (key, value) => values.set(key, value), + removeItem: key => values.delete(key), +}}; +let login = 'timmy'; +const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}}); +const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}}); +const first = queue.add(issue(1)); +const duplicate = queue.add(issue(1)); +queue.add(issue(2)); +queue.add(issue(3)); +const full = queue.add(issue(4)); +queue.move(issue(3), 'up'); +queue.move(issue(3), 'up'); +const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number); +const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}}) + .reconcile([issue(1), issue(2), issue(3)]).map(item => item.number); +login = 'alexander'; +const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number); +process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}})); +""" + + assert json.loads(run_node(script)) == { + "first": "added", + "duplicate": "exists", + "full": "full", + "timmy": [3, 1, 2], + "persisted": [3, 1, 2], + "isolated": [], + } + + +def test_today_queue_exposes_reorder_boundaries_for_touch_controls(): + script = f""" +const createTodayWork = require({json.dumps(str(TODAY_WORK))}); +const values = new Map(); +const storage = {{ + getItem: key => values.get(key) || null, + setItem: (key, value) => values.set(key, value), + removeItem: key => values.delete(key), +}}; +const queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); +const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}}); +queue.add(item(1)); queue.add(item(2)); queue.add(item(3)); +process.stdout.write(JSON.stringify([ + queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9)) +])); +""" + + assert json.loads(run_node(script)) == [ + {"can_up": False, "can_down": True}, + {"can_up": True, "can_down": True}, + {"can_up": True, "can_down": False}, + {"can_up": False, "can_down": False}, + ] + + +@pytest.mark.anyio +async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): + html = await dashboard() + + assert '' in html + assert 'data-work-filter="today"' in html + assert 'data-work-count="today"' in html + assert "const todayWork = createTodayWork({" in html + assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html + assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html + assert 'data-today-add' in html + assert 'data-today-remove' in html + assert 'data-today-move="up"' in html + assert 'data-today-move="down"' in html + assert 'Today is limited to 5 items' in html + assert '.today-actions button' in html and 'min-height:44px' in html -- 2.43.0