From 8b42eaa8f2f8ec99b637c28ea7ebf492748383e8 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 10 Jul 2026 19:45:49 -0300 Subject: [PATCH 1/3] feat(hackathons): reparto de premios por escasez, regla 1-premio-por-persona y fix del marcador de timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prizedProjects: cuando participan menos proyectos que slots de premio, ahora entrega los premios más chicos primero (3 proyectos → 100k/60k/40k) en vez de tomar los slots grandes y dejar parte del pozo sin asignar. Anclado desde el final con `slots.length - ranked.length`; el caso normal (6+ proyectos) y los empates no cambian. - /hackathons: letra chica bajo la grilla de premios + modal explicativo (PrizeRulesNote) con el ejemplo de 3 proyectos. - Regla nueva compartida por todas las páginas de hackatón: "Podés sumar varios proyectos, pero solo uno por persona puede ganar premio." - Timeline: el marcador de "hoy" queda anclado al centro del nodo activo y viaja hacia el siguiente a medida que transcurre el hackatón, en vez de arrancar en el borde izquierdo de su celda (quedaba a la izquierda del punto activo). Co-Authored-By: Claude Opus 4.8 --- app/hackathons/PrizeRulesNote.tsx | 132 ++++++++++++++++++++++++++++++ app/hackathons/page.tsx | 14 +++- data/hackathons/hackathons.json | 1 + lib/hackathons.ts | 7 +- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 app/hackathons/PrizeRulesNote.tsx diff --git a/app/hackathons/PrizeRulesNote.tsx b/app/hackathons/PrizeRulesNote.tsx new file mode 100644 index 0000000..5431a17 --- /dev/null +++ b/app/hackathons/PrizeRulesNote.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Info, X } from "lucide-react"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { PROGRAM, formatSats } from "@/lib/hackathons"; + +/** + * Fine print under the prize-structure grid: explains the partial-podium + * rule (fewer participating projects than prize slots → the smallest prizes + * go out first) and opens a modal with a worked example. Kept as its own + * client component so the surrounding page.tsx stays a server component. + */ +export default function PrizeRulesNote() { + const [open, setOpen] = useState(false); + useScrollLock(open); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + const slots = [...PROGRAM.prizeDistribution].sort( + (a, b) => a.position - b.position, + ); + const example = slots.slice(-3); + + return ( + <> +

+ * Si hay menos proyectos participantes que premios, se entregan los + premios más chicos primero.{" "} + +

+ + + {open && ( + + setOpen(false)} + /> + + +
+
+ + + +
+
+
+ +
+

+ Cómo se reparten los premios +

+
+ +
+

+ Cada hackatón reparte{" "} + {formatSats(PROGRAM.prizePerHackathon)} sats entre los + mejores {slots.length} proyectos, del 1° al {slots.length} + °. +

+

+ Si participan menos de {slots.length} proyectos, los + premios más chicos se entregan primero — así nadie se + lleva de más y no queda parte del pozo sin asignar. +

+ +
+

+ Ejemplo con 3 proyectos participando +

+
    + {example.map((slot, i) => ( +
  • + + {i + 1}° lugar + + + {formatSats(slot.sats)} sats + +
  • + ))} +
+
+
+
+ + + )} + + + ); +} diff --git a/app/hackathons/page.tsx b/app/hackathons/page.tsx index cd7c51e..3dce1d9 100644 --- a/app/hackathons/page.tsx +++ b/app/hackathons/page.tsx @@ -20,6 +20,7 @@ import HackathonTimeline, { type TimelineHackathon, } from "@/app/hackathons/HackathonTimeline"; import PilaresDisclosure from "@/app/hackathons/PilaresDisclosure"; +import PrizeRulesNote from "@/app/hackathons/PrizeRulesNote"; const DESCRIPTION = "Lightning Hackathons 2026 — 8 hackatones mensuales, 8M sats en premios. Bitcoin, Lightning, Nostr."; @@ -119,8 +120,13 @@ function buildTimeline(now: Date): { const initialIndex = activeIdx >= 0 ? activeIdx : firstUpcomingIdx >= 0 ? firstUpcomingIdx : n - 1; - // Elapsed fraction: closed nodes count as fully done; the active one is - // interpolated across its own date span. Each node owns a 1/n slice. + // Elapsed fraction: node dots sit at the CENTER of each 1/n slice + // (`(i + 0.5)/n`), so the marker must too. While a hackathon is active the + // marker travels from its own dot toward the next one as its date span + // elapses (f: 0→1 maps activeIdx's center → the next node's center), keeping + // the "today" pip on the live hackathon instead of lagging a half-slice + // behind it. Between hackathons it rests at the midpoint of the two dots, + // which `closedCount/n` already yields (a cell boundary == a dot midpoint). let todayPct: number; if (activeIdx >= 0) { const h = ordered[activeIdx]; @@ -130,7 +136,7 @@ function buildTimeline(now: Date): { const span = new Date(last).getTime() - new Date(first).getTime(); const into = new Date(today).getTime() - new Date(first).getTime(); const f = span > 0 ? Math.min(1, Math.max(0, into / span)) : 0.5; - todayPct = ((activeIdx + f) / n) * 100; + todayPct = ((activeIdx + 0.5 + f) / n) * 100; } else { const closedCount = items.filter((x) => x.status === "closed").length; todayPct = (closedCount / n) * 100; @@ -253,6 +259,8 @@ export default async function HackathonsPage() { Mirá las Community Calls en {PROGRAM.organization} YouTube )} + +
diff --git a/data/hackathons/hackathons.json b/data/hackathons/hackathons.json index f3b4e0f..f63619c 100644 --- a/data/hackathons/hackathons.json +++ b/data/hackathons/hackathons.json @@ -21,6 +21,7 @@ ], "rules": [ "Hasta {maxTeamSize} personas por equipo.", + "Podés sumar varios proyectos, pero solo uno por persona puede ganar premio.", "Inscripción abierta al primer sábado, cierre al tercero.", "Open source recomendado — sumá puntos por licencia.", "Evaluación por jurado AI: {judges}." diff --git a/lib/hackathons.ts b/lib/hackathons.ts index de8c569..b1aca71 100644 --- a/lib/hackathons.ts +++ b/lib/hackathons.ts @@ -427,6 +427,11 @@ export type PrizedProject = { * Walks the ranking and distributes the program's prize slots in order. * Ties share the combined value of the consecutive slots they occupy * (e.g. a tie at 1st consumes slots 1 and 2 and splits 400k+250k = 325k each). + * + * When fewer projects participate than there are prize slots, the smallest + * prizes go out first: with only 3 ranked projects out of 6 slots, 1st takes + * slot 4, 2nd takes slot 5, 3rd takes slot 6 (100k/60k/40k) rather than + * claiming the top slots and leaving the rest of the pool unawarded. */ export function prizedProjects(hackathonId: string): PrizedProject[] { const ranked = rankedProjects(hackathonId).filter( @@ -444,7 +449,7 @@ export function prizedProjects(hackathonId: string): PrizedProject[] { const positions = [...byPosition.keys()].sort((a, b) => a - b); const slots = PROGRAM.prizeDistribution; const result: PrizedProject[] = []; - let slotIdx = 0; + let slotIdx = Math.max(0, slots.length - ranked.length); for (const pos of positions) { const group = byPosition.get(pos)!; From 7233738425cbed02974f06094d8a6c4a35cc9872 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 10 Jul 2026 19:55:03 -0300 Subject: [PATCH 2/3] a11y(hackathons): dialog semantics + focus management en PrizeRulesNote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega role="dialog", aria-modal, aria-labelledby y tabIndex al panel del modal; id en el heading; aria-haspopup/aria-expanded en el trigger. Mueve el foco al diálogo al abrir y lo restaura al trigger al cerrar. Aborda el nitpick de accesibilidad de CodeRabbit. Co-Authored-By: Claude Opus 4.8 --- app/hackathons/PrizeRulesNote.tsx | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/app/hackathons/PrizeRulesNote.tsx b/app/hackathons/PrizeRulesNote.tsx index 5431a17..ecbd4b5 100644 --- a/app/hackathons/PrizeRulesNote.tsx +++ b/app/hackathons/PrizeRulesNote.tsx @@ -1,11 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Info, X } from "lucide-react"; import { useScrollLock } from "@/lib/useScrollLock"; import { PROGRAM, formatSats } from "@/lib/hackathons"; +const TITLE_ID = "prize-rules-modal-title"; + /** * Fine print under the prize-structure grid: explains the partial-podium * rule (fewer participating projects than prize slots → the smallest prizes @@ -14,15 +16,24 @@ import { PROGRAM, formatSats } from "@/lib/hackathons"; */ export default function PrizeRulesNote() { const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const dialogRef = useRef(null); useScrollLock(open); useEffect(() => { if (!open) return; + // Move focus into the dialog on open, and restore it to the trigger on + // close so keyboard users don't lose their place behind the overlay. + const trigger = triggerRef.current; + dialogRef.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + trigger?.focus(); + }; }, [open]); const slots = [...PROGRAM.prizeDistribution].sort( @@ -36,8 +47,11 @@ export default function PrizeRulesNote() { * Si hay menos proyectos participantes que premios, se entregan los premios más chicos primero.{" "}

@@ -103,42 +105,58 @@ export default function PrizeRulesNote() {

- Cómo se reparten los premios + Condiciones de los premios

-
+

Cada hackatón reparte{" "} {formatSats(PROGRAM.prizePerHackathon)} sats entre los mejores {slots.length} proyectos, del 1° al {slots.length} °.

-

- Si participan menos de {slots.length} proyectos, los - premios más chicos se entregan primero — así nadie se - lleva de más y no queda parte del pozo sin asignar. -

-
-

- Ejemplo con 3 proyectos participando +

+

+ Un premio por persona +

+

+ Cada persona puede ganar un solo premio. Si participás con + varios proyectos, solo se premia el mejor ranqueado; los + demás quedan fuera del reparto. +

+
+ +
+

+ Menos proyectos que premios +

+

+ Si participan menos de {slots.length} proyectos, los + premios más chicos se entregan primero — así nadie se + lleva de más y no queda parte del pozo sin asignar.

-
    - {example.map((slot, i) => ( -
  • - - {i + 1}° lugar - - - {formatSats(slot.sats)} sats - -
  • - ))} -
+
+

+ Ejemplo con 3 proyectos participando +

+
    + {example.map((slot, i) => ( +
  • + + {i + 1}° lugar + + + {formatSats(slot.sats)} sats + +
  • + ))} +
+
diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 50bec0f..5fbdcc5 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -30,7 +30,6 @@ import { getHackathon, hackathonSlug, hackathonStatus, - isHackathonInscriptionOpen, primaryProjectPubkey, prizedProjects, programRules, @@ -61,10 +60,10 @@ import HackathonTabs from "./HackathonTabs"; import { VotingProvider, HackathonVotingActions } from "./VotingSection"; import VotingHero from "@/components/voting/VotingHero"; import HackathonResultsClient from "./HackathonResultsClient"; +import PrizeRulesNote from "@/app/hackathons/PrizeRulesNote"; import AdminBadgesLink from "./AdminBadgesLink"; import PrizeBadgeButton, { type PrizeBadgeTask } from "./PrizeBadgeButton"; import PrizeZapButton from "@/components/voting/PrizeZapButton"; -import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; export function generateStaticParams() { // The dynamic segment is the public slug (falls back to id). @@ -589,11 +588,6 @@ export default async function HackathonPage({ ))}
- {isHackathonInscriptionOpen(hackathon) && ( -
- -
- )} @@ -761,6 +755,9 @@ export default async function HackathonPage({ /> )} +
+ +
)}