feat: add mobile Later queue (#254)
This commit is contained in:
parent
8e0389153c
commit
65a09b5c66
|
|
@ -92,6 +92,14 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
|||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
My Work also has a local **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
|
||||
Deferred items leave normal and Attention queues without marking notifications read
|
||||
or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time, and **Bring back now** restores them
|
||||
early. Later state is stored only in this browser, scoped to the confirmed Gitea
|
||||
login, and removed when fully loaded work confirms that an item no longer exists.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ 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 button { min-height:44px; width:100%; }
|
||||
.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; }
|
||||
|
|
@ -312,6 +314,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||
<button class="work-filter" data-work-filter="later" aria-pressed="false">Later <span data-work-count="later">0</span></button>
|
||||
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
|
||||
</div>
|
||||
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||
|
|
@ -746,6 +749,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
|
|
@ -785,7 +789,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
);
|
||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
function openMobileWork() {
|
||||
const counts = countMyWork(lastMyWork);
|
||||
const counts = countMyWork(activeMyWork);
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
|
|
@ -826,7 +830,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', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
|
|
@ -866,6 +870,19 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
let activeMyWork = [];
|
||||
let laterMyWork = [];
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onWake: () => {
|
||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||
refreshMyWorkView();
|
||||
},
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) refreshMyWorkView();
|
||||
});
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
|
|
@ -1214,7 +1231,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => lastMyWork,
|
||||
getItems: () => activeMyWork,
|
||||
getFilter: () => selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
onOpen: openWorkSessionItem,
|
||||
|
|
@ -1309,7 +1326,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
function refreshMyWorkView() {
|
||||
lastDrafts = draftInbox.list();
|
||||
const counts = countMyWork(lastMyWork);
|
||||
const partitioned = laterWork.partition(lastMyWork, {
|
||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||
});
|
||||
activeMyWork = partitioned.active;
|
||||
laterMyWork = partitioned.later;
|
||||
const counts = countMyWork(activeMyWork);
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
|
|
@ -1332,7 +1355,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||||
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
|
||||
'No assigned work, review requests, or unread updates.';
|
||||
updateWorkPaginationControls();
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
|
|
@ -1452,7 +1476,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#load-more-notifications').hidden = true;
|
||||
return;
|
||||
}
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const visible = selectedWorkFilter === 'later' ?
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
|
|
@ -1465,24 +1491,28 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
|
||||
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||
const laterActions = selectedWorkFilter === 'later' ?
|
||||
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
||||
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>';
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'pull') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + laterActions + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')))) + '.') + '</div>';
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
|
|
@ -1514,6 +1544,22 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-later-preset]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||
if (!item || !laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))) return;
|
||||
qs('#my-work-action-status').textContent = 'Deferred work stays unread and unchanged in Gitea.';
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||
if (!item || !laterWork.restore(item)) return;
|
||||
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
|
|
|
|||
113
frontend/later-work.js
Normal file
113
frontend/later-work.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
|
||||
const prefix = 'stackchain.later-work.v1.';
|
||||
let timer = null;
|
||||
|
||||
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 value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
|
||||
const records = read();
|
||||
records[id] = wake.toISOString();
|
||||
write(records);
|
||||
return true;
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
if (preset === 'tomorrow') {
|
||||
const wake = new Date(current);
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
return wake;
|
||||
}
|
||||
return new Date(NaN);
|
||||
}
|
||||
|
||||
function restore(item) {
|
||||
const id = identity(item);
|
||||
const records = read();
|
||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||
delete records[id];
|
||||
write(records);
|
||||
return true;
|
||||
}
|
||||
|
||||
function schedule(wakeTimes, current) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (!wakeTimes.length) return;
|
||||
const delay = Math.max(0, Math.min(...wakeTimes) - current);
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onWake();
|
||||
}, Math.min(delay, 2147483647));
|
||||
}
|
||||
|
||||
function partition(items, { pruneMissing = true } = {}) {
|
||||
const current = now().getTime();
|
||||
const records = read();
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const retained = {};
|
||||
const wakeTimes = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, defer, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v13';
|
||||
const CACHE = 'stackchain-dashboard-shell-v14';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -17,6 +17,7 @@ const SHELL = [
|
|||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user