feat: pin frequent mobile work (Closes #1477)
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m46s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-27 18:34:01 +00:00
parent 2b630d3fa8
commit 94bd2cafba
11 changed files with 527 additions and 75 deletions

View File

@ -135,7 +135,10 @@ Mobile **Recent work** is also portable across signed-in devices. Opening an iss
review, Filed item, or update records its canonical detail route locally before navigation and marks review, Filed item, or update records its canonical detail route locally before navigation and marks
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
merge the server list without duplicate routes, while each confirmed account remains bounded to its merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. Titles, repositories, and routes are encrypted at rest with the shared five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
private-state key; stale responses from a prior account are discarded. Set private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`. `STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.

View File

@ -1551,7 +1551,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; } .mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; } .mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); } .mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-recent-work-route] { min-height:56px; overflow-wrap:anywhere; } .mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; } .mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; } .mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; } .mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }

View File

@ -439,6 +439,8 @@
document, document,
section:qs('#mobile-recent-work'), section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'), list:qs('#mobile-recent-work-list'),
pinnedSection:qs('#mobile-pinned-work'),
pinnedList:qs('#mobile-pinned-work-list'),
status:qs('#mobile-recent-work-status'), status:qs('#mobile-recent-work-status'),
openRoute:fragment => { openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet'); const sheet = qs('#mobile-queue-sheet');

View File

@ -2183,9 +2183,13 @@
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p> <p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button> <button id="mobile-queue-next-action" type="button">Find Work</button>
</section> </section>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
</section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden> <section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3> <h3 id="mobile-recent-work-heading">Recent work</h3>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div> <div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section> </section>
<details class="mobile-queue-priority" id="mobile-queue-priority"> <details class="mobile-queue-priority" id="mobile-queue-priority">

View File

@ -8,6 +8,7 @@
const getLogin = options.getLogin; const getLogin = options.getLogin;
const fetchJson = options.fetchJson; const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5); const limit = Math.max(1, Number(options.limit) || 5);
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
const prefix = 'stackchain.mobile-recent-work.v1.'; const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']); const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
@ -45,29 +46,51 @@
return {kind, ...(repository ? {repository} : {}), number, title, route}; return {kind, ...(repository ? {repository} : {}), number, title, route};
} }
function normalizeList(value) { function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
const unique = []; const unique = [];
for (const candidate of value) { for (const candidate of value) {
const item = normalize(candidate); const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item); if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
if (unique.length === limit) break; if (unique.length === maximum) break;
} }
return unique; return unique;
} }
function normalizePinOps(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const action = candidate?.action;
const item = action === 'pin' ? normalize(candidate.item) : null;
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
unique.push(action === 'pin' ? {action, item} : {action, route});
}
if (unique.length === pinnedLimit) break;
}
return unique;
}
function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]};
}
function read() { function read() {
const storageKey = key(); const storageKey = key();
if (!storageKey) return {items:[], pending:[]}; if (!storageKey) return empty();
try { try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]}; if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
return { return {
items:normalizeList(parsed?.items), items:normalizeList(parsed?.items),
pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizeList(parsed?.pending), pending:normalizeList(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps),
}; };
} catch (_) { } catch (_) {
return {items:[], pending:[]}; return empty();
} }
} }
@ -75,7 +98,10 @@
if (!accountKey || accountKey !== key()) return false; if (!accountKey || accountKey !== key()) return false;
try { try {
storage.setItem(accountKey, JSON.stringify({ storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items), pending:normalizeList(value.pending), items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizeList(value.pending),
pinOps:normalizePinOps(value.pinOps),
})); }));
return true; return true;
} catch (_) { } catch (_) {
@ -83,18 +109,27 @@
} }
} }
function hasPending(value) {
return value.pending.length > 0 || value.pinOps.length > 0;
}
function announce(value = read(), status = null) { function announce(value = read(), status = null) {
if (!options.status) return; if (!options.status) return;
options.status.textContent = status || (value.pending.length ? 'Sync pending.' : ''); options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
} }
function items() { function items() {
return read().items; return read().items;
} }
function pinned() {
return read().pinned;
}
function state() { function state() {
const value = read(); const value = read();
return {pending:value.pending.length > 0, pendingCount:value.pending.length}; const pendingCount = value.pending.length + value.pinOps.length;
return {pending:pendingCount > 0, pendingCount};
} }
function scheduleSync() { function scheduleSync() {
@ -113,6 +148,7 @@
if (!accountKey || !normalized) return false; if (!accountKey || !normalized) return false;
const current = read(); const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit); current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pinned = current.pinned.map(existing => existing.route === normalized.route ? normalized : existing);
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit); current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
if (!persist(current, accountKey)) return false; if (!persist(current, accountKey)) return false;
announce(current); announce(current);
@ -121,13 +157,62 @@
return true; return true;
} }
function adopt(snapshot, accountKey, pending = []) { function queuePinOp(current, operation) {
const route = operation.item?.route || operation.route;
current.pinOps = [operation, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
}
function pin(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
queuePinOp(current, {action:'pin', item:normalized});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function unpin(route) {
const accountKey = key();
route = String(route || '');
if (!accountKey || !route) return false;
const current = read();
if (!current.pinned.some(item => item.route === route)) return false;
current.pinned = current.pinned.filter(item => item.route !== route);
queuePinOp(current, {action:'unpin', route});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function applyPinOps(remote, operations) {
let result = normalizeList(remote, pinnedLimit);
for (const operation of [...normalizePinOps(operations)].reverse()) {
const route = operation.item?.route || operation.route;
result = operation.action === 'pin'
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
: result.filter(item => item.route !== route);
}
return result;
}
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false; if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items); const remote = normalizeList(snapshot.items);
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizeList(pending); const unsent = normalizeList(pending);
const unsentPinOps = normalizePinOps(pinOps);
const value = { const value = {
items:normalizeList([...unsent, ...remote]), items:normalizeList([...unsent, ...remote]),
pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent, pending:unsent,
pinOps:unsentPinOps,
}; };
persist(value, accountKey); persist(value, accountKey);
announce(value); announce(value);
@ -138,17 +223,36 @@
async function drain(accountKey) { async function drain(accountKey) {
while (key() === accountKey) { while (key() === accountKey) {
const current = read(); const current = read();
if (!current.pending.length) return current; if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1]; const sending = current.pending[current.pending.length - 1];
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…'); announce(current, 'Syncing recent work…');
try { try {
const snapshot = await fetchJson('api/v1/recent-work', { let snapshot;
if (sending) {
snapshot = await fetchJson('api/v1/recent-work', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending), method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
}); });
} else {
const isPin = pinOperation.action === 'pin';
snapshot = await fetchJson('api/v1/recent-work/pin', {
method:isPin ? 'PUT' : 'DELETE',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
});
}
if (key() !== accountKey) return read(); if (key() !== accountKey) return read();
const latest = read(); const latest = read();
const pending = latest.pending.filter(item => item.route !== sending.route); const pending = sending
if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.'); ? latest.pending.filter(item => item.route !== sending.route)
: latest.pending;
const pinOps = pinOperation
? latest.pinOps.filter(operation => {
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
return !sameRoute || operation.action !== pinOperation.action;
})
: latest.pinOps;
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
} catch (_error) { } catch (_error) {
if (key() === accountKey) announce(read()); if (key() === accountKey) announce(read());
return read(); return read();
@ -160,7 +264,7 @@
function sync() { function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; } if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key(); const accountKey = key();
if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read()); if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
if (syncFlight && syncAccount === accountKey) return syncFlight; if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey; syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => { syncFlight = drain(accountKey).finally(() => {
@ -176,16 +280,16 @@
const snapshot = await fetchJson('api/v1/recent-work'); const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read(); if (key() !== accountKey) return read();
const current = read(); const current = read();
adopt(snapshot, accountKey, current.pending); adopt(snapshot, accountKey, current.pending, current.pinOps);
return current.pending.length ? sync() : read(); return hasPending(current) ? sync() : read();
} catch (_error) { } catch (_error) {
if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.'); if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
return read(); return read();
} }
} }
function startLifecycle(lifecycle = {}) { function startLifecycle(lifecycle = {}) {
const reconcile = () => read().pending.length ? sync() : load(); const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); }); lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => { lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile(); if (!lifecycle.document.hidden) void reconcile();
@ -193,34 +297,57 @@
return reconcile; return reconcile;
} }
function render() { function detail(item) {
const recent = items(); return item.kind === 'update'
const list = options.list;
const section = options.section;
if (!list || !section || !options.document) return recent.length;
const rows = recent.map(item => {
const detail = item.kind === 'update'
? 'Update · #' + item.number ? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number; : item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
}
function row(item, isPinned) {
const itemDetail = detail(item);
const wrapper = options.document.createElement('div');
const button = options.document.createElement('button'); const button = options.document.createElement('button');
const action = options.document.createElement('button');
const copy = options.document.createElement('span'); const copy = options.document.createElement('span');
const primary = options.document.createElement('strong'); const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small'); const secondary = options.document.createElement('small');
wrapper.setAttribute('class', 'mobile-recent-work-row');
primary.textContent = item.title; primary.textContent = item.title;
secondary.textContent = detail; secondary.textContent = itemDetail;
copy.appendChild(primary); copy.appendChild(primary);
copy.appendChild(secondary); copy.appendChild(secondary);
button.appendChild(copy); button.appendChild(copy);
button.setAttribute('type', 'button'); button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route); button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' ')); button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => options.openRoute?.(item.route)); button.addEventListener('click', () => options.openRoute?.(item.route));
return button; action.textContent = isPinned ? 'Unpin' : 'Pin';
}); action.setAttribute('type', 'button');
list.replaceChildren(...rows); action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
section.hidden = rows.length === 0; action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
return rows.length; action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
wrapper.appendChild(button);
wrapper.appendChild(action);
return wrapper;
} }
return {items, record, render, load, sync, startLifecycle, state}; function render() {
const recent = items();
const fixed = pinned();
const list = options.list;
const section = options.section;
if (list && section && options.document) {
const rows = recent.map(item => row(item, false));
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
}
if (options.pinnedList && options.pinnedSection && options.document) {
const rows = fixed.map(item => row(item, true));
options.pinnedList.replaceChildren(...rows);
options.pinnedSection.hidden = rows.length === 0;
}
return recent.length + fixed.length;
}
return {items, pinned, record, pin, unpin, render, load, sync, startLifecycle, state};
}); });

