diff --git a/frontend/src/components/voting/VotingResultsChart.astro b/frontend/src/components/voting/VotingResultsChart.astro index c2216c5..e46eb1c 100644 --- a/frontend/src/components/voting/VotingResultsChart.astro +++ b/frontend/src/components/voting/VotingResultsChart.astro @@ -4,13 +4,14 @@ * Supports all voting methods with method-specific visualizations */ interface Props { - proposalId; + proposalId: string | number; + apiBase?: string; } -const { proposalId } = Astro.props; +const { proposalId, apiBase = '' } = Astro.props; --- -
+
@@ -36,18 +37,60 @@ const { proposalId } = Astro.props; diff --git a/frontend/src/pages/proposals/[id].astro b/frontend/src/pages/proposals/[id].astro index bb62c5e..5a61e5a 100644 --- a/frontend/src/pages/proposals/[id].astro +++ b/frontend/src/pages/proposals/[id].astro @@ -2,13 +2,19 @@ export const prerender = false; import Layout from '../../layouts/Layout.astro'; import { API_BASE as apiBase } from '../../lib/api'; +import VotingResultsChart from '../../components/voting/VotingResultsChart.astro'; const { id } = Astro.params; --- -
-
-

Loading proposal...

+
+
+
+

Loading proposal…

