stackchain-dashboard/frontend/following.js
timmy 12c0e18dfe
All checks were successful
CI / lint (pull_request) Successful in 3m34s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m34s
CI / release-candidate (pull_request) Has been skipped
feat: review Following during Prepare Today (Closes #1301)
2026-08-23 10:09:12 +00:00

198 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function (root, factory) {
const exports = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exports.createFollowing;
module.exports.attachFollowing = exports.attachFollowing;
} else {
root.createFollowing = exports.createFollowing;
root.attachFollowing = exports.attachFollowing;
}
})(typeof self !== 'undefined' ? self : this, function () {
function createFollowing(options) {
let generation = 0;
let snapshot = {revision:0, items:[]};
let review = null;
function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true;
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
if (review?.acknowledged.size) state.reviewSummary = {
reviewed:review.acknowledged.size,
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
};
if (error) state.error = error;
options.render?.(state);
if (status === 'ready') options.onCount?.(
snapshot.items.filter(item => item.has_unseen_change === true).length);
return state;
}
async function load() {
const requestGeneration = ++generation;
publish('loading');
try {
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
if (requestGeneration !== generation) return snapshot;
snapshot = {
revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [],
degraded:result?.degraded === true,
refreshFailures:Number(result?.refresh_failures) || 0,
};
publish('ready');
return snapshot;
} catch (error) {
if (requestGeneration === generation) publish('error', error);
throw error;
}
}
async function acknowledge(item) {
const current = snapshot.items.find(candidate =>
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current);
current.has_unseen_change = false;
review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready');
return true;
}
async function open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, kind:'issue'});
await acknowledge(item);
return true;
}
async function startReview() {
const items = snapshot.items
.filter(item => item.has_unseen_change === true)
.map(item => ({...item, kind:'issue'}));
if (!items.length) return false;
review = {items, more:false, active:true, acknowledged:new Set()};
await open(snapshot.items.indexOf(snapshot.items.find(item =>
item.repository === items[0].repository && Number(item.number) === Number(items[0].number))));
return true;
}
function finishReview() {
const completed = Boolean(review?.active &&
!snapshot.items.some(item => item.has_unseen_change === true));
if (review) review.active = false;
if (completed && !review.completed) {
review.completed = true;
options.onReviewComplete?.();
}
publish('ready');
return completed;
}
return {
load, open, startReview, previewLoaded:acknowledge, finishReview,
session:() => review?.active ? {items:[...review.items], more:false} : null,
items:() => snapshot.items.map(item => ({...item})),
count:() => snapshot.items.length,
};
}
function attachFollowing(onOpen, hooks = {}) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
const formatTime = value => new Date(value).toLocaleString();
const fetchJson = async (url, options) => {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
return payload;
};
let feature;
function render(state) {
hooks.onStatus?.(state.status);
const list = query('#following-list');
const status = query('#following-status');
const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
: 'No watched issues yet. Watch one from Search to keep it here.');
list.innerHTML = state.items.map((item, index) =>
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
'" type="button" data-following-index="' + index + '"><span>' +
(item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true"></span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
query('#following-sheet').close();
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
}));
}
feature = createFollowing({
fetchJson, render,
onCount:count => {
const value = query('[data-mobile-queue-count="following"]');
value.textContent = count;
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
(count === 1 ? ' unseen change' : ' unseen changes'));
hooks.onCount?.(count, feature.items());
},
onOpen,
onReviewComplete:hooks.onReviewComplete,
onAcknowledge:item => {
const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/seen', {
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
body:JSON.stringify({updated_at:item.updated_at}),
});
},
});
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
query('#review-following').addEventListener('click', () => {
query('#following-sheet').close();
feature.startReview().catch(() => {
if (!query('#following-sheet').open) query('#following-sheet').showModal();
});
});
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
return {
load:feature.load,
review:feature.startReview,
open() {
const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal();
feature.load().catch(() => {});
return 'opened-following';
},
session:feature.session,
previewLoaded:feature.previewLoaded,
returnToFollowing() {
const completed = feature.finishReview();
if (completed) return 'completed-following';
const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal();
globalThis.requestAnimationFrame?.(() => {
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
target?.focus();
});
return 'returned-following';
},
};
}
return {createFollowing, attachFollowing};
});