View File

@ -980,6 +980,10 @@ class RecentWorkItem(BaseModel):
route: str = Field(min_length=1, max_length=300) route: str = Field(min_length=1, max_length=300)
class RecentWorkRoute(BaseModel):
route: str = Field(min_length=1, max_length=300)
class CompletedFiledReviewReceipt(BaseModel): class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field( repository: str = Field(
min_length=3, min_length=3,
@ -3468,6 +3472,40 @@ async def record_recent_work(payload: RecentWorkItem):
) )
@app.put("/api/v1/recent-work/pin")
async def pin_recent_work(payload: RecentWorkItem):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_recent_work_store().pin, login, payload.model_dump()
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.delete("/api/v1/recent-work/pin")
async def unpin_recent_work(payload: RecentWorkRoute):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_recent_work_store().unpin, login, payload.route
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/unfiled-drafts") @app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response): async def get_unfiled_drafts(response: Response):
login = await _confirmed_login() login = await _confirmed_login()

View File

@ -18,10 +18,12 @@ class RecentWorkStore:
timeout: float = 1.0, timeout: float = 1.0,
encryption_key: bytes | None = None, encryption_key: bytes | None = None,
limit: int = 5, limit: int = 5,
pinned_limit: int = 20,
): ):
self.path = Path(path) self.path = Path(path)
self.timeout = timeout self.timeout = timeout
self.limit = max(1, int(limit)) self.limit = max(1, int(limit))
self.pinned_limit = max(1, int(pinned_limit))
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="recent-work", store="recent-work",
@ -86,30 +88,47 @@ class RecentWorkStore:
raise ValueError("recent work item is invalid") raise ValueError("recent work item is invalid")
return normalized return normalized
def _items(self, row, login: str) -> tuple[list[dict], bool]: def _state(self, row, login: str) -> tuple[dict, bool]:
if row is None: if row is None:
return [], False return {"items": [], "pinned": []}, False
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}") payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
if not isinstance(payload, list): if isinstance(payload, list):
payload = {"items": payload, "pinned": []}
legacy = True
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
raise PrivateStateEncryptionError("private state could not be decrypted") raise PrivateStateEncryptionError("private state could not be decrypted")
try: try:
return [self._normalize(item) for item in payload][: self.limit], legacy items = [self._normalize(item) for item in payload["items"]][: self.limit]
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
if len({item["route"] for item in pinned}) != len(pinned):
raise ValueError("recent work item is invalid")
return {"items": items, "pinned": pinned}, legacy
except ValueError as error: except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error raise PrivateStateEncryptionError("private state could not be decrypted") from error
def _seal(self, state: dict, login: str) -> str:
return self._cipher.seal(state, binding=f"items:{login}")
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
connection.execute(
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
(login, self._seal(state, login)),
)
def get(self, login: str) -> dict: def get(self, login: str) -> dict:
login = self._login(login) login = self._login(login)
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,) "SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone() ).fetchone()
items, legacy = self._items(row, login) state, legacy = self._state(row, login)
if row is not None and legacy: if row is not None and legacy:
connection.execute( connection.execute(
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?", "UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
(self._cipher.seal(items, binding=f"items:{login}"), login, row[0]), (self._seal(state, login), login, row[0]),
) )
return {"items": items} return state
def record(self, login: str, item: dict) -> dict: def record(self, login: str, item: dict) -> dict:
login = self._login(login) login = self._login(login)
@ -119,13 +138,41 @@ class RecentWorkStore:
row = connection.execute( row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,) "SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone() ).fetchone()
current, _legacy = self._items(row, login) state, _legacy = self._state(row, login)
items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])] state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
items = items[: self.limit] state["pinned"] = [
sealed = self._cipher.seal(items, binding=f"items:{login}") normalized if entry["route"] == normalized["route"] else entry
connection.execute( for entry in state["pinned"]
"INSERT INTO recent_work(login, items) VALUES (?, ?) " ]
"ON CONFLICT(login) DO UPDATE SET items=excluded.items", self._write(connection, login, state)
(login, sealed), return state
)
return {"items": items} def pin(self, login: str, item: dict) -> dict:
login = self._login(login)
normalized = self._normalize(item)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, _legacy = self._state(row, login)
state["pinned"] = [
normalized,
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
][: self.pinned_limit]
self._write(connection, login, state)
return state
def unpin(self, login: str, route: str) -> dict:
login = self._login(login)
if not isinstance(route, str) or not route:
raise ValueError("recent work route is invalid")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, _legacy = self._state(row, login)
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
self._write(connection, login, state)
return state

