dashboard: question_added / question_resolved mutation events + client derived state

This commit is contained in:
müde 2026-05-17 13:33:02 +02:00
parent 56d615b51f
commit 1879b2f485
6 changed files with 175 additions and 15 deletions

View file

@ -477,15 +477,57 @@
root.append(ul);
}
function renderQuestions(s) {
// Derived question state — cold-loaded from /api/state, then mutated
// live by `question_added` / `question_resolved` dashboard events.
const QUESTION_HISTORY_LIMIT = 20;
const questionsState = { pending: [], history: [] };
function syncQuestionsFromSnapshot(s) {
questionsState.pending = (s.questions || []).slice();
questionsState.history = (s.question_history || []).slice();
}
function applyQuestionAdded(ev) {
if (questionsState.pending.some((q) => q.id === ev.id)) return;
questionsState.pending.push({
id: ev.id,
asker: ev.asker,
question: ev.question,
options: ev.options || [],
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
});
renderQuestions();
}
function applyQuestionResolved(ev) {
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
const existing = idx >= 0 ? questionsState.pending[idx] : null;
if (idx >= 0) questionsState.pending.splice(idx, 1);
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
renderQuestions();
}
function renderQuestions() {
const root = $('questions-section');
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
if (!s.questions || !s.questions.length) {
const pending = questionsState.pending;
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'no pending questions'));
}
const ul = el('ul', { class: 'questions' });
for (const q of s.questions) {
for (const q of pending) {
const li = el('li', { class: 'question' });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
@ -567,10 +609,10 @@
li.append(cancelForm);
ul.append(li);
}
if (s.questions && s.questions.length) root.append(ul);
if (pending.length) root.append(ul);
// Answered question history
const hist = s.question_history || [];
const hist = questionsState.history;
if (hist.length) {
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
@ -997,12 +1039,13 @@
const openDetails = snapshotOpenDetails();
renderContainers(s);
renderTombstones(s);
renderQuestions(s);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events
// mutate the stores directly and re-render without a snapshot
// refetch.
syncQuestionsFromSnapshot(s);
renderQuestions();
renderInbox();
// Sync the derived approvals store from the snapshot, then
// render. Live `approval_added` / `approval_resolved` events
// mutate the store directly and call renderApprovals() without
// a snapshot refetch.
syncApprovalsFromSnapshot(s);
renderApprovals();
renderMetaInputs(s);
@ -1069,6 +1112,8 @@
// for broker traffic, not state-change chatter).
approval_added: (ev) => { applyApprovalAdded(ev); },
approval_resolved: (ev) => { applyApprovalResolved(ev); },
question_added: (ev) => { applyQuestionAdded(ev); },
question_resolved: (ev) => { applyQuestionResolved(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and