feat: defer from mobile detail sheets (#256)
This commit is contained in:
parent
61168dbfd4
commit
fdb39e43ad
25
frontend/detail-defer.js
Normal file
25
frontend/detail-defer.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
function createDetailDefer({
|
||||
laterWork,
|
||||
session,
|
||||
close,
|
||||
refresh,
|
||||
focus,
|
||||
announce,
|
||||
formatTime = value => new Date(value).toLocaleString(),
|
||||
}) {
|
||||
return {
|
||||
defer(item, preset) {
|
||||
if (!item) return false;
|
||||
const until = laterWork.presetUntil(preset);
|
||||
if (!laterWork.defer(item, until)) return false;
|
||||
const inSession = session.active();
|
||||
if (!inSession) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
if (!inSession) focus();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
|
||||
|
|
@ -84,6 +84,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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 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; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
|
|
@ -514,6 +519,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button id="release-issue" type="button">Release assignment</button>
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
|
|
@ -617,6 +623,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
|
|
@ -656,6 +663,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
</div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
|
|
@ -720,6 +728,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</section>
|
||||
<h2>Review history</h2>
|
||||
<div id="review-history" class="muted"></div>
|
||||
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today">Later today</button><button type="button" data-detail-defer-preset="tomorrow">Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
@ -750,6 +759,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
|
|
@ -1255,6 +1265,30 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
},
|
||||
});
|
||||
|
||||
const detailDefer = createDetailDefer({
|
||||
laterWork,
|
||||
session: workSession,
|
||||
close: () => workRoute.close(),
|
||||
refresh: refreshMyWorkView,
|
||||
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
formatTime: fmt,
|
||||
});
|
||||
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||||
button.closest('.detail-defer').open = false;
|
||||
detailDefer.defer(item, button.dataset.detailDeferPreset);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const chooser = button.closest('.detail-defer');
|
||||
chooser.open = false;
|
||||
chooser.querySelector('summary')?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v14';
|
||||
const CACHE = 'stackchain-dashboard-shell-v15';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -18,6 +18,7 @@ const SHELL = [
|
|||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,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"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.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"
|
||||
|
|
@ -838,6 +839,65 @@ process.stdout.write(JSON.stringify({{
|
|||
}
|
||||
|
||||
|
||||
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
const calls = [];
|
||||
let sessionActive = false;
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
|
||||
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
|
||||
}},
|
||||
session: {{ active:() => sessionActive }},
|
||||
close:() => calls.push(['close']),
|
||||
refresh:() => calls.push(['refresh']),
|
||||
focus:() => calls.push(['focus']),
|
||||
announce:message => calls.push(['announce', message]),
|
||||
formatTime:value => value.toISOString(),
|
||||
}});
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
|
||||
const outside = controller.defer(item, 'today');
|
||||
sessionActive = true;
|
||||
const session = controller.defer(item, 'tomorrow');
|
||||
process.stdout.write(JSON.stringify({{outside,session,calls}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"outside": True,
|
||||
"session": True,
|
||||
"calls": [
|
||||
["defer", "Read first", "2026-08-08T16:00:00.000Z"],
|
||||
["close"],
|
||||
["refresh"],
|
||||
["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
|
||||
["focus"],
|
||||
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
|
||||
["refresh"],
|
||||
["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/detail-defer.js"></script>' in html
|
||||
assert html.count('class="detail-defer"') == 4
|
||||
assert html.count('data-detail-defer-preset="today"') == 4
|
||||
assert html.count('data-detail-defer-preset="tomorrow"') == 4
|
||||
assert 'const detailDefer = createDetailDefer({' in html
|
||||
assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
|
||||
assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
|
||||
assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
|
||||
assert "fetch(" not in DETAIL_DEFER.read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ async function dispatchSync(tag) {{
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_background_authored_sync_ships_in_a_new_shell_cache():
|
||||
def test_detail_defer_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v14" in source
|
||||
assert "stackchain-dashboard-shell-v15" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
|
|
@ -114,6 +114,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user