feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(voting): auditoría de votos cerrados + podio refleja el resultado final - #49

Merged
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display
Jun 30, 2026
Merged

feat(voting): auditoría de votos cerrados + podio refleja el resultado final#49
agustinkassis merged 1 commit into
mainfrom
feat/voting-closed-results-display

Conversation

@agustinkassis

@agustinkassisagustinkassis commented Jun 30, 2026

Copy link
Copy Markdown
Member

Continúa el trabajo de votación comunitaria ya mergeado (#47, #48). Tres mejoras al cierre de la votación, verificadas end-to-end en dev (relay local).

1. El admin puede ver por quién votó cada uno — también con la votación CERRADA

Antes el revelado por votante solo funcionaba con la votación abierta.

  • route.ts: nueva acción admin reveal-ballots — descifra y devuelve los votos por persona igual que close-preview, pero permite el período cerrado (no publica nada).
  • useAdminLiveTally(hackathonId, closed): firma reveal-ballots cuando está cerrada, close-preview cuando está abierta.
  • VotingProvider: suscribe a ballots también cuando la votación está cerrada y sos admin, para que el padrón sepa quién votó (los usuarios normales no suscriben en cerrado).
  • VotingDetailModal: canReveal = isAdmin (antes excluía cerrado) + copy del footer.

Los votos siguen cifrados en el relay; el descifrado es server-side con LACRYPTA_NSEC, solo visible para el admin.

2. El podio del hero cerrado refleja el RESULTADO FINAL (70/30), no el voto popular

  • El ClosedHero arma las entradas del podio (PodiumEntry) desde results.final cuando hay jueces → orden = ranking combinado, métrica = score final ("21.1 pts"). Sin jueces cae al voto popular (métrica = "X votos").
  • PodiumCard / RunnerRow pasaron de VotingWinner a PodiumEntry con métrica pre-formateada.
  • La tabla "Resultado Final" detallada (desglose por juez + matemática 70/30) sigue debajo.

Ej.: ZapChess tenía más votos populares (1° popular) pero queda #4 en el final; SatArcade es el ganador real (21.1).

3. Se oculta la sección genérica "Premios" cuando hay resultado publicado

app/hackathons/[id]/page.tsx — cuando la votación cerró con resultado (resultsPublished), se oculta la card "Premios" (estructura genérica 250k/400k/…) porque el resultado final ya muestra el premio de cada proyecto.

Verificación

  • tsc --noEmit sin errores.
  • Probado: votación cerrada → padrón muestra 5/14 votaron → revelar descifra (SatArcade 3 / Lightning Runner 2) → podio ordenado por final → sección Premios oculta.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Admins can now reveal and review individual ballots after voting is closed.
    • Final results now display a definitive combined ranking when available.
  • Bug Fixes

    • Improved closed-voting behavior so admins can still inspect ballot details after the vote ends.
    • The awards section is hidden once published results are available, reducing duplicate or outdated views.

…o final
Tres mejoras al cierre de la votación comunitaria:
- Admin puede ver por quién votó cada uno también CON LA VOTACIÓN CERRADA.
Nueva acción `reveal-ballots` (descifra los ballots igual que `close-preview`
pero permite período cerrado, sin publicar nada); `useAdminLiveTally(closed)`
firma la acción correcta; VotingProvider suscribe a ballots cuando está
cerrada y sos admin (para saber quién votó); el modal de padrón habilita el
revelado y ajusta el copy.
- El podio del hero cerrado refleja el RESULTADO FINAL combinado (70% popular /
30% jueces) en vez del voto popular: las entradas se arman desde
`results.final` (orden = ranking final, métrica = score final "pts"); sin
jueces cae al voto popular (métrica = votos). La tabla detallada sigue debajo.
- Se oculta la sección genérica "Premios" de la página del hackatón cuando la
votación cerró con resultado publicado (el premio ya se ve por proyecto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
lacrypta-devReadyReadyPreview, CommentJun 30, 2026 10:41pm

Request Review

@coderabbitai

coderabbitaiBot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an admin-only "reveal-ballots" voting action that decrypts per-voter ballots after results are frozen, without publishing. Updates the API route, admin live-tally hook, voting UI subscriptions/messaging, hackathon page results visibility, and closed-results podium rendering to use a combined final-results metric.

Changes

Admin reveal-after-close feature

Layer / File(s)Summary
Reveal-ballots API action
app/api/hackathons/[id]/voting/route.ts
Adds REVEAL_ACTION constant, authorizes it in the POST admin allowlist, and shares decrypt+tally preview logic between close-preview (requires open status) and reveal-ballots (works regardless of open status, requires existing period).
Admin live tally hook supports closed mode
lib/useAdminLiveTally.ts
Hook signature gains a closed parameter; derives "reveal-ballots" vs "close-preview" action name, updates NIP-98 signed request content/tags accordingly, and adds action to the refresh dependency array.
Voting UI reveal flow after close
app/hackathons/[id]/VotingSection.tsx
VotingProvider subscribes to live ballots for admins even when voting is closed via a new watchBallots condition; VotingDetailModal loosens canReveal to admin-only (removing the "not closed" restriction), passes closed to the tally hook, and updates footer copy for admin reveal during closed voting.
Suppress Premios section on published results
app/hackathons/[id]/page.tsx
Computes resultsPublished from voting period status/results and conditionally hides the generic Premios card when results are already published.
Closed hero podium uses combined final results
components/voting/VotingHero.tsx
ClosedHero derives podium/runner entries from period.results.final (combined metric, points) when present, falling back to period.results.winners (vote counts); introduces a local PodiumEntry type and updates PodiumCard/RunnerRow to use entry.projectId/projectName/metric/position instead of winner.* fields.

Sequence Diagram(s)

sequenceDiagram
participant Admin
participant VotingDetailModal
participant useAdminLiveTally
participant VotingAPI
Admin->>VotingDetailModal: open modal (voting closed)
VotingDetailModal->>VotingDetailModal: canReveal = isAdmin
VotingDetailModal->>useAdminLiveTally: call(hackathonId, closed=true)
useAdminLiveTally->>VotingAPI: POST action=reveal-ballots
VotingAPI-->>useAdminLiveTally: decrypted ballot preview
useAdminLiveTally-->>VotingDetailModal: tally data
VotingDetailModal-->>Admin: render decrypted ballots
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#41: Both PRs refactor closed-results podium rendering in components/voting/VotingHero.tsx toward a PodiumEntry-based structure with final-results handling.
  • lacrypta/lacrypta-dev#44: Both PRs modify app/hackathons/[id]/VotingSection.tsx and lib/useAdminLiveTally.ts to change how admins fetch decrypted tally data after close.

Poem

A rabbit peeked behind the veil so tight,
"Reveal the votes, but keep it out of sight!"
🥕 Hop, decrypt, the ballots softly sing,
No publish yet — just one admin's peering.
Podiums recount with final scores combined,
Thump thump, my friends — the truth, refined! 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 37.50% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title matches the main changes: closed-vote auditing and the hero podium using the final result.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-closed-results-display

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agustinkassis
agustinkassis merged commit a673f74 into mainJun 30, 2026
2 of 3 checks passed
@agustinkassis
agustinkassis deleted the feat/voting-closed-results-display branch June 30, 2026 22:48

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/hackathons/[id]/voting/route.ts (1)

811-832: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the frozen close time for reveal-ballots.buildClosePreview(..., Math.floor(Date.now() / 1000)) lets reveal-ballots admit ballots that were cast after the voting period was actually closed, so the audit view can diverge from period.results. Pass existing.period.closedAt for the reveal path instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/api/hackathons/`[id]/voting/route.ts around lines 811 - 832, The
close-preview/reveal branch in the voting route currently always uses the
current timestamp when calling buildClosePreview, which can make reveal-ballots
include votes cast after the period actually closed. Update the logic in the
action handling block for CLOSE_PREVIEW_ACTION and REVEAL_ACTION so that the
reveal path uses existing.period.closedAt as the reference time, while keeping
the preview path on the current time. Use the existing.period,
buildClosePreview, and REVEAL_ACTION symbols to locate and split the timestamp
passed into the preview builder.
🧹 Nitpick comments (2)
app/hackathons/[id]/page.tsx (1)

466-500: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing awards/badge data when Premios is suppressed.

awards, prizeProfiles, prizeBadgeIssuerPubkey, prizeBadgeCatalog, and prizeSoldierRecipients (lines 466-500) are still computed unconditionally whenever status === "closed", even though the only place they're consumed — the Premios card — is now skipped entirely when resultsPublished is true (lines 582-695). This means getCachedHackathonBadgePublisherPubkey(), per-pubkey getCachedNostrProfile calls, and getSoldiers() run for no visible benefit once results are published.

Consider gating these computations on !resultsPublished as well.

♻️ Proposed refactor
 const prizeByProjectId = new Map(
- awards.map((a) => [a.project.id, a] as const),+ awards.map((a) => [a.project.id, a] as const),
);
const nostrSubmissions = (await getNostrHackathonSubmissions(id)).map(
fromCachedNostrSubmission,
);
const prizeBadgeIssuerPubkey =
- status === "closed"+ status === "closed" && !resultsPublished
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
: "";
const prizeBadgeCatalog =
- status === "closed" && prizeBadgeIssuerPubkey+ status === "closed" && !resultsPublished && prizeBadgeIssuerPubkey
? buildHackathonBadgeCatalogContent(
prizeBadgeIssuerPubkey,
id,
hackathon.name,
)
: null;
const needsSoldierRecipientLookup =
- status === "closed" &&+ status === "closed" &&+ !resultsPublished &&
awards.some((award) => !primaryProjectPubkey(award.project));

Also applies to: 582-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/page.tsx around lines 466 - 500, Gate the
Premios-related data loading in the hackathon page so it only runs when the card
can actually render. In the page component that computes `awards`,
`prizeProfiles`, `prizeBadgeIssuerPubkey`, `prizeBadgeCatalog`, and
`prizeSoldierRecipients`, add the same `!resultsPublished` check used by the
Premios card before calling `getCachedHackathonBadgePublisherPubkey`,
`getCachedNostrProfile`, and `getSoldiers`. Keep the existing `status ===
"closed"` logic, but skip all of these lookups when results are already
published.
lib/useAdminLiveTally.ts (1)

37-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider sharing the action-name constants with the API route instead of re-deriving the literal strings.

"close-preview" / "reveal-ballots" are independently hardcoded here while the server defines CLOSE_PREVIEW_ACTION/REVEAL_ACTION as local constants in app/api/hackathons/[id]/voting/route.ts. Exporting these from a shared module (e.g. @/lib/voting) and importing on both sides would let TypeScript catch a future rename instead of silently breaking the client/server contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useAdminLiveTally.ts` around lines 37 - 44, The action names in
useAdminLiveTally are duplicated as raw strings, so update the client and API
route to use shared exported constants instead of re-deriving "close-preview"
and "reveal-ballots". Move CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common
module such as `@/lib/voting`, then import those values in useAdminLiveTally and
in the voting route so TypeScript can enforce the client/server contract and
catch future renames.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 802-806: The admin tally in VotingSection/VotingDetailModal is not
refreshed when the period status changes from open to closed, so stale perVoter
data can persist across the transition. Update the logic around
useAdminLiveTally and the modal’s open/closed handling so that a closed-state
flip triggers a refresh or clears the existing tally when data is already
loaded; use the canReveal, tally, and VotingDetailModal flow to locate the state
transition hook.
In `@components/voting/VotingHero.tsx`:
- Around line 600-612: The hero copy in VotingHero.tsx is still hardcoded to say
the winners “se llevaron los votos … de la comunidad” even when hasFinal is true
and the podium is based on the combined finalScore result. Update the
user-facing sentence in the VotingHero rendering logic to be conditional on
hasFinal, so the final-score mode mentions the combined popular/judge result in
Spanish while the fallback continues to reference votes; use the existing
hasFinal and period.results.final symbols to locate the copy.
---
Outside diff comments:
In `@app/api/hackathons/`[id]/voting/route.ts:
- Around line 811-832: The close-preview/reveal branch in the voting route
currently always uses the current timestamp when calling buildClosePreview,
which can make reveal-ballots include votes cast after the period actually
closed. Update the logic in the action handling block for CLOSE_PREVIEW_ACTION
and REVEAL_ACTION so that the reveal path uses existing.period.closedAt as the
reference time, while keeping the preview path on the current time. Use the
existing.period, buildClosePreview, and REVEAL_ACTION symbols to locate and
split the timestamp passed into the preview builder.
---
Nitpick comments:
In `@app/hackathons/`[id]/page.tsx:
- Around line 466-500: Gate the Premios-related data loading in the hackathon
page so it only runs when the card can actually render. In the page component
that computes `awards`, `prizeProfiles`, `prizeBadgeIssuerPubkey`,
`prizeBadgeCatalog`, and `prizeSoldierRecipients`, add the same
`!resultsPublished` check used by the Premios card before calling
`getCachedHackathonBadgePublisherPubkey`, `getCachedNostrProfile`, and
`getSoldiers`. Keep the existing `status === "closed"` logic, but skip all of
these lookups when results are already published.
In `@lib/useAdminLiveTally.ts`:
- Around line 37-44: The action names in useAdminLiveTally are duplicated as raw
strings, so update the client and API route to use shared exported constants
instead of re-deriving "close-preview" and "reveal-ballots". Move
CLOSE_PREVIEW_ACTION and REVEAL_ACTION into a common module such as
`@/lib/voting`, then import those values in useAdminLiveTally and in the voting
route so TypeScript can enforce the client/server contract and catch future
renames.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82b2639e-dc9d-4916-8b0a-3204ca6a256e

📥 Commits

Reviewing files that changed from the base of the PR and between e4e5d07 and 63abfcb.

📒 Files selected for processing (5)
  • app/api/hackathons/[id]/voting/route.ts
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • components/voting/VotingHero.tsx
  • lib/useAdminLiveTally.ts

Comment on lines +802 to +806
// encrypted, so one decrypt round-trip (admin-gated, no publish) loads every
// voter's breakdown; we only fetch it the first time a voter is opened. Works
// both while open (`close-preview`) and after close (`reveal-ballots`).
const canReveal = isAdmin;
const tally = useAdminLiveTally(period.hackathonId, closed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Check whether VotingDetailModal stays mounted across open/closed transitions# (i.e. is it conditionally rendered/unmounted on close, which would naturally reset hook state?)
rg -n "VotingDetailModal" app/hackathons -B5 -A10

Repository: lacrypta/lacrypta-dev

Length of output: 3096


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the relevant hook and modal implementation to determine whether the data can go stale on closed-state changes.
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook and its callers around the relevant lines.
grep -n "function useAdminLiveTally\|toggleReveal\|refresh()\|perVoter\|results\|action\|closed""$FILE"| sed -n '1,120p'echoecho"---- VotingDetailModal section ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9129


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Confirm whether the modal is conditionally mounted and whether closed changes unmount/remount it.
FILE="app/hackathons/[id]/VotingSection.tsx"
sed -n '680,770p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 3374


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal implementation.
awk ' /function useAdminLiveTally|const canReveal|function VotingDetailModal|toggleReveal|refresh\(\)|perVoter|results|action|closed/ {print NR ":" $0}'"$FILE"| sed -n '1,220p'echoecho"---- around VotingDetailModal ----"
sed -n '774,930p'"$FILE"echoecho"---- around modal mount in parent ----"
sed -n '700,725p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 9912


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook, toggle logic, and modal mounting behavior.
rg -n "function useAdminLiveTally|toggleReveal|refresh\(\)|perVoter|results|action|closed|VotingDetailModal""$FILE" -A6 -B6
echoecho"---- modal parent mount ----"
sed -n '700,725p'"$FILE"echoecho"---- modal implementation ----"
sed -n '774,930p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 23087


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the live tally hook implementation and its dependencies.
sed -n '1,170p'"$FILE"echo
sed -n '170,430p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 13348


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Show the hook definition and the refresh logic with enough surrounding context.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Check whether the hook's request key includes `closed` or only the action name.
rg -n "useAdminLiveTally|close-preview|reveal-ballots|refresh:|refresh\(|action:""$FILE" -A4 -B4

Repository: lacrypta/lacrypta-dev

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="app/hackathons/[id]/VotingSection.tsx"# Inspect the hook implementation and surrounding helpers to determine whether# a closed/open transition changes the fetched action or can leave cached data in place.
sed -n '430,620p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 5684


🏁 Script executed:

#!/bin/bashset -euo pipefail
FILE="lib/useAdminLiveTally.ts"# Show the hook implementation and any refresh/request-key logic.
sed -n '1,260p'"$FILE"

Repository: lacrypta/lacrypta-dev

Length of output: 4177


Refresh the admin tally when the period status flips

VotingDetailModal stays mounted across the open→closed transition, but useAdminLiveTally() only refreshes on the first row expansion. If an admin already loaded perVoter during close-preview, the modal can keep showing that pre-close snapshot after the UI switches to reveal-ballots. Trigger a refresh on closed changes when tally data already exists, or clear the loaded tally on status change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 802 - 806, The admin
tally in VotingSection/VotingDetailModal is not refreshed when the period status
changes from open to closed, so stale perVoter data can persist across the
transition. Update the logic around useAdminLiveTally and the modal’s
open/closed handling so that a closed-state flip triggers a refresh or clears
the existing tally when data is already loaded; use the canReveal, tally, and
VotingDetailModal flow to locate the state transition hook.

Comment on lines +600 to +612
// The podium reflects the DEFINITIVE result: the combined 70/30 ranking when
// judges' scores were merged (headline metric = final score), else the raw
// popular vote (metric = votes). The detailed breakdown table renders below.
const hasFinal =
!!period.results?.final && period.results.final.length > 0;
// Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below.
const winners = (period.results?.winners ?? []).slice(0, 6);
const podium = winners.slice(0, 3);
const runnersUp = winners.slice(3, 6);
const entries: PodiumEntry[] = hasFinal
? period.results!.final!.slice(0, 6).map((r) => ({
position: r.position,
projectId: r.projectId,
projectName: r.name,
metric: `${r.finalScore.toFixed(1)} pts`,
}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the hero copy with the combined-score mode.

When hasFinal is true, the podium now shows the 70/30 final score, but the visible hero text still says these projects “se llevaron los votos … de la comunidad.” Consider making that sentence conditional so final-score results mention the combined popular/judge result instead of only votes. Keep the replacement user-facing copy in Spanish. As per coding guidelines, “User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/voting/VotingHero.tsx` around lines 600 - 612, The hero copy in
VotingHero.tsx is still hardcoded to say the winners “se llevaron los votos … de
la comunidad” even when hasFinal is true and the podium is based on the combined
finalScore result. Update the user-facing sentence in the VotingHero rendering
logic to be conditional on hasFinal, so the final-score mode mentions the
combined popular/judge result in Spanish while the fallback continues to
reference votes; use the existing hasFinal and period.results.final symbols to
locate the copy.

Source: Coding guidelines

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@agustinkassis