View File

@ -0,0 +1,72 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
page.evaluate("""() => {
const values = new Map();
const storage = {
getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value),
};
const item = {
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1477,
title:'Pin frequent work across signed-in mobile devices',
};
window.opened = [];
window.recentWork = createMobileRecentWork({
storage, getLogin:() => 'timmy', document,
section:document.querySelector('#mobile-recent-work'),
list:document.querySelector('#mobile-recent-work-list'),
pinnedSection:document.querySelector('#mobile-pinned-work'),
pinnedList:document.querySelector('#mobile-pinned-work-list'),
status:document.querySelector('#mobile-recent-work-status'),
openRoute:route => window.opened.push(route),
});
window.recentWork.record(item);
window.recentWork.pin(item);
document.querySelector('#mobile-queue-sheet').showModal();
}""")
expect(page.locator("#mobile-pinned-work")).to_be_visible()
expect(page.locator("#mobile-recent-work")).to_be_visible()
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
open_button = page.get_by_role(
"button", name="Open Pin frequent work across signed-in mobile devices, issue stackchain/stackchain-dashboard #1477"
).first
pin_button = page.get_by_role(
"button", name="Unpin Pin frequent work across signed-in mobile devices"
)
for control in (open_button, pin_button):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
open_button.focus()
open_button.press("Enter")
assert page.evaluate("window.opened") == [
"#/my-work/issue/stackchain/stackchain-dashboard/1477"
]
browser.close()

