diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx index b5c4805..3e05b86 100644 --- a/app/hackathons/[id]/HackathonProjectsList.tsx +++ b/app/hackathons/[id]/HackathonProjectsList.tsx @@ -11,7 +11,6 @@ import { import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowRight, CircleDashed, ExternalLink, Loader2, @@ -613,7 +612,6 @@ function ProjectRow({ projectId={project.id} projectName={project.name} /> - ); diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx index 4821322..fc014e2 100644 --- a/app/hackathons/[id]/VotingSection.tsx +++ b/app/hackathons/[id]/VotingSection.tsx @@ -11,9 +11,11 @@ import { useState, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; import { CheckCircle2, + ChevronDown, Coins, Loader2, Lock, @@ -50,6 +52,11 @@ import { subscribeToBallots, subscribeToVotingPeriod, } from "@/lib/votingClient"; +import LiveTally from "@/components/voting/LiveTally"; +import { + useAdminLiveTally, + type AdminVoterAllocation, +} from "@/lib/useAdminLiveTally"; /** Shape of the close-preview the backend returns (decrypted, admin-only). */ type ClosePreviewData = { @@ -108,10 +115,24 @@ type VotingContextValue = { publishing: boolean; celebrate: boolean; hasPrev: boolean; + /** On-screen allocation differs from the published ballot — there are unsaved + * changes worth publishing. False right after load / publish. */ + isDirty: boolean; adjustProjectVote: (projectId: string, delta: number) => void; publishVotes: () => Promise; }; +/** Allocation maps are equal ignoring zero/missing entries. */ +function allocationsEqual( + a: Record, + b: Record, +): boolean { + const keysA = Object.keys(a).filter((k) => (a[k] ?? 0) > 0); + const keysB = Object.keys(b).filter((k) => (b[k] ?? 0) > 0); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => a[k] === b[k]); +} + const VotingContext = createContext(null); function useVotingContext() { @@ -353,6 +374,7 @@ export function VotingProvider({ const used = Object.values(allocations).reduce((sum, n) => sum + n, 0); const remaining = Math.max(0, maxVotes - used); const hasPrev = (ownBallotEvent?.created_at ?? 0) > 0 || !!ownAllocations; + const isDirty = !allocationsEqual(allocations, ownAllocations ?? {}); const resetKey = `${period?.openedAt ?? 0}:${auth?.pubkey ?? ""}`; const ownAllocationsKey = JSON.stringify(ownAllocations ?? {}); @@ -369,6 +391,15 @@ export function VotingProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ownAllocationsKey]); + // Once the editor matches the published ballot again (the user reverted their + // edits, or just published), clear the edit flag so later authoritative ballot + // updates resume syncing into this tab. Only ever clears — adjustProjectVote + // sets it — so a genuine ownAllocations change (isDirty flips true while the + // editor is stale) still lets the sync effect above run. + useEffect(() => { + if (!isDirty) dirty.current = false; + }, [isDirty]); + const adjustProjectVote = useCallback( (projectId: string, delta: number) => { if (!voter || publishing || blocked.includes(projectId)) return; @@ -469,6 +500,7 @@ export function VotingProvider({ publishing, celebrate, hasPrev, + isDirty, adjustProjectVote, publishVotes, }} @@ -631,7 +663,7 @@ function VotingSectionInner() { {period.status === "closed" && results && ( <> - + {results.winners && results.winners.length > 0 && ( `. Renders nothing for a non-admin before voting + * has opened. + */ +export function HackathonVotingActions() { + const { period, isAdmin, admin, voterRows, totals } = useVotingContext(); + const [detailOpen, setDetailOpen] = useState(false); + + if (!period && !isAdmin) return null; + + return ( + <> +
+ {period && ( + + )} + {isAdmin && } +
+ + {detailOpen && period && ( + setDetailOpen(false)} + /> + )} + + ); +} + /* ───────────────────────── Detail modal ───────────────────────── */ function VotingDetailModal({ @@ -684,7 +760,46 @@ function VotingDetailModal({ onClose: () => void; }) { useScrollLock(true); - return ( + // SSR-safe portal: stay null on the server and the first client render, then + // mount into after hydration. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + // Admins can reveal each voter's actual allocations on demand. Ballots are + // encrypted, so one `close-preview` decrypt (admin-gated, no publish) loads + // every voter's breakdown; we only fetch it the first time a voter is opened. + // Only available while open — once closed the round can't be previewed. + const canReveal = isAdmin && !closed; + const tally = useAdminLiveTally(period.hackathonId); + const [expanded, setExpanded] = useState>(new Set()); + const projectNames = useMemo( + () => new Map(period.projects.map((p) => [p.id, p.name])), + [period.projects], + ); + const perVoterMap = useMemo(() => { + const map = new Map(); + for (const v of tally.perVoter ?? []) map.set(v.pubkey.toLowerCase(), v); + return map; + }, [tally.perVoter]); + + const toggleReveal = useCallback( + (pubkey: string) => { + if (!tally.perVoter && !tally.loading) void tally.refresh(); + setExpanded((prev) => { + const key = pubkey.toLowerCase(); + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, + [tally], + ); + + if (!mounted) return null; + // Portal to : the hero folds this in under a transformed, + // overflow-hidden motion.div, which would otherwise clip a fixed child. + return createPortal(
    - {rows.map((r) => ( -
  • -
    - - - {r.name} - - - {r.used}/{r.maxVotes} votos declarados · {r.remaining}{" "} - restante{r.remaining === 1 ? "" : "s"} - - - {r.voted ? ( - - - VOTÓ - - ) : ( - - SIN VOTAR + {rows.map((r) => { + const isOpen = expanded.has(r.pubkey.toLowerCase()); + const revealable = canReveal && r.voted; + return ( +
  • +
    + + + {r.name} + + + {r.used}/{r.maxVotes} votos declarados · {r.remaining}{" "} + restante{r.remaining === 1 ? "" : "s"} + + {r.voted ? ( + revealable ? ( + + ) : ( + + + VOTÓ + + ) + ) : ( + + SIN VOTAR + + )} +
    + + {revealable && isOpen && ( + )} -
- - ))} + + ); + })} @@ -772,12 +915,71 @@ function VotingDetailModal({ {closed ? "Resultados congelados y publicados en Nostr." - : "Los votos están cifrados — el detalle (qué votó cada uno) se revela al cerrar la votación."} + : canReveal + ? "Tocá “VOTÓ” para descifrar qué votó cada quien — solo lo ves vos, no se publica nada." + : "Los votos están cifrados — el detalle (qué votó cada uno) se revela al cerrar la votación."} {" · "} {period.projects.length} proyectos votables - + , + document.body, + ); +} + +/** Admin-only expanded breakdown of one voter's decrypted allocations. */ +function VoterBallotBreakdown({ + voter, + loading, + error, + projectNames, +}: { + voter: AdminVoterAllocation | null; + loading: boolean; + error: string | null; + projectNames: Map; +}) { + if (loading) { + return ( +
+ + Descifrando voto… +
+ ); + } + if (error) { + return ( +
+ {error} +
+ ); + } + const entries = Object.entries(voter?.allocations ?? {}) + .filter(([, n]) => n > 0) + .sort((a, b) => b[1] - a[1]); + if (!voter || entries.length === 0) { + return ( +
+ No se pudo descifrar este voto. +
+ ); + } + return ( +
    + {entries.map(([projectId, count]) => ( +
  • + + {projectNames.get(projectId) ?? projectId} + + + {count} {count === 1 ? "voto" : "votos"} + +
  • + ))} +
); } @@ -1117,12 +1319,17 @@ function CloseReviewModal({ onConfirm: () => void; }) { useScrollLock(true); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); const projectName = useMemo( () => new Map(period.projects.map((p) => [p.id, p.name])), [period.projects], ); const counted = preview.countedBallotIds.length; - return ( + if (!mounted) return null; + // Portal to : same reason as the padrón modal — the admin controls now + // live inside the hero's transformed, overflow-hidden card. + return createPortal(
- + , + document.body, ); } @@ -1341,27 +1549,32 @@ export function ProjectVotingToolbar() { )} - + {/* Save-changes affordance: surfaced only when the on-screen ballot + differs from what's published. Stays visible (but disabled) for an + empty edit so a ballot cleared to zero isn't silently unsavable. */} + {voting.isDirty && ( + + )} ); @@ -1434,70 +1647,6 @@ export function ProjectVotingControls({ /* ───────────────────────── Tally board ───────────────────────── */ -function TallyBoard({ - results, - closed, -}: { - results: VotingResults; - closed: boolean; -}) { - const max = Math.max(1, ...results.tally.map((r) => r.votes)); - return ( -
-
- - {closed ? "Resultados finales" : "Resultados en vivo"} - - - {results.ballotsCounted}{" "} - {results.ballotsCounted === 1 ? "votante" : "votantes"} ·{" "} - {results.totalVotesCast} votos - -
-
    - {results.tally.map((row, i) => { - const leader = closed && i === 0 && row.votes > 0; - return ( -
  1. -
    -
    -
    - {leader && } - - {row.name} - - - {row.votes} - -
    -
    -
  2. - ); - })} -
-
- ); -} - /* ───────────────────────── Winners (post-close) ───────────────────────── */ const MEDAL = ["🥇", "🥈", "🥉"]; diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 73fdef9..0f41a80 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -50,7 +50,7 @@ import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; import { getCachedVotingPeriod } from "@/lib/votingCache"; import { nostrVotingTag } from "@/lib/nostrCacheTags"; import HackathonProjectsList from "./HackathonProjectsList"; -import VotingSection, { VotingProvider } from "./VotingSection"; +import { VotingProvider, HackathonVotingActions } from "./VotingSection"; import VotingHero from "@/components/voting/VotingHero"; import HackathonResultsClient from "./HackathonResultsClient"; import AdminBadgesLink from "./AdminBadgesLink"; @@ -698,31 +698,31 @@ export default async function HackathonPage({ * the SSR skeleton; client takes over on hydration. */} {/* Community voting hero — promotes the vote; "Votar ahora" scrolls to - * the projects list (#votar), where the ballot controls live. Renders - * nothing until voting has been opened at least once. */} - -
- -
-
- + * the projects list (#votar), where the ballot controls live. The + * "Ver padrón" + admin controls are folded into the hero via `actions` + * (they need the VotingProvider context, so the hero lives inside it). + * Renders nothing until voting has been opened at least once (admins see + * the open-voting control). */} +
+ } + /> +
+ - -
diff --git a/app/page.tsx b/app/page.tsx index 15e5962..3b4f073 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,14 +20,12 @@ async function HomeVotingHero() { const period = await getCachedVotingPeriod(gaming.id); if (!period) return null; return ( -
- -
+ ); } diff --git a/components/voting/LiveTally.tsx b/components/voting/LiveTally.tsx new file mode 100644 index 0000000..0ad9e82 --- /dev/null +++ b/components/voting/LiveTally.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Trophy } from "lucide-react"; +import type { VotingResults } from "@/lib/voting"; +import { cn } from "@/lib/cn"; + +/** + * Ranked vote standings bar list. Shared by the hackathon `VotingSection` + * (live while open + final once closed) and the home admin hero so the two + * never drift. `closed` switches the header label and highlights the leader. + */ +export default function LiveTally({ + results, + closed, +}: { + results: VotingResults; + closed: boolean; +}) { + const max = Math.max(1, ...results.tally.map((r) => r.votes)); + return ( +
+
+ + {closed ? "Resultados finales" : "Resultados en vivo"} + + + {results.ballotsCounted}{" "} + {results.ballotsCounted === 1 ? "votante" : "votantes"} ·{" "} + {results.totalVotesCast} votos + +
+
    + {results.tally.map((row, i) => { + const leader = closed && i === 0 && row.votes > 0; + return ( +
  1. +
    +
    +
    + {leader && } + + {row.name} + + + {row.votes} + +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/components/voting/VotingHero.tsx b/components/voting/VotingHero.tsx index 6354fe7..6f92f00 100644 --- a/components/voting/VotingHero.tsx +++ b/components/voting/VotingHero.tsx @@ -5,10 +5,13 @@ import { useCallback } from "react"; import { motion } from "framer-motion"; import { ArrowRight, + BarChart3, CheckCircle2, Coins, + Loader2, LogIn, Radio, + ShieldCheck, Sparkles, Trophy, Vote, @@ -16,12 +19,14 @@ import { import { useAuth } from "@/lib/auth"; import { hackathonSlugForId, prizeForPosition, formatSats } from "@/lib/hackathons"; import { useVotingLive } from "@/lib/useVotingLive"; +import { useAdminLiveTally } from "@/lib/useAdminLiveTally"; import { VOTES_PER_HACKATHON, type VotingPeriod, type VotingWinner, } from "@/lib/voting"; import { cn } from "@/lib/cn"; +import LiveTally from "@/components/voting/LiveTally"; /** * Gamified, live "community voting" hero. Reused on the home page and the @@ -34,15 +39,20 @@ export default function VotingHero({ hackathonName, initialPeriod, variant, + actions, }: { hackathonId: string; hackathonName: string; initialPeriod: VotingPeriod | null; variant: "home" | "page"; + /** Page-only action bar (Ver padrón + admin controls), folded into the hero. + * Must be rendered inside the VotingProvider — only passed on the hackathon + * page (`variant="page"`). */ + actions?: React.ReactNode; }) { const { ready } = useAuth(); const live = useVotingLive(hackathonId, initialPeriod); - const { period } = live; + const { period, viewer } = live; const slug = hackathonSlugForId(hackathonId); // On the hackathon page the CTA scrolls to the ballot; on home it links there. @@ -59,10 +69,51 @@ export default function VotingHero({ [variant], ); - if (!period) return null; + if (!period) { + // On the hackathon page the admin still needs the action bar before the + // first round exists (to open voting). Everyone else sees nothing. + if (variant === "page" && actions && live.isAdmin) { + return ; + } + return null; + } + + // On the home page, default to a generic "voting in progress" hero (linking to + // the hackathon) while the viewer's state resolves in the background. Swap to + // the full ballot hero ONLY once we've confirmed they're eligible and still + // have votes to spend; otherwise the "in progress" hero stays put. + // (The closed/results state still shows for all.) + if (variant === "home" && period.status === "open") { + // The La Crypta admin oversees the round rather than voting in it — give + // them the live standings (decrypted on demand) instead of the ballot. + if (live.isAdmin) { + return ( + + ); + } + const hasVotesAvailable = + !live.loading && viewer.eligible && viewer.remaining > 0; + if (!hasVotesAvailable) { + return ( + + ); + } + } return ( -
+
+ {/* Page-only action bar (Ver padrón + admin controls), folded in from + the old voting section card. */} + {variant === "page" && actions && ( +
+ {actions} +
+ )} + {period.status === "open" ? ( +
+
+
+
+ +

+ Votación comunitaria +

+
+
+ {actions} +
+
+
+
+
+ ); +} + +/* ─────────────────── In-progress (home default) ─────────────── */ + +/** + * Generic "voting in progress" hero shown on the home page by default — while + * the viewer's eligibility/budget is still loading, and afterwards whenever they + * have no votes available. Links through to the hackathon. The full ballot hero + * (`OpenHero`) only replaces it once the viewer is confirmed to have votes left. + */ +function HomeVotingInProgress({ + hackathonName, + ballotHref, +}: { + hackathonName: string; + ballotHref: string; +}) { + return ( +
+
+ + {/* Animated backdrop */} +
+
+
+
+
+ +
+
+
+ + + + + Votación en progreso +
+ +

+ La comunidad está{" "} + votando a los + ganadores de {hackathonName} +

+

+ Seguí la votación en vivo y mirá cómo se reparten los votos de la + comunidad. +

+
+ +
+ {}} + tone="nostr" + label="Ver la votación" + icon={} + /> +
+
+ +
+
+ ); +} + +/* ───────────────────── Admin standings (home) ───────────────── */ + +/** + * Admin-only home hero shown while voting is open. The La Crypta admin oversees + * the round, so instead of a ballot they get the live participation gauge plus + * the current decrypted standings — fetched on demand via the `close-preview` + * action (admin-gated, decrypted server-side, publishes nothing). + */ +function HomeVotingAdmin({ + hackathonId, + hackathonName, + ballotHref, + live, +}: { + hackathonId: string; + hackathonName: string; + ballotHref: string; + live: ReturnType; +}) { + const tally = useAdminLiveTally(hackathonId); + const { votedCount, eligibleCount, progressPct } = live; + + return ( +
+
+ + {/* Animated backdrop */} +
+
+
+
+
+ +
+
+
+ + Panel admin · Votación abierta +
+ +

+ Así viene la votación de{" "} + {hackathonName} +

+

+ Los votos están cifrados en los relays. Descifralos vos para ver + el conteo actual — no se publica nada hasta que cierres la + votación. +

+ +
+ + + Abrir panel completo + + +
+ + {tally.error && ( +

+ {tally.error} +

+ )} + + {tally.results ? ( + + ) : ( + !tally.loading && ( +

+ Tocá “Ver votos actuales” para descifrar y ver el conteo en + vivo. +

+ ) + )} +
+ + {/* Live participation gauge (no decryption needed) */} + +
+ +
+
+ ); +} + /* ───────────────────────── Open state ───────────────────────── */ function OpenHero({ @@ -173,7 +442,7 @@ function OpenHero({ /> - Cierre manual de La Crypta — no hay reloj, ¡votá tranqui! + Cierra el Martes 30 de Junio diff --git a/lib/useAdminLiveTally.ts b/lib/useAdminLiveTally.ts new file mode 100644 index 0000000..c3a1320 --- /dev/null +++ b/lib/useAdminLiveTally.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useAuth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import type { VotingResults } from "@/lib/voting"; + +/** A single voter's decrypted ballot, as returned by `close-preview`. */ +export type AdminVoterAllocation = { + pubkey: string; + name: string; + allocations: Record; + total: number; +}; + +export type AdminLiveTally = { + /** Decrypted live standings, or null until the admin loads them. */ + results: VotingResults | null; + /** Per-voter decrypted allocations, or null until loaded. */ + perVoter: AdminVoterAllocation[] | null; + /** A fetch/sign round-trip is in flight. */ + loading: boolean; + error: string | null; + /** Sign a `close-preview` request and fetch the current (decrypted) tally. + * Admin-gated server-side; decrypts with LACRYPTA_NSEC and PUBLISHES NOTHING. */ + refresh: () => Promise; +}; + +/** + * Live vote standings for the La Crypta admin while voting is still open. + * + * Ballots are NIP-44 encrypted, so the client can't tally them — only the + * backend `close-preview` action decrypts and counts. It requires the admin to + * sign a NIP-98 (kind 27235) request but never writes to any relay, so it's + * safe to call repeatedly to peek at how the voting is going. + */ +export function useAdminLiveTally(hackathonId: string): AdminLiveTally { + const { auth } = useAuth(); + const [results, setResults] = useState(null); + const [perVoter, setPerVoter] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // In-flight guard kept in a ref so `refresh` depends only on auth/hackathonId + // (a stale `loading` closure would defeat the guard otherwise). + const inFlight = useRef(false); + + const refresh = useCallback(async () => { + if (!auth || inFlight.current) return; + inFlight.current = true; + setLoading(true); + setError(null); + try { + const signer = await getSigner(auth); + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: "close-preview · votación comunitaria", + tags: [ + ["u", `/api/hackathons/${hackathonId}/voting`], + ["method", "POST"], + ["action", "close-preview"], + ["h", hackathonId], + ], + }); + // No client ballot snapshot: let the server do its own authoritative + // relay fetch (fetchBallotEvents) so a slow relay can't undercount the + // preview. This is a read-only peek — unlike the real close it doesn't + // need to freeze the exact ballot set. + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + preview?: { tally: VotingResults; perVoter?: AdminVoterAllocation[] }; + error?: string; + }; + if (!res.ok || !data.ok || !data.preview) { + throw new Error(data.error || "No se pudo obtener la votación actual."); + } + setResults(data.preview.tally); + setPerVoter(data.preview.perVoter ?? []); + } catch (err) { + // Don't keep presenting a stale tally as current once a refresh fails. + setResults(null); + setPerVoter(null); + setError(err instanceof Error ? err.message : "Error desconocido."); + } finally { + inFlight.current = false; + setLoading(false); + } + }, [auth, hackathonId]); + + return { results, perVoter, loading, error, refresh }; +} diff --git a/lib/useVotingLive.ts b/lib/useVotingLive.ts index 8aa2bcf..42d9fda 100644 --- a/lib/useVotingLive.ts +++ b/lib/useVotingLive.ts @@ -29,6 +29,9 @@ export type ViewerVotingState = { export type VotingLive = { period: VotingPeriod | null; pubkeys: Pubkeys; + /** Viewer voting state not yet settled: auth still resolving, or (when + * eligible) the ballot backlog hasn't arrived so `remaining` is unreliable. */ + loading: boolean; /** Distinct eligible voters who have published a ballot. */ votedCount: number; /** Size of the frozen eligible snapshot. */ @@ -50,8 +53,9 @@ export function useVotingLive( hackathonId: string, initialPeriod: VotingPeriod | null, ): VotingLive { - const { auth } = useAuth(); + const { auth, ready } = useAuth(); const [period, setPeriod] = useState(initialPeriod); + const [ballotsLoaded, setBallotsLoaded] = useState(false); const [pubkeys, setPubkeys] = useState({ adminPubkey: null, publisherPubkey: null, @@ -119,23 +123,31 @@ export function useVotingLive( // Live ballots while open (for the participation progress). const votingOpen = period?.status === "open"; useEffect(() => { + // Drop any prior round's ballots so a hackathonId change / round restart + // doesn't compute hasVoted/used/remaining/progress from stale events. + setBallots(new Map()); + setBallotsLoaded(false); if (!votingOpen) return; - return subscribeToBallots(hackathonId, (ev) => { - setBallots((prev) => { - const key = ev.pubkey.toLowerCase(); - const existing = prev.get(key); - if ( - existing && - (existing.created_at > ev.created_at || - (existing.created_at === ev.created_at && existing.id <= ev.id)) - ) { - return prev; - } - const next = new Map(prev); - next.set(key, ev); - return next; - }); - }); + return subscribeToBallots( + hackathonId, + (ev) => { + setBallots((prev) => { + const key = ev.pubkey.toLowerCase(); + const existing = prev.get(key); + if ( + existing && + (existing.created_at > ev.created_at || + (existing.created_at === ev.created_at && existing.id <= ev.id)) + ) { + return prev; + } + const next = new Map(prev); + next.set(key, ev); + return next; + }); + }, + () => setBallotsLoaded(true), + ); }, [hackathonId, votingOpen]); const isAdmin = @@ -161,9 +173,16 @@ export function useVotingLive( const used = myBallot ? claimedVotes(myBallot) : 0; const maxVotes = me?.maxVotes ?? 0; + // Settled once auth is resolved and — if the viewer is eligible and voting + // is open — the ballot backlog has arrived (so `used`/`remaining` are real). + const votingOpenNow = period?.status === "open"; + const loading = + !ready || (votingOpenNow && !!me && !ballotsLoaded); + return { period, pubkeys, + loading, votedCount, eligibleCount, progressPct, @@ -176,5 +195,5 @@ export function useVotingLive( hasVoted: !!myBallot, }, }; - }, [period, pubkeys, ballots, auth?.pubkey, isAdmin]); + }, [period, pubkeys, ballots, auth?.pubkey, isAdmin, ready, ballotsLoaded]); } diff --git a/lib/votingClient.ts b/lib/votingClient.ts index e33105e..37dd629 100644 --- a/lib/votingClient.ts +++ b/lib/votingClient.ts @@ -194,6 +194,7 @@ export async function fetchAllBallotEvents( export function subscribeToBallots( hackathonId: string, onEvent: (ev: SignedEvent) => void, + onEose?: () => void, ): () => void { let closed = false; let teardown: (() => void) | null = null; @@ -221,6 +222,9 @@ export function subscribeToBallots( }, oneose() { // Keep the subscription open for live ballots — do not close here. + // Signal that the historical backlog has been delivered so callers + // can tell "still loading" from "loaded, zero ballots". + onEose?.(); }, }, );