stackchain-dashboard/frontend/mobile-recent-work.js
timmy 94bd2cafba
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
feat: pin frequent mobile work (Closes #1477)
2026-08-27 18:34:01 +00:00

354 lines
13 KiB
JavaScript

(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;
let syncFlight = null;
let syncAccount = '';
let debounceTimer = null;
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)) {
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 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:normalizeList(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:normalizeList(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.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);
render();
scheduleSync();
return true;
}
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);
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(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.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();
}
}
return read();
}
function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key();
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', () => 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};
});