View File

@ -93,13 +93,14 @@ const recent=createRecentWork({{
openRoute:route=>opened.push(route), openRoute:route=>opened.push(route),
}}); }});
const rendered=recent.render(); const rendered=recent.render();
list.children[1].click(); list.children[1].children[0].click();
process.stdout.write(JSON.stringify({{ process.stdout.write(JSON.stringify({{
rendered,hidden:section.hidden,rows:list.children.map(button=>({{ rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'], label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
primary:button.children[0].children[0].textContent, primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent, secondary:button.children[0].children[1].textContent,
}})),opened, pin:row.children[1].attributes['data-recent-work-pin'],
}});}}),opened,
}})); }}));
""" """
payload = run_node(script) payload = run_node(script)
@ -113,12 +114,14 @@ process.stdout.write(JSON.stringify({{
"route": "#/my-work/issue/stackchain/dashboard/7", "route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue", "primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7", "secondary": "Issue · stackchain/dashboard #7",
"pin": "pin",
}, },
{ {
"label": "Open Review release status, update #42", "label": "Open Review release status, update #42",
"route": "#/my-work/update/42", "route": "#/my-work/update/42",
"primary": "Review release status", "primary": "Review release status",
"secondary": "Update · #42", "secondary": "Update · #42",
"pin": "pin",
}, },
], ],
"opened": ["#/my-work/update/42"], "opened": ["#/my-work/update/42"],
@ -156,6 +159,73 @@ process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:st
assert payload["calls"] == [["api/v1/recent-work", "POST"]] assert payload["calls"] == [["api/v1/recent-work", "POST"]]
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
const storageKey='stackchain.mobile-recent-work.v1.alice';
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
const status={{textContent:''}}; const calls=[]; const opened=[];
function node(tag) {{
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
}};
}}
const recentList=node('div'); const recentSection=node('section');
const pinnedList=node('div'); const pinnedSection=node('section');
let remote={{items:[item],pinned:[]}};
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
list:recentList,section:recentSection,pinnedList,pinnedSection,
openRoute:route=>opened.push(route),
fetchJson:async (url,options={{}})=>{{
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
return remote;
}},
}});
const pinnedImmediately=recent.pin(item);
recent.render();
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
recentActions:recentList.children[0].children.map(child=>child.attributes),
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
await recent.sync();
pinnedList.children[0].children[0].click();
const unpinnedImmediately=recent.unpin(item.route);
await recent.sync();
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["pinnedImmediately"] is True
assert payload["immediate"]["pinned"][0]["number"] == 1477
assert payload["immediate"]["status"] == "Sync pending."
assert payload["immediate"]["recentHidden"] is False
assert payload["immediate"]["pinnedHidden"] is False
assert payload["immediate"]["recentActions"][0]["data-recent-work-route"].endswith("/1477")
assert payload["immediate"]["recentActions"][1]["data-recent-work-pin"] == "pin"
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
assert payload["unpinnedImmediately"] is True
assert payload["settled"] == []
assert payload["calls"] == [
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
[
"api/v1/recent-work/pin",
"DELETE",
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
],
]
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes(): def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text() html = INDEX.read_text()
dashboard = DASHBOARD.read_text() dashboard = DASHBOARD.read_text()
@ -163,15 +233,21 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
assert 'id="mobile-recent-work"' in html assert 'id="mobile-recent-work"' in html
assert 'id="mobile-recent-work-list"' in html assert 'id="mobile-recent-work-list"' in html
assert 'id="mobile-pinned-work"' in html
assert 'id="mobile-pinned-work-list"' in html
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard assert "createMobileRecentWork({" in dashboard
assert "fetchJson:fetchReviewJson" in dashboard assert "fetchJson:fetchReviewJson" in dashboard
assert "status:qs('#mobile-recent-work-status')" in dashboard assert "status:qs('#mobile-recent-work-status')" in dashboard
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
assert "void mobileRecentWork.load();" in dashboard assert "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard assert "workRoute.sync()" in dashboard
assert "[data-recent-work-route]" in css assert "[data-recent-work-route]" in css
assert "min-height:56px" in css assert "[data-recent-work-pin]" in css
assert "min-height:44px" in css
assert "min-width:0" in css

