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

+

+
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)