(function (root, factory) { if (typeof module === 'object' && module.exports) module.exports = factory; else root.createMobileRecentWork = factory; })(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) { 'use strict'; const storage = options.storage; 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']); const setTimer = options.setTimeout || setTimeout; const clearTimer = options.clearTimeout || clearTimeout; const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150; const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000; const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000; let syncFlight = null; let syncAccount = ''; let debounceTimer = null; let retryTimer = null; let retryAccount = ''; let retryAttempt = 0; let operationSequence = 0; let pinsExpanded = false; options.pinnedToggle?.addEventListener?.('click', () => { pinsExpanded = !pinsExpanded; render(); }); function operationId() { operationSequence += 1; return Date.now().toString(36) + '-' + operationSequence.toString(36); } function clearRetry(resetAttempt = false) { if (retryTimer) clearTimer(retryTimer); retryTimer = null; retryAccount = ''; if (resetAttempt) retryAttempt = 0; } function scheduleRetry(accountKey) { if (retryTimer || key() !== accountKey || !hasPending(read())) return false; retryAccount = accountKey; const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt)); retryAttempt += 1; retryTimer = setTimer(() => { const timer = retryTimer; retryTimer = null; retryAccount = ''; if (timer) clearTimer(timer); if (key() === accountKey && hasPending(read())) void sync(); }, delay); return true; } function login() { return String(getLogin?.() || '').trim().toLowerCase(); } function key() { const owner = login(); return owner ? prefix + owner : ''; } function normalize(item) { const kind = String(item?.kind || ''); const number = Number(item?.number ?? item?.notification_id); if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null; let route = ''; let repository = ''; if (kind === 'update') { route = '#/my-work/update/' + number; } else { repository = String(item?.repository || ''); if (!repositoryPattern.test(repository)) return null; route = '#/my-work/' + kind + '/' + repository + '/' + number; } const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180); if (!title) return null; return {kind, ...(repository ? {repository} : {}), number, title, route}; } 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 === 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)) { const normalized = action === 'pin' ? {action, item} : {action, route}; if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId; unique.push(normalized); } if (unique.length === pinnedLimit) break; } return unique; } function normalizePending(value) { 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)) continue; if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId; unique.push(item); if (unique.length === limit) break; } return unique; } function empty() { return {items:[], pinned:[], pending:[], pinOps:[]}; } function read() { const storageKey = key(); if (!storageKey) return empty(); try { const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)}; return { items:normalizeList(parsed?.items), pinned:normalizeList(parsed?.pinned, pinnedLimit), pending:normalizePending(parsed?.pending), pinOps:normalizePinOps(parsed?.pinOps), }; } catch (_) { return empty(); } } function persist(value, accountKey = key()) { if (!accountKey || accountKey !== key()) return false; try { storage.setItem(accountKey, JSON.stringify({ items:normalizeList(value.items), pinned:normalizeList(value.pinned, pinnedLimit), pending:normalizePending(value.pending), pinOps:normalizePinOps(value.pinOps), })); return true; } catch (_) { return false; } } 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 || (hasPending(value) ? 'Sync pending.' : ''); } function items() { return read().items; } function pinned() { return read().pinned; } function state() { const value = read(); const pendingCount = value.pending.length + value.pinOps.length; return {pending:pendingCount > 0, pendingCount}; } function scheduleSync() { if (!fetchJson || !key()) return false; if (debounceTimer) clearTimer(debounceTimer); debounceTimer = setTimer(() => { debounceTimer = null; void sync(); }, debounceMs); return true; } function record(item) { const accountKey = key(); const normalized = normalize(item); 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.some(existing => existing.route === normalized.route) ? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)] : current.pinned; current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit); if (!persist(current, accountKey)) return false; announce(current); render(); scheduleSync(); return true; } function queuePinOp(current, operation) { const route = operation.item?.route || operation.route; current.pinOps = [{...operation, operationId:operationId()}, ...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 = normalizePending(pending); const unsentPinOps = normalizePinOps(pinOps); const value = { items:normalizeList([...unsent, ...remote]), pinned:applyPinOps(remotePinned, unsentPinOps), pending:unsent, pinOps:unsentPinOps, }; persist(value, accountKey); announce(value); render(); return true; } async function drain(accountKey) { while (key() === accountKey) { const current = read(); 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 { let snapshot; if (sending) { snapshot = await fetchJson('api/v1/recent-work', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(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 = sending ? latest.pending.filter(item => item.operationId !== sending.operationId) : 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 || operation.operationId !== pinOperation.operationId; }) : latest.pinOps; if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.'); retryAttempt = 0; } catch (_error) { if (key() === accountKey) { announce(read()); scheduleRetry(accountKey); } return read(); } } return read(); } function sync() { if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; } const accountKey = key(); if (retryTimer && retryAccount !== accountKey) clearRetry(true); else if (retryTimer) clearRetry(false); if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read()); if (syncFlight && syncAccount === accountKey) return syncFlight; syncAccount = accountKey; syncFlight = drain(accountKey).finally(() => { if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; } }); return syncFlight; } async function load() { const accountKey = key(); if (!fetchJson || !accountKey) return read(); try { const snapshot = await fetchJson('api/v1/recent-work'); if (key() !== accountKey) return read(); const current = read(); adopt(snapshot, accountKey, current.pending, current.pinOps); return hasPending(current) ? sync() : read(); } catch (_error) { if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.'); return read(); } } function startLifecycle(lifecycle = {}) { const reconcile = () => hasPending(read()) ? sync() : load(); lifecycle.window?.addEventListener?.('online', () => { void reconcile(); }); lifecycle.document?.addEventListener?.('visibilitychange', () => { if (!lifecycle.document.hidden) void reconcile(); }); return reconcile; } function detail(item) { return item.kind === 'update' ? 'Update · #' + 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 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', () => { if (isPinned) record(item); 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 fixedRoutes = new Set(fixed.map(item => item.route)); const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route)); const list = options.list; const section = options.section; if (list && section && options.document) { const rows = visibleRecent.map(item => row(item, false)); list.replaceChildren(...rows); section.hidden = rows.length === 0; } if (options.pinnedList && options.pinnedSection && options.document) { const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3); const rows = visiblePins.map(item => row(item, true)); options.pinnedList.replaceChildren(...rows); options.pinnedSection.hidden = rows.length === 0; } if (options.pinnedToggle) { options.pinnedToggle.hidden = fixed.length <= 3; options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length; options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false'); options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list'); } return visibleRecent.length + fixed.length; } return {items, pinned, record, pin, unpin, render, load, sync, startLifecycle, state}; });