feat: add mobile Later queue (#254)
This commit is contained in:
1 parent
8e0389153c
commit
65a09b5c66
6 files changed
+365
-14
No files matched your search
+183
-1
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,6 +9,7 @@ from src.views import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
@@ -677,6 +679,185 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_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),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
|
||||
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
|
||||
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
const timmy = store.partition([item]);
|
||||
login = 'alexander';
|
||||
const alexander = store.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
deferred,
|
||||
timmy:{{active:timmy.active.length,later:timmy.later}},
|
||||
alexander:{{active:alexander.active.length,later:alexander.later.length}},
|
||||
keys:Array.from(values.keys()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": True,
|
||||
"timmy": {
|
||||
"active": 0,
|
||||
"later": [{
|
||||
"kind": "pull", "is_review": True, "repository": "stackchain/api",
|
||||
"number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
|
||||
}],
|
||||
},
|
||||
"alexander": {"active": 1, "later": 0},
|
||||
"keys": ["stackchain.later-work.v1.timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_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),
|
||||
}};
|
||||
let clock = new Date('2026-08-08T12:00:00Z');
|
||||
let scheduled = null;
|
||||
let wakes = 0;
|
||||
const store = createLaterWork({{
|
||||
storage, getLogin:() => 'timmy', now:() => clock,
|
||||
setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
|
||||
clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
|
||||
}});
|
||||
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
|
||||
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
|
||||
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
|
||||
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
|
||||
const before = store.partition([kept]);
|
||||
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
|
||||
clock = new Date('2026-08-08T13:00:01Z');
|
||||
scheduled.callback();
|
||||
const after = store.partition([kept]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
before:{{active:before.active.length,later:before.later.length}},
|
||||
persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
|
||||
after:{{active:after.active.map(item => item.title),later:after.later.length}},
|
||||
storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"active": 0, "later": 1},
|
||||
"persisted": ["issue:stackchain/api:17:"],
|
||||
"delay": 3600000,
|
||||
"wakes": 1,
|
||||
"after": {"active": ["Kept"], "later": 0},
|
||||
"storageEmpty": True,
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_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 store = createLaterWork({{
|
||||
storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
|
||||
setTimer:() => 1, clearTimer:() => {{}},
|
||||
}});
|
||||
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
|
||||
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
|
||||
store.defer(second, new Date('2026-08-09T12:00:00Z'));
|
||||
store.defer(first, new Date('2026-08-09T12:00:00Z'));
|
||||
store.partition([first], {{pruneMissing:false}});
|
||||
const visible = store.partition([first, second], {{pruneMissing:false}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
titles:visible.later.map(item => item.title),
|
||||
records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"titles": ["Higher priority", "Lower priority"],
|
||||
"records": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_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 now = new Date('2026-08-08T12:00:00Z');
|
||||
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
|
||||
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
|
||||
const today = store.presetUntil('today');
|
||||
const tomorrow = store.presetUntil('tomorrow');
|
||||
store.defer(item, tomorrow);
|
||||
const removed = store.restore(item);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
|
||||
partition:store.partition([item]),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True,
|
||||
env={**os.environ, "TZ": "UTC"},
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "2026-08-08T16:00:00.000Z",
|
||||
"tomorrow": "2026-08-09T09:00:00.000Z",
|
||||
"removed": True,
|
||||
"partition": {"active": [{
|
||||
"kind": "update", "repository": "stackchain/api", "number": 17,
|
||||
"notification_id": 91,
|
||||
}], "later": []},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-work.js"></script>' in html
|
||||
assert 'data-work-filter="later"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert 'const laterWork = createLaterWork({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'laterWork.partition(lastMyWork,' in html
|
||||
assert 'data-later-preset="today"' in html
|
||||
assert 'data-later-preset="tomorrow"' in html
|
||||
assert 'data-later-restore' in html
|
||||
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
|
||||
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
|
||||
assert 'laterWork.restore(item)' in html
|
||||
assert '.later-actions button { min-height:44px;' in html
|
||||
assert 'Deferred work stays unread and unchanged in Gitea.' in html
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
@@ -2676,7 +2857,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', '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
|
||||
|
||||
@@ -73,7 +73,7 @@ async function dispatchSync(tag) {{
|
||||
def test_background_authored_sync_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v13" in source
|
||||
assert "stackchain-dashboard-shell-v14" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -113,6 +113,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/later-work.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
|
||||
Reference in new issue
Block a user