Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@
--font-mono: var(--font-jetbrains-mono);
}

* {
border-color: var(--border);
/* Default border color for every element. MUST stay inside `@layer base`:
Tailwind 4 emits border-color utilities into `@layer utilities`, and
unlayered CSS outranks every layer — so an unlayered `*` rule here silently
overrode `border-bitcoin/30`, `border-success/40`, `border-border-strong`
and every other accent border in the app. Inside `base` it only supersedes
preflight's `border: 0 solid` (currentColor) default, and any utility wins. */
@layer base {
* {
border-color: var(--border);
}
}

html {
Expand Down
67 changes: 65 additions & 2 deletions app/hackathons/HackathonTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ChevronRight,
Clock,
Flame,
FolderGit2,
Gauge,
Layers,
Radio,
Expand Down Expand Up @@ -40,8 +41,32 @@ export type TimelineHackathon = {
inscriptionOpen: boolean;
countdown: string | null;
sponsors: { name: string; logo: string }[];
/** Projects registered for this hackathon — curated + community submissions,
* deduped the same way the detail page's list is. `null` when the relay
* snapshot came back empty, i.e. the count is unknown rather than zero. */
projectCount: number | null;
};

/** "Sin proyectos" / "1 proyecto" / "N proyectos", or `null` when unknown —
* callers render nothing in that case rather than claiming a number. */
function projectCountLabel(count: number | null): string | null {
if (count === null) return null;
if (count === 0) return "Sin proyectos";
return `${count} ${count === 1 ? "proyecto" : "proyectos"}`;
}

/** Tooltip body for the project-count chip. Spanish agreement follows the
* count, so the singular case reads "1 proyecto anotado … para verlo". */
function projectCountTooltip(h: TimelineHackathon): string {
const n = h.projectCount ?? 0;
if (n > 0) {
return `${projectCountLabel(n)} ${n === 1 ? "anotado" : "anotados"} en este hackatón — abrí el detalle para ${n === 1 ? "verlo" : "verlos"}.`;
}
return h.status === "closed"
? "No se anotó ningún proyecto en esta edición."
: "Todavía no se anotó ningún proyecto. Podés ser el primero.";
}

const STATUS_META: Record<
TimelineHackathon["status"],
{ label: string; dot: string; text: string; ring: string }
Expand Down Expand Up @@ -162,13 +187,18 @@ export default function HackathonTimeline({
{items.map((h, i) => {
const meta = STATUS_META[h.status];
const selected = i === index;
const countLabel = projectCountLabel(h.projectCount);
return (
<button
key={h.id}
type="button"
onClick={() => goTo(i)}
aria-current={selected ? "true" : undefined}
aria-label={`${h.name} — ${meta.label}`}
aria-label={
countLabel
? `${h.name} — ${meta.label} — ${countLabel}`
: `${h.name} — ${meta.label}`
}
className="group relative flex flex-1 flex-col items-center gap-2 rounded-lg pt-[6px] outline-none focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{/* Dot — nudged up to sit centered on the rail line above. */}
Expand Down Expand Up @@ -367,6 +397,7 @@ function PeekCard({
onClick: () => void;
}) {
const meta = STATUS_META[h.status];
const countLabel = projectCountLabel(h.projectCount);
return (
<motion.button
type="button"
Expand Down Expand Up @@ -407,6 +438,20 @@ function PeekCard({
<div className="truncate text-[11px] font-mono uppercase tracking-wide text-foreground-muted">
{h.focus}
</div>
{countLabel && (
<div
className={cn(
// Deliberately not `text-foreground-subtle` even at zero: the peek
// card is already held at 0.78 opacity, which drags the subtle
// token under the AA contrast floor.
"mt-1.5 inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-wider text-foreground-muted",
side === "right" && "flex-row-reverse",
)}
>
<FolderGit2 className="h-3 w-3 shrink-0" />
<span className="tabular-nums">{countLabel}</span>
</div>
)}
</div>
<span
className={cn(
Expand Down Expand Up @@ -434,18 +479,27 @@ function InfoChip({
icon,
label,
tooltip,
strong = false,
}: {
icon: React.ReactNode;
label: string;
tooltip: React.ReactNode;
/** Pulls the chip forward (brighter text + border) — used for the project
* count, the one figure on the card that changes as people submit. */
strong?: boolean;
}) {
return (
<div className="group/chip relative">
{/* A button so the detail is reachable by keyboard/touch (focus), not
* only by hover. The tooltip reveals on hover OR focus-within. */}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-mono font-semibold uppercase tracking-wider text-foreground-muted outline-none transition-colors hover:border-border-strong hover:text-foreground focus-visible:border-border-strong focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-cyan/60"
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-mono font-semibold uppercase tracking-wider outline-none transition-colors hover:border-border-strong hover:text-foreground focus-visible:border-border-strong focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-cyan/60",
strong
? "border-border-strong text-foreground"
: "border-border text-foreground-muted",
)}
>
<span className="text-foreground-subtle">{icon}</span>
{label}
Expand All @@ -460,6 +514,7 @@ function InfoChip({
function StageCard({ h }: { h: TimelineHackathon }) {
const isActive = h.status === "active";
const isClosed = h.status === "closed";
const countLabel = projectCountLabel(h.projectCount);

const accent = isActive
? {
Expand Down Expand Up @@ -567,6 +622,14 @@ function StageCard({ h }: { h: TimelineHackathon }) {
{!isActive && !isClosed && <Clock className="h-3.5 w-3.5" />}
{accent.badgeLabel}
</span>
{countLabel && (
<InfoChip
icon={<FolderGit2 className="h-3.5 w-3.5" />}
label={countLabel}
strong={(h.projectCount ?? 0) > 0}
tooltip={projectCountTooltip(h)}
/>
)}
<InfoChip
icon={<Calendar className="h-3.5 w-3.5" />}
label={
Expand Down
27 changes: 7 additions & 20 deletions app/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import {
getHackathon,
hackathonSlug,
hackathonStatus,
mergeWithSubmissions,
primaryProjectPubkey,
prizedProjects,
programRules,
rankedProjects,
toHackathonSubmission,
type HackathonSubmission,
type Sponsor,
} from "@/lib/hackathons";
Expand Down Expand Up @@ -257,21 +258,6 @@ function soldierRecipientLookup(
return lookup;
}

type CachedHackathonSubmission = Awaited<
ReturnType<typeof getNostrHackathonSubmissions>
>[number];

function fromCachedNostrSubmission(
project: CachedHackathonSubmission,
): HackathonSubmission {
return {
...project,
nostrAuthor: project.author,
nostrEventId: project.eventId,
nostrCreatedAt: project.eventCreatedAt,
};
}

function SponsorStrip({ sponsors }: { sponsors: Sponsor[] }) {
if (sponsors.length === 0) return null;
if (sponsors.length === 1) return <SponsorHero sponsor={sponsors[0]} />;
Expand Down Expand Up @@ -475,9 +461,6 @@ export default async function HackathonPage({
votingPeriod?.status === "closed" && !!votingPeriod.results;
const status = hackathonStatus(hackathon);
const statusMeta = STATUS_META[status];
const projects = rankedProjects(id);
const total = projects.length;
const hasReports = projects.some((p) => p.report);
const awards = prizedProjects(id);
const prizePubkeys = [
...new Set(awards.map((a) => primaryProjectPubkey(a.project)).filter((pubkey): pubkey is string => !!pubkey)),
Expand All @@ -501,7 +484,11 @@ export default async function HackathonPage({
const nostrSubmissions = attachProjectSlugs(
cachedSubmissions,
projectRegistry,
).map(fromCachedNostrSubmission);
).map(toHackathonSubmission);
// Count what the list below actually renders (curated + community, deduped),
// not just the curated slice — /hackathons advertises this same merged number,
// so a curated-only tab badge would contradict the card the user clicked.
const total = mergeWithSubmissions(id, nostrSubmissions).length;
const prizeBadgeIssuerPubkey =
status === "closed"
? await getCachedHackathonBadgePublisherPubkey().catch(() => "")
Expand Down
42 changes: 40 additions & 2 deletions app/hackathons/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { cacheTag } from "next/cache";
import {
Calendar,
Zap,
Expand All @@ -14,7 +15,15 @@ import {
hackathonSlug,
hackathonStatus,
isHackathonInscriptionOpen,
mergeWithSubmissions,
toHackathonSubmission,
type HackathonSubmission,
} from "@/lib/hackathons";
import {
getNostrSubmissionsSnapshot,
NOSTR_PROJECTS_TAG,
NOSTR_SUBMISSIONS_TAG,
} from "@/lib/nostrCache";
import { cn } from "@/lib/cn";
import HackathonTimeline, {
type TimelineHackathon,
Expand Down Expand Up @@ -75,7 +84,11 @@ function startsInLabel(startISO: string, now: Date): string | null {
* hackathon (active, else soonest upcoming, else the last one) and the
* fraction of the program that has already elapsed (0–100), so the rail can
* draw a "today" marker even between two hackathons. */
function buildTimeline(now: Date): {
function buildTimeline(
now: Date,
submissions: HackathonSubmission[],
submissionsKnown: boolean,
): {
items: TimelineHackathon[];
initialIndex: number;
todayPct: number;
Expand All @@ -86,6 +99,9 @@ function buildTimeline(now: Date): {

const items: TimelineHackathon[] = ordered.map((h) => {
const status = hackathonStatus(h, now);
// Same merge the detail page renders (curated + community, deduped by
// id/repo/name), so the number here matches the list one click away.
const merged = mergeWithSubmissions(h.id, submissions).length;
// Sort dates to match hackathonStatus()/isHackathonInscriptionOpen(), which
// normalize ordering — keeps every timeline calc on the same boundaries.
const sortedDates = [...h.dates].sort((a, b) => a.date.localeCompare(b.date));
Expand All @@ -112,6 +128,11 @@ function buildTimeline(now: Date): {
inscriptionOpen: isHackathonInscriptionOpen(h, now),
countdown: status === "upcoming" && firstISO ? startsInLabel(firstISO, now) : null,
sponsors: (h.sponsors ?? []).map((s) => ({ name: s.name, logo: s.logo })),
// A count of zero derived from an empty snapshot is not a fact — see
// `submissionsKnown` below. Report it as unknown so the UI stays silent
// instead of asserting nobody entered. A curated hackathon still has a
// real number to show, so only the all-zero case goes null.
projectCount: merged > 0 || submissionsKnown ? merged : null,
};
});

Expand Down Expand Up @@ -147,8 +168,25 @@ function buildTimeline(now: Date): {

export default async function HackathonsPage() {
"use cache";
// The per-hackathon counts below read the shared relay snapshot, so this
// page's entry has to expire with it. The snapshot's own "use cache" tags do
// propagate up into this one, but declaring them here keeps the page's
// invalidation contract visible and survives that helper being refactored.
cacheTag(NOSTR_PROJECTS_TAG);
cacheTag(NOSTR_SUBMISSIONS_TAG);
const now = new Date();
const { items, initialIndex, todayPct } = buildTimeline(now);
const snapshot = await getNostrSubmissionsSnapshot();
const submissions = snapshot.projects.map(toHackathonSubmission);
// `buildSubmissionsSnapshot` swallows relay errors and returns an empty list,
// so "no projects" and "every relay timed out" look identical here. Unlike the
// detail page, this one has no client-side rescan to correct a bad read — so
// treat an empty snapshot as "unknown" rather than rendering "Sin proyectos".
const submissionsKnown = snapshot.projects.length > 0;
const { items, initialIndex, todayPct } = buildTimeline(
now,
submissions,
submissionsKnown,
);

return (
<>
Expand Down
26 changes: 26 additions & 0 deletions lib/hackathons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,32 @@ export type HackathonSubmission = HackathonProject & {
nostrCreatedAt?: number;
};

/** Event provenance carried by any project parsed out of a Nostr event.
* Both producers use these names — `lib/nostrCache.ts` (server, cached relay
* snapshot) and `lib/userProjects.ts` (client scan). */
export type NostrProjectProvenance = {
author: string;
eventId: string;
eventCreatedAt: number;
};

/**
* Re-labels a Nostr-sourced project's event provenance onto the
* `HackathonSubmission` fields `mergeWithSubmissions()` reads (it sorts
* community entries by `nostrCreatedAt`). Generic so extra fields the caller
* attached — e.g. the registry `slug` from `attachProjectSlugs` — survive.
*/
export function toHackathonSubmission<
T extends HackathonProject & NostrProjectProvenance,
>(project: T): T & HackathonSubmission {
return {
...project,
nostrAuthor: project.author,
nostrEventId: project.eventId,
nostrCreatedAt: project.eventCreatedAt,
};
}

export function comparableProjectName(name: string): string {
return name
.trim()
Expand Down