diff --git a/README.md b/README.md
index 78b1efa..d9b77ea 100644
--- a/README.md
+++ b/README.md
@@ -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
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
-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
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index bbaf595..ed2bf4f 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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 > span:first-child { display:grid; gap:2px; }
.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="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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index c75af83..a8a799c 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -439,6 +439,8 @@
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
+ pinnedSection:qs('#mobile-pinned-work'),
+ pinnedList:qs('#mobile-pinned-work-list'),
status:qs('#mobile-recent-work-status'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
diff --git a/frontend/index.html b/frontend/index.html
index 9444ef8..8923994 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2183,9 +2183,13 @@
Start / Continue
Find Work
+
+
diff --git a/frontend/mobile-recent-work.js b/frontend/mobile-recent-work.js
index ccab3c2..9d65029 100644
--- a/frontend/mobile-recent-work.js
+++ b/frontend/mobile-recent-work.js
@@ -8,6 +8,7 @@
const getLogin = options.getLogin;
const fetchJson = options.fetchJson;
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 repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
@@ -45,29 +46,51 @@
return {kind, ...(repository ? {repository} : {}), number, title, route};
}
- function normalizeList(value) {
+ function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
- if (unique.length === limit) break;
+ if (unique.length === maximum) break;
}
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() {
const storageKey = key();
- if (!storageKey) return {items:[], pending:[]};
+ if (!storageKey) return empty();
try {
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 {
items:normalizeList(parsed?.items),
+ pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizeList(parsed?.pending),
+ pinOps:normalizePinOps(parsed?.pinOps),
};
} catch (_) {
- return {items:[], pending:[]};
+ return empty();
}
}
@@ -75,7 +98,10 @@
if (!accountKey || accountKey !== key()) return false;
try {
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;
} catch (_) {
@@ -83,18 +109,27 @@
}
}
+ function hasPending(value) {
+ return value.pending.length > 0 || value.pinOps.length > 0;
+ }
+
function announce(value = read(), status = null) {
if (!options.status) return;
- options.status.textContent = status || (value.pending.length ? 'Sync pending.' : '');
+ options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
}
function items() {
return read().items;
}
+ function pinned() {
+ return read().pinned;
+ }
+
function state() {
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() {
@@ -113,6 +148,7 @@
if (!accountKey || !normalized) return false;
const current = read();
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);
if (!persist(current, accountKey)) return false;
announce(current);
@@ -121,13 +157,62 @@
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;
const remote = normalizeList(snapshot.items);
+ const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizeList(pending);
+ const unsentPinOps = normalizePinOps(pinOps);
const value = {
items:normalizeList([...unsent, ...remote]),
+ pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent,
+ pinOps:unsentPinOps,
};
persist(value, accountKey);
announce(value);
@@ -138,17 +223,36 @@
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
- if (!current.pending.length) return current;
+ if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1];
+ const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…');
try {
- const snapshot = await fetchJson('api/v1/recent-work', {
- method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
- });
+ let snapshot;
+ if (sending) {
+ snapshot = await fetchJson('api/v1/recent-work', {
+ 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();
const latest = read();
- const pending = latest.pending.filter(item => item.route !== sending.route);
- if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.');
+ const pending = sending
+ ? 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) {
if (key() === accountKey) announce(read());
return read();
@@ -160,7 +264,7 @@
function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
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;
syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => {
@@ -176,16 +280,16 @@
const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read();
const current = read();
- adopt(snapshot, accountKey, current.pending);
- return current.pending.length ? sync() : read();
+ adopt(snapshot, accountKey, current.pending, current.pinOps);
+ return hasPending(current) ? sync() : read();
} 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();
}
}
function startLifecycle(lifecycle = {}) {
- const reconcile = () => read().pending.length ? sync() : load();
+ const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile();
@@ -193,34 +297,57 @@
return reconcile;
}
- function render() {
- const recent = items();
- 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
- : item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
- const button = options.document.createElement('button');
- const copy = options.document.createElement('span');
- const primary = options.document.createElement('strong');
- const secondary = options.document.createElement('small');
- primary.textContent = item.title;
- secondary.textContent = detail;
- copy.appendChild(primary);
- copy.appendChild(secondary);
- button.appendChild(copy);
- button.setAttribute('type', 'button');
- button.setAttribute('data-recent-work-route', item.route);
- button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
- button.addEventListener('click', () => options.openRoute?.(item.route));
- return button;
- });
- list.replaceChildren(...rows);
- section.hidden = rows.length === 0;
- return rows.length;
+ function detail(item) {
+ return item.kind === 'update'
+ ? 'Update · #' + item.number
+ : item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
}
- return {items, record, render, load, sync, startLifecycle, state};
+ function row(item, isPinned) {
+ const itemDetail = detail(item);
+ const wrapper = options.document.createElement('div');
+ const button = options.document.createElement('button');
+ const action = options.document.createElement('button');
+ const copy = options.document.createElement('span');
+ const primary = options.document.createElement('strong');
+ const secondary = options.document.createElement('small');
+ wrapper.setAttribute('class', 'mobile-recent-work-row');
+ primary.textContent = item.title;
+ secondary.textContent = itemDetail;
+ copy.appendChild(primary);
+ copy.appendChild(secondary);
+ button.appendChild(copy);
+ button.setAttribute('type', 'button');
+ button.setAttribute('data-recent-work-route', item.route);
+ button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
+ button.addEventListener('click', () => options.openRoute?.(item.route));
+ action.textContent = isPinned ? 'Unpin' : 'Pin';
+ action.setAttribute('type', 'button');
+ action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
+ action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
+ action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
+ wrapper.appendChild(button);
+ wrapper.appendChild(action);
+ return wrapper;
+ }
+
+ 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};
});
diff --git a/src/main.py b/src/main.py
index 42315ef..0a8d7c3 100644
--- a/src/main.py
+++ b/src/main.py
@@ -980,6 +980,10 @@ class RecentWorkItem(BaseModel):
route: str = Field(min_length=1, max_length=300)
+class RecentWorkRoute(BaseModel):
+ route: str = Field(min_length=1, max_length=300)
+
+
class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
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")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
diff --git a/src/recent_work_store.py b/src/recent_work_store.py
index 36a1663..d54c4f2 100644
--- a/src/recent_work_store.py
+++ b/src/recent_work_store.py
@@ -18,10 +18,12 @@ class RecentWorkStore:
timeout: float = 1.0,
encryption_key: bytes | None = None,
limit: int = 5,
+ pinned_limit: int = 20,
):
self.path = Path(path)
self.timeout = timeout
self.limit = max(1, int(limit))
+ self.pinned_limit = max(1, int(pinned_limit))
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="recent-work",
@@ -86,30 +88,47 @@ class RecentWorkStore:
raise ValueError("recent work item is invalid")
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:
- return [], False
+ return {"items": [], "pinned": []}, False
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")
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:
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:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
- items, legacy = self._items(row, login)
+ state, legacy = self._state(row, login)
if row is not None and legacy:
connection.execute(
"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:
login = self._login(login)
@@ -119,13 +138,41 @@ class RecentWorkStore:
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
- current, _legacy = self._items(row, login)
- items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])]
- items = items[: self.limit]
- sealed = self._cipher.seal(items, binding=f"items:{login}")
- connection.execute(
- "INSERT INTO recent_work(login, items) VALUES (?, ?) "
- "ON CONFLICT(login) DO UPDATE SET items=excluded.items",
- (login, sealed),
- )
- return {"items": items}
+ state, _legacy = self._state(row, login)
+ state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
+ state["pinned"] = [
+ normalized if entry["route"] == normalized["route"] else entry
+ for entry in state["pinned"]
+ ]
+ self._write(connection, login, state)
+ return state
+
+ 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
diff --git a/tests/e2e/test_mobile_pinned_work_release.py b/tests/e2e/test_mobile_pinned_work_release.py
new file mode 100644
index 0000000..4dccddc
--- /dev/null
+++ b/tests/e2e/test_mobile_pinned_work_release.py
@@ -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()
diff --git a/tests/test_mobile_recent_work.py b/tests/test_mobile_recent_work.py
index 6f57795..37e63e2 100644
--- a/tests/test_mobile_recent_work.py
+++ b/tests/test_mobile_recent_work.py
@@ -93,13 +93,14 @@ const recent=createRecentWork({{
openRoute:route=>opened.push(route),
}});
const rendered=recent.render();
-list.children[1].click();
+list.children[1].children[0].click();
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'],
primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent,
- }})),opened,
+ pin:row.children[1].attributes['data-recent-work-pin'],
+ }});}}),opened,
}}));
"""
payload = run_node(script)
@@ -113,12 +114,14 @@ process.stdout.write(JSON.stringify({{
"route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7",
+ "pin": "pin",
},
{
"label": "Open Review release status, update #42",
"route": "#/my-work/update/42",
"primary": "Review release status",
"secondary": "Update · #42",
+ "pin": "pin",
},
],
"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"]]
+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():
html = INDEX.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-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 '' in html
assert "createMobileRecentWork({" in dashboard
assert "fetchJson:fetchReviewJson" 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 "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard
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
diff --git a/tests/test_recent_work_api.py b/tests/test_recent_work_api.py
index 5241d14..6310ceb 100644
--- a/tests/test_recent_work_api.py
+++ b/tests/test_recent_work_api.py
@@ -47,7 +47,60 @@ async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_acco
assert forbidden.status_code == 403
assert saved.status_code == 200
- assert saved.json() == {"items": [entry]}
+ assert saved.json() == {"items": [entry], "pinned": []}
assert fetched.json() == saved.json()
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": []}
diff --git a/tests/test_recent_work_store.py b/tests/test_recent_work_store.py
index bdac945..b4dfd7c 100644
--- a/tests/test_recent_work_store.py
+++ b/tests/test_recent_work_store.py
@@ -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 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:
payload = connection.execute(
"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
+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):
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)