View File

@ -47,7 +47,60 @@ async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_acco
assert forbidden.status_code == 403 assert forbidden.status_code == 403
assert saved.status_code == 200 assert saved.status_code == 200
assert saved.json() == {"items": [entry]} assert saved.json() == {"items": [entry], "pinned": []}
assert fetched.json() == saved.json() assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store" assert fetched.headers["cache-control"] == "no-store"
assert other_account.json() == {"items": []} assert other_account.json() == {"items": [], "pinned": []}
@pytest.mark.anyio
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
active_login = "Timmy"
async def user():
return {"id": 1, "login": active_login}
monkeypatch.setattr(main, "current_user", user)
entry = {
"kind": "pull",
"repository": "stackchain/dashboard",
"number": 1476,
"title": "Sync recent work",
"route": "#/my-work/pull/stackchain/dashboard/1476",
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
active_login = "Alexander"
isolated = await client.get("/api/v1/recent-work")
active_login = "Timmy"
unpinned = await client.request(
"DELETE",
"/api/v1/recent-work/pin",
json={"route": entry["route"]},
headers=headers,
)
assert forbidden.status_code == 403
assert pinned.status_code == 200
assert pinned.json() == {"items": [], "pinned": [entry]}
assert isolated.json() == {"items": [], "pinned": []}
assert unpinned.status_code == 200
assert unpinned.json() == {"items": [], "pinned": []}

View File

@ -23,7 +23,7 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2] assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
assert store.get("alexander") == {"items": []} assert store.get("alexander") == {"items": [], "pinned": []}
with sqlite3.connect(database) as connection: with sqlite3.connect(database) as connection:
payload = connection.execute( payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'" "SELECT items FROM recent_work WHERE login = 'timmy'"
@ -33,6 +33,33 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
assert "#/my-work/issue" not in payload assert "#/my-work/issue" not in payload
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
database = tmp_path / "recent-work.sqlite3"
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
store.record("timmy", item(1))
pinned = store.pin("timmy", item(1))
for number in range(2, 8):
store.record("timmy", item(number))
assert pinned["pinned"] == [item(1)]
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
assert store.get("timmy")["pinned"] == [item(1)]
assert store.get("alexander") == {"items": [], "pinned": []}
store.record("timmy", item(1, title="Issue 1 current"))
unpinned = store.unpin("timmy", item(1)["route"])
assert unpinned["pinned"] == []
assert unpinned["items"][0] == item(1, title="Issue 1 current")
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'"
).fetchone()[0]
assert "Issue 1" not in payload
assert "#/my-work/issue" not in payload
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path): def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32) store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)