ux: add delegations insights panel

This commit is contained in:
Marco Allegretti 2026-01-29 18:28:20 +01:00
parent 22d932c4fc
commit 8d199d5eab

View file

@ -53,6 +53,20 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
</div> </div>
</details> </details>
<details class="panel ui-card" open>
<summary class="panel-summary">
<span class="panel-title">Insights</span>
<span class="panel-meta">
<span class="ui-badge" id="badge-delegation-insights">—</span>
</span>
</summary>
<div class="panel-body">
<div id="delegation-insights" class="panel-content">
<p class="loading">Loading...</p>
</div>
</div>
</details>
<div class="tabs ui-card"> <div class="tabs ui-card">
<button class="tab active" data-tab="outgoing" type="button">My Delegations</button> <button class="tab active" data-tab="outgoing" type="button">My Delegations</button>
<button class="tab" data-tab="incoming" type="button">Delegated to Me</button> <button class="tab" data-tab="incoming" type="button">Delegated to Me</button>
@ -400,6 +414,26 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.insights-body {
display: grid;
gap: 0.75rem;
}
.insights-title {
font-weight: 700;
letter-spacing: -0.01em;
}
.insights-list {
margin: 0;
padding-left: 1.1rem;
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.5;
display: grid;
gap: 0.35rem;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.delegate-card { .delegate-card {
flex-direction: column; flex-direction: column;
@ -417,6 +451,11 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
<script define:vars={{ apiBase }}> <script define:vars={{ apiBase }}>
const API_URL = apiBase; const API_URL = apiBase;
let token = localStorage.getItem('token'); let token = localStorage.getItem('token');
const delegationsState = {
outgoing: null,
incoming: null,
delegates: null,
};
function escapeHtml(value) { function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function(ch) { return String(value || '').replace(/[&<>"']/g, function(ch) {
@ -453,6 +492,100 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
if (el) el.textContent = String(value); if (el) el.textContent = String(value);
} }
function setBadge(id, value) {
const el = document.getElementById(id);
if (!el) return;
const str = String(value);
el.textContent = str;
el.setAttribute('aria-label', str);
}
function pluralize(label, count) {
return count === 1 ? label : `${label}s`;
}
function renderDelegationInsights() {
const container = document.getElementById('delegation-insights');
if (!container) return;
const outgoing = delegationsState.outgoing;
const incoming = delegationsState.incoming;
const delegates = delegationsState.delegates;
if (!outgoing || !incoming || !delegates) {
container.innerHTML = '<p class="loading">Loading...</p>';
return;
}
const outgoingCount = outgoing.length;
const incomingCount = incoming.length;
const delegatesCount = delegates.length;
const bullets = [];
if (outgoingCount === 0) {
bullets.push('You are currently voting directly (no outgoing delegations).');
} else {
bullets.push(`You delegate to ${outgoingCount} ${pluralize('person', outgoingCount)}.`);
}
if (incomingCount === 0) {
bullets.push('No one has delegated to you yet.');
} else {
bullets.push(`${incomingCount} ${pluralize('person', incomingCount)} delegated votes to you—check if any are community-scoped.`);
}
if (delegatesCount === 0) {
bullets.push('No delegates are available yet.');
} else {
bullets.push(`${delegatesCount} available ${pluralize('delegate', delegatesCount)} to choose from.`);
}
if (outgoingCount > 0 && incomingCount > 0) {
bullets.push('You both delegate and receive delegation—double-check your responsibilities before voting.');
}
const actions = [];
if (outgoingCount === 0) {
actions.push({ action: 'new', label: 'Create Delegation', kind: 'primary' });
} else {
actions.push({ action: 'tab-outgoing', label: 'Review My Delegations', kind: 'secondary' });
}
if (incomingCount > 0) {
actions.push({ action: 'tab-incoming', label: 'Review Incoming', kind: 'secondary' });
}
actions.push({ action: 'tab-delegates', label: 'Find Delegates', kind: 'secondary' });
setBadge('badge-delegation-insights', bullets.length);
container.innerHTML = `
<div class="insights-body">
<div class="insights-title">What stands out</div>
<ul class="insights-list">
${bullets.map((b) => `<li>${escapeHtml(b)}</li>`).join('')}
</ul>
<div class="panel-actions">
${actions.map((a) => `<button type="button" class="ui-btn ui-btn-${a.kind}" data-insight-action="${a.action}">${escapeHtml(a.label)}</button>`).join('')}
</div>
</div>
`;
container.querySelectorAll('[data-insight-action]').forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-insight-action') || '';
if (action === 'new') {
document.getElementById('new-delegation-btn-secondary')?.click();
return;
}
if (action.startsWith('tab-')) {
const tab = action.replace('tab-', '');
document.querySelector(`.tab[data-tab="${tab}"]`)?.click();
}
});
});
}
async function init() { async function init() {
if (!token) { if (!token) {
document.getElementById('auth-check').style.display = 'block'; document.getElementById('auth-check').style.display = 'block';
@ -461,6 +594,8 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
document.getElementById('auth-check').style.display = 'none'; document.getElementById('auth-check').style.display = 'none';
document.getElementById('delegations-content').style.display = 'block'; document.getElementById('delegations-content').style.display = 'block';
renderDelegationInsights();
document.getElementById('new-delegation-btn')?.setAttribute('style', 'display:inline-flex;'); document.getElementById('new-delegation-btn')?.setAttribute('style', 'display:inline-flex;');
@ -531,8 +666,10 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
const delegations = await res.json(); const delegations = await res.json();
delegationsState.outgoing = delegations;
setText('kpi-outgoing', delegations.length); setText('kpi-outgoing', delegations.length);
renderDelegationInsights();
if (delegations.length === 0) { if (delegations.length === 0) {
container.innerHTML = '<div class="empty-state">No active delegations. Create one to delegate your voting power.</div>'; container.innerHTML = '<div class="empty-state">No active delegations. Create one to delegate your voting power.</div>';
@ -581,8 +718,10 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
const delegations = await res.json(); const delegations = await res.json();
delegationsState.incoming = delegations;
setText('kpi-incoming', delegations.length); setText('kpi-incoming', delegations.length);
renderDelegationInsights();
if (delegations.length === 0) { if (delegations.length === 0) {
container.innerHTML = '<div class="empty-state">No one has delegated votes to you yet.</div>'; container.innerHTML = '<div class="empty-state">No one has delegated votes to you yet.</div>';
@ -618,8 +757,10 @@ import DelegationGraph from '../components/voting/DelegationGraph.astro';
try { try {
const res = await fetch(`${API_URL}/api/delegates`); const res = await fetch(`${API_URL}/api/delegates`);
const delegates = await res.json(); const delegates = await res.json();
delegationsState.delegates = delegates;
setText('kpi-delegates', delegates.length); setText('kpi-delegates', delegates.length);
renderDelegationInsights();
if (delegates.length === 0) { if (delegates.length === 0) {
container.innerHTML = '<div class="empty-state">No delegates available yet.</div>'; container.innerHTML = '<div class="empty-state">No delegates available yet.</div>';