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 '
' + contents + '' + readUpdate + markRead + laterActions + '';
+ return '
' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'issue') {
- return '
' + contents + '' + readUpdate + markRead + laterActions + '';
+ return '
' + contents + '' + readUpdate + markRead + planningActions + '';
}
if (item.kind === 'pull') {
- return '
' + contents + '' + readUpdate + markRead + laterActions + '';
+ return '
' + contents + '' + readUpdate + markRead + planningActions + '';
}
- return '
' + contents + '' + markRead + laterActions + '';
+ return '
' + contents + '' + markRead + planningActions + '';
}).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