+
+
@@ -21,6 +27,31 @@ const { id } = Astro.params; let quadraticAllocations = {}; let starRatings = {}; + function escapeHtml(value) { + return String(value || '').replace(/[&<>"']/g, function(ch) { + switch (ch) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case "'": return '''; + default: return ch; + } + }); + } + + function normalizeStatus(status) { + const s = String(status || '').toLowerCase(); + if (s === 'draft' || s === 'discussion' || s === 'voting' || s === 'closed') return s; + return 'draft'; + } + + function normalizeMethod(method) { + const m = String(method || '').toLowerCase(); + if (m === 'approval' || m === 'ranked_choice' || m === 'quadratic' || m === 'star' || m === 'schulze') return m; + return 'approval'; + } + function getVotingHint(method) { const hints = { 'approval': 'Click options to select, then submit your vote', @@ -42,124 +73,166 @@ const { id } = Astro.params; const data = await res.json(); currentProposal = data; - const isDraft = data.proposal.status === 'draft'; - const isDiscussion = data.proposal.status === 'discussion'; - const isVoting = data.proposal.status === 'voting'; - const isClosed = data.proposal.status === 'closed'; + const statusKey = normalizeStatus(data.proposal.status); + const methodKey = normalizeMethod(data.proposal.voting_method); + const isDraft = statusKey === 'draft'; + const isDiscussion = statusKey === 'discussion'; + const isVoting = statusKey === 'voting'; + const isClosed = statusKey === 'closed'; const canStartDiscussion = token && isDraft; const canStartVoting = token && (isDraft || isDiscussion); const canCloseVoting = token && isVoting; + const safeTitle = escapeHtml(data.proposal.title); + const safeAuthor = escapeHtml(data.author_name); + const safeDescription = escapeHtml(data.proposal.description); + const safeMethodLabel = escapeHtml(methodKey.replace('_', ' ')); + container.innerHTML = ` -
-

${data.proposal.title}

- ${data.proposal.status} -
-

- by ${data.author_name} - on ${new Date(data.proposal.created_at).toLocaleDateString()} - ${data.proposal.voting_starts_at ? ` | Voting started: ${new Date(data.proposal.voting_starts_at).toLocaleDateString()}` : ''} - ${data.proposal.voting_ends_at ? ` | Ends: ${new Date(data.proposal.voting_ends_at).toLocaleDateString()}` : ''} -

-
${data.proposal.description}
- -
-

Options

-
- ${(() => { - const maxVotes = Math.max(...data.options.map(o => o.vote_count)); - const hasVotes = maxVotes > 0; - const method = data.proposal.voting_method; - return data.options.map(opt => { - const isWinner = isClosed && hasVotes && opt.vote_count === maxVotes; - let votingUI = ''; - if (isVoting && token) { - if (method === 'quadratic') { - votingUI = `
- - 0 - -
`; - } else if (method === 'star') { - votingUI = `
- ${[1,2,3,4,5].map(s => ``).join('')} -
`; - } else if (method === 'ranked_choice') { - votingUI = ``; - } - } - return ` -
-
- ${opt.label} ${isWinner ? '🏆' : ''} - ${opt.description ? `${opt.description}` : ''} -
- ${votingUI} -
${opt.vote_count} votes
-
- `; - }).join(''); - })()} -
-
- - ${isVoting && token ? ` -
-

${getVotingHint(data.proposal.voting_method)}

-

Method: ${data.proposal.voting_method.replace('_', ' ')}

- ${data.proposal.voting_method === 'quadratic' ? '

Credits used: 0/100

' : ''} - -
- ` : ''} - - ${canStartDiscussion ? ` -
- - - - -
- ` : ''} - - ${isDiscussion && token ? ` -
- - -
- ` : ''} - - ${canCloseVoting ? ` -
- -
- ` : ''} - - ${isClosed ? ` -
-

📊 Voting Results

-
-

Loading detailed results...

+
+
+
+

${safeTitle}

+
+ ${escapeHtml(statusKey)} + + by ${safeAuthor} + on ${new Date(data.proposal.created_at).toLocaleDateString()} + ${data.proposal.voting_starts_at ? ` | Voting started: ${new Date(data.proposal.voting_starts_at).toLocaleDateString()}` : ''} + ${data.proposal.voting_ends_at ? ` | Ends: ${new Date(data.proposal.voting_ends_at).toLocaleDateString()}` : ''} + +
- ` : ''} +
${safeDescription}
+
- ${!token && isVoting ? ` - - ` : ''} +
+
+ + Options + Your choices + +
+
+ ${(() => { + const maxVotes = Math.max(...data.options.map(o => o.vote_count)); + const hasVotes = maxVotes > 0; + const method = methodKey; + return data.options.map(opt => { + const isWinner = isClosed && hasVotes && opt.vote_count === maxVotes; + let votingUI = ''; + if (isVoting && token) { + if (method === 'quadratic') { + votingUI = `
+ + 0 + +
`; + } else if (method === 'star') { + votingUI = `
+ ${[1,2,3,4,5].map(s => ``).join('')} +
`; + } else if (method === 'ranked_choice') { + votingUI = ``; + } + } -
-

Discussion

- ${token ? ` -
- - -
- ` : ``} -
-

Loading comments...

-
+ const safeLabel = escapeHtml(opt.label); + const safeOptDesc = opt.description ? escapeHtml(opt.description) : ''; + + return ` +
+
+ ${safeLabel} ${isWinner ? '🏆' : ''} + ${safeOptDesc ? `${safeOptDesc}` : ''} +
+ ${votingUI} +
${opt.vote_count} votes
+
+ `; + }).join(''); + })()} +
+ + ${isVoting && token ? ` +
+

${escapeHtml(getVotingHint(methodKey))}

+

Method: ${safeMethodLabel}

+ ${methodKey === 'quadratic' ? '

Credits used: 0/100

' : ''} + +
+ ` : ''} + + ${!token && isVoting ? ` + + ` : ''} +
+
+ + ${canStartDiscussion ? ` +
+
+ + + + +
+
+ ` : ''} + + ${isDiscussion && token ? ` +
+
+ + +
+
+ ` : ''} + + ${canCloseVoting ? ` +
+
+ +
+
+ ` : ''} + + ${isClosed ? ` +
+ + Voting Results + Summary + method details + +
+
+

Loading results…

+
+
+
+ ` : ''} + +
+ + Discussion + Comments + +
+ ${token ? ` +
+ +
+ +
+
+ ` : ``} +
+

Loading comments...

+
+
+
`; @@ -258,142 +331,26 @@ const { id } = Astro.params; try { if (!token) { - container.innerHTML = '

Login required to view detailed results

'; + container.innerHTML = '

Login required to view results

'; return; } - const res = await fetch(`${apiBase}/api/proposals/${id}/results`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - if (!res.ok) { - container.innerHTML = '

Could not load detailed results

'; + const host = document.getElementById('results-chart-host'); + const chart = host?.querySelector('.results-chart'); + if (!host || !chart) { + container.innerHTML = '

Results chart unavailable

'; return; } - const data = await res.json(); - const method = currentProposal?.proposal?.voting_method || 'approval'; - - let resultsHTML = ` -
-
- ${data.total_voters || 0} - Total Voters -
-
- ${data.total_votes || 0} - Total Votes -
- ${data.winner ? ` -
- 🏆 ${data.winner.label} - Winner -
- ` : ''} -
- `; + host.style.display = 'block'; + host.setAttribute('aria-hidden', 'false'); - // Method-specific details - if (method === 'ranked_choice' && data.rounds) { - resultsHTML += ` -
-

Instant Runoff Rounds

-
- ${data.rounds.map((round, i) => ` -
- Round ${i + 1} -
    - ${round.counts.map((c) => ` -
  • ${c.label}: ${c.votes} votes ${c.eliminated ? '(eliminated)' : ''}
  • - `).join('')} -
-
- `).join('')} -
-
- `; - } else if (method === 'schulze' && data.pairwise_matrix) { - resultsHTML += ` -
-

Schulze Method - Pairwise Comparison

-

Winner determined by Condorcet-consistent pairwise comparison

-
- ${data.ranking?.map((r, i) => ` -
- #${i + 1} - ${r.label} - ${r.score} wins -
- `).join('') || ''} -
-
- `; - } else if (method === 'star' && data.scores) { - resultsHTML += ` -
-

STAR Voting Results

-

Score Then Automatic Runoff

-
- ${data.scores.map((s) => ` -
- ${s.label} -
-
-
- ${s.average.toFixed(2)} avg (${s.total} total) -
- `).join('')} -
- ${data.runoff ? ` -
-
Automatic Runoff
-

${data.runoff.winner} vs ${data.runoff.runner_up}: ${data.runoff.winner_votes} to ${data.runoff.runner_up_votes}

-
- ` : ''} -
- `; - } else if (method === 'quadratic' && data.allocations) { - resultsHTML += ` -
-

Quadratic Voting Results

-

Credits spent: cost = votes² (intensity-weighted)

-
- ${data.allocations.map((a) => ` -
- ${a.label} -
-
-
- ${a.effective_votes} effective votes (${a.total_credits} credits) -
- `).join('')} -
-
- `; - } else { - // Default approval voting display - resultsHTML += ` -
-

Approval Voting Results

-
- ${(data.options || currentProposal?.options || []).sort((a, b) => b.vote_count - a.vote_count).map((o) => { - const maxVotes = Math.max(...(data.options || currentProposal?.options || []).map((x) => x.vote_count)); - const pct = maxVotes > 0 ? (o.vote_count / maxVotes) * 100 : 0; - return ` -
- ${o.label} -
-
-
- ${o.vote_count} votes -
- `; - }).join('')} -
-
- `; + if (!container.contains(chart)) { + container.innerHTML = ''; + container.appendChild(chart); } - container.innerHTML = resultsHTML; + chart.dataset.enabled = '1'; } catch (error) { container.innerHTML = '

Error loading results

'; } @@ -561,15 +518,20 @@ const { id } = Astro.params; return; } - container.innerHTML = comments.map(c => ` -
-
- ${c.author_name} - ${new Date(c.created_at).toLocaleString()} + container.innerHTML = comments.map(c => { + const authorName = escapeHtml(c.author_name); + const authorHref = `/users/${encodeURIComponent(String(c.author_name))}`; + const content = escapeHtml(c.content); + return ` +
+
+ ${authorName} + ${new Date(c.created_at).toLocaleString()} +
+
${content}
-
${c.content}
-
- `).join(''); + `; + }).join(''); } catch (e) { container.innerHTML = '

Failed to load comments

'; } @@ -607,21 +569,82 @@ const { id } = Astro.params;