diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 7f3b62e..9ab6edb 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -392,6 +392,7 @@
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
+ let planningOwnerAccountKey = '';
let activeFlushLogin = '';
let rR = null;
function rRC() {
@@ -637,6 +638,7 @@
const humanGates = createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
+ getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
@@ -655,8 +657,9 @@
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
});
qs('#human-gates-list').addEventListener('click', event => {
- if (!event.target.closest('[data-human-gate-id]')) return;
- humanGates.reviewNext();
+ const card = event.target.closest('[data-human-gate-id]');
+ if (!card) return;
+ humanGates.select(card.dataset.humanGateId);
});
qs('#human-gate-detail').addEventListener('click', event => {
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
@@ -5564,6 +5567,8 @@
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
+ planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
+ String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
diff --git a/frontend/human-gates.js b/frontend/human-gates.js
index ad4a30e..052b668 100644
--- a/frontend/human-gates.js
+++ b/frontend/human-gates.js
@@ -1,6 +1,7 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
+ const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
@@ -8,11 +9,12 @@ function createHumanGates(options = {}) {
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
+ const decisionKeys = new Map();
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[character]);
- const cacheKey = () => 'stackchain.human-gates.v1:' + String(getLogin() || '').trim().toLowerCase();
+ const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
@@ -111,10 +113,22 @@ function createHumanGates(options = {}) {
return item;
}
+ function select(gateId) {
+ if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
+ const index = reviewSnapshot.findIndex(item => item.id === gateId);
+ if (index < 0) throw new Error('Gate is not in the current review snapshot.');
+ reviewIndex = index;
+ return reviewNext();
+ }
+
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
- function idempotencyKey(item, decision) {
+ function idempotencyKey(item, decision, payload) {
+ const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
+ if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
- return 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
+ const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
+ decisionKeys.set(operation, key);
+ return { operation, key };
}
async function decideAndNext(decision, values = {}) {
@@ -135,11 +149,13 @@ function createHumanGates(options = {}) {
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
+ const decisionKey = idempotencyKey(item, decision, payload);
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey(item, decision) },
+ headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
body: JSON.stringify(payload),
});
+ decisionKeys.delete(decisionKey.operation);
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
@@ -157,7 +173,8 @@ function createHumanGates(options = {}) {
}
return {
- load, open, reviewNext, decideAndNext, current,
+ load, open, reviewNext, select, decideAndNext, current,
+ restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
};
diff --git a/frontend/index.html b/frontend/index.html
index bd96993..ee574a3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -187,7 +187,7 @@
-
+