From c50b9b045c856863ce11ff965a2d949437193811 Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:29:20 +0100 Subject: [PATCH] Add district leaderboard page --- app/districts/[id]/leaderboard/page.tsx | 119 ++++++++++++++++++ components/districts/countdown-timer.tsx | 30 +++++ .../open-stellar/district-event-overlay.tsx | 5 +- data/district-leaderboard-snapshots.json | 26 ++++ lib/gamification/district-leaderboard.ts | 80 ++++++++++++ lib/gamification/events.ts | 19 +-- 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 app/districts/[id]/leaderboard/page.tsx create mode 100644 components/districts/countdown-timer.tsx create mode 100644 data/district-leaderboard-snapshots.json create mode 100644 lib/gamification/district-leaderboard.ts diff --git a/app/districts/[id]/leaderboard/page.tsx b/app/districts/[id]/leaderboard/page.tsx new file mode 100644 index 00000000..88d057a6 --- /dev/null +++ b/app/districts/[id]/leaderboard/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import { CountdownTimer } from "@/components/districts/countdown-timer" +import { DISTRICTS, createAgents } from "@/lib/data" +import { getDistrictLeaderboard, getPreviousDistrictLeaderboardSnapshot } from "@/lib/gamification/district-leaderboard" +import { getActiveDistrictEvent } from "@/lib/gamification/events" +import type { DistrictId } from "@/lib/types" + +type DistrictLeaderboardPageProps = { + params: Promise<{ id: string }> +} + +export const dynamic = "force-dynamic" + +export async function generateMetadata({ params }: DistrictLeaderboardPageProps) { + const { id } = await params + const district = DISTRICTS.find((entry) => entry.id === id) + return { + title: district ? `${district.name} Leaderboard | Open Stellar` : "District Leaderboard | Open Stellar", + description: "Current-week Open Stellar district competition rankings.", + } +} + +export default async function DistrictLeaderboardPage({ params }: DistrictLeaderboardPageProps) { + const { id } = await params + const district = DISTRICTS.find((entry) => entry.id === id) + if (!district) notFound() + + const districtId = district.id as DistrictId + const agents = createAgents() + const event = getActiveDistrictEvent() + const entries = getDistrictLeaderboard(agents, districtId) + const previousSnapshot = getPreviousDistrictLeaderboardSnapshot(districtId) + + return ( +
+
+ + +
+

District competition

+
+
+

{district.name} Leaderboard

+

+ Top 10 agents ranked by this week's {event.challenge.name.toLowerCase()} score. Competition weeks reset every Sunday at 00:00 UTC. +

+
+
+

Ends at {new Date(event.endsAt).toUTCString()}

+ +
+
+
+ +
+
+
+

Current week rankings

+

Metric: {event.challenge.metric}

+
+ {event.challenge.scoreLabel} +
+ +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
RankAgent nameScoreTasks completed this weekDistrict
#{entry.rank}{entry.agentName}{entry.formattedScore}{entry.tasksCompletedThisWeek}{entry.districtName}
+
+
+ + {previousSnapshot && ( +
+

Previous week results

+

+ Week {previousSnapshot.weekIndex} · {previousSnapshot.challengeName} · ended {new Date(previousSnapshot.endedAt).toUTCString()} +

+
+ {previousSnapshot.entries.slice(0, 3).map((entry) => ( +
+
#{entry.rank}
+
{entry.agentName}
+
Score: {entry.formattedScore}
+
Tasks: {entry.tasksCompletedThisWeek}
+
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/components/districts/countdown-timer.tsx b/components/districts/countdown-timer.tsx new file mode 100644 index 00000000..40ffd1dd --- /dev/null +++ b/components/districts/countdown-timer.tsx @@ -0,0 +1,30 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" + +function formatRemaining(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + return `${days}d ${String(hours).padStart(2, "0")}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s` +} + +export function CountdownTimer({ endsAt }: { endsAt: string }) { + const endMs = useMemo(() => new Date(endsAt).getTime(), [endsAt]) + const [remainingMs, setRemainingMs] = useState(() => endMs - Date.now()) + + useEffect(() => { + const update = () => setRemainingMs(endMs - Date.now()) + update() + const id = window.setInterval(update, 1000) + return () => window.clearInterval(id) + }, [endMs]) + + return ( + + ) +} diff --git a/components/open-stellar/district-event-overlay.tsx b/components/open-stellar/district-event-overlay.tsx index a694c9e6..b139568f 100644 --- a/components/open-stellar/district-event-overlay.tsx +++ b/components/open-stellar/district-event-overlay.tsx @@ -1,5 +1,6 @@ "use client" +import Link from "next/link" import type { ActiveDistrictEvent, DistrictStanding } from "@/lib/gamification/events" interface DistrictEventOverlayProps { @@ -49,7 +50,7 @@ export function DistrictEventOverlay({ event, standings }: DistrictEventOverlayP
{standings.slice(0, 5).map((standing) => ( -
+
#{standing.rank}
@@ -68,7 +69,7 @@ export function DistrictEventOverlay({ event, standings }: DistrictEventOverlayP
{standing.formattedScore} {standing.multiplier > 1 ? `${standing.multiplier}×` : ""}
-
+ ))}
diff --git a/data/district-leaderboard-snapshots.json b/data/district-leaderboard-snapshots.json new file mode 100644 index 00000000..3ecab36a --- /dev/null +++ b/data/district-leaderboard-snapshots.json @@ -0,0 +1,26 @@ +[ + { + "weekIndex": 24, + "districtId": "data-center", + "districtName": "Data Center", + "endedAt": "2026-06-28T00:00:00.000Z", + "challengeName": "Skill Mastery", + "entries": [ + { "rank": 1, "agentId": "bot-0", "agentName": "Nexus-7", "score": 4, "formattedScore": "4.00", "tasksCompletedThisWeek": 43 }, + { "rank": 2, "agentId": "bot-10", "agentName": "Vertex-11", "score": 2.67, "formattedScore": "2.67", "tasksCompletedThisWeek": 31 }, + { "rank": 3, "agentId": "bot-5", "agentName": "Stratos-2", "score": 2.5, "formattedScore": "2.50", "tasksCompletedThisWeek": 24 } + ] + }, + { + "weekIndex": 24, + "districtId": "comm-hub", + "districtName": "Comm Hub", + "endedAt": "2026-06-28T00:00:00.000Z", + "challengeName": "Skill Mastery", + "entries": [ + { "rank": 1, "agentId": "bot-1", "agentName": "Cipher-3", "score": 3.5, "formattedScore": "3.50", "tasksCompletedThisWeek": 38 }, + { "rank": 2, "agentId": "bot-6", "agentName": "Bolt-8", "score": 2.25, "formattedScore": "2.25", "tasksCompletedThisWeek": 27 }, + { "rank": 3, "agentId": "bot-11", "agentName": "Echo-12", "score": 2, "formattedScore": "2.00", "tasksCompletedThisWeek": 20 } + ] + } +] diff --git a/lib/gamification/district-leaderboard.ts b/lib/gamification/district-leaderboard.ts new file mode 100644 index 00000000..35bbb39d --- /dev/null +++ b/lib/gamification/district-leaderboard.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" +import { DISTRICTS } from "@/lib/data" +import { formatDistrictScore, getActiveDistrictEvent, getAgentDistrictCompetitionScore, getWeekBounds, getWeeklyTasksCompleted } from "@/lib/gamification/events" +import type { DistrictId, MoltbotAgent } from "@/lib/types" + +export interface DistrictLeaderboardEntry { + rank: number + agentId: string + agentName: string + score: number + formattedScore: string + tasksCompletedThisWeek: number + districtId: DistrictId + districtName: string +} + +export interface DistrictLeaderboardSnapshot { + weekIndex: number + districtId: DistrictId + districtName: string + endedAt: string + challengeName: string + entries: Omit[] +} + +const SNAPSHOT_PATH = path.join(process.cwd(), "data", "district-leaderboard-snapshots.json") + +export function getDistrictLeaderboard(agents: MoltbotAgent[], districtId: DistrictId, now: Date = new Date()): DistrictLeaderboardEntry[] { + const event = getActiveDistrictEvent(now) + const district = DISTRICTS.find((entry) => entry.id === districtId) + const districtName = district?.name ?? districtId + + return agents + .filter((agent) => agent.district === districtId) + .map((agent) => { + const score = getAgentDistrictCompetitionScore(agent, event.challenge, event.weekIndex) + return { + rank: 0, + agentId: agent.id, + agentName: agent.name, + score: Number(score.toFixed(3)), + formattedScore: formatDistrictScore(event.challenge, score), + tasksCompletedThisWeek: getWeeklyTasksCompleted(agent, event.weekIndex), + districtId, + districtName, + } + }) + .sort((a, b) => event.challenge.better === "lower" ? a.score - b.score : b.score - a.score) + .slice(0, 10) + .map((entry, index) => ({ ...entry, rank: index + 1 })) +} + +function isSnapshot(value: unknown): value is DistrictLeaderboardSnapshot { + if (!value || typeof value !== "object") return false + const candidate = value as Partial + return typeof candidate.weekIndex === "number" + && typeof candidate.districtId === "string" + && typeof candidate.districtName === "string" + && typeof candidate.endedAt === "string" + && typeof candidate.challengeName === "string" + && Array.isArray(candidate.entries) +} + +export function readDistrictLeaderboardSnapshots(): DistrictLeaderboardSnapshot[] { + if (!existsSync(SNAPSHOT_PATH)) return [] + try { + const parsed: unknown = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) + return Array.isArray(parsed) ? parsed.filter(isSnapshot) : [] + } catch { + return [] + } +} + +export function getPreviousDistrictLeaderboardSnapshot(districtId: DistrictId, now: Date = new Date()): DistrictLeaderboardSnapshot | null { + const previousWeekIndex = Math.max(0, getWeekBounds(now).weekIndex - 1) + return readDistrictLeaderboardSnapshots() + .filter((snapshot) => snapshot.districtId === districtId && snapshot.weekIndex <= previousWeekIndex) + .sort((a, b) => b.weekIndex - a.weekIndex)[0] ?? null +} diff --git a/lib/gamification/events.ts b/lib/gamification/events.ts index bf750505..7272893e 100644 --- a/lib/gamification/events.ts +++ b/lib/gamification/events.ts @@ -66,9 +66,9 @@ export const DISTRICT_CHALLENGES: DistrictChallengeDefinition[] = [ const WEEK_MS = 7 * 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000 -const EPOCH_START_MS = Date.UTC(2026, 0, 5, 0, 0, 0, 0) +const EPOCH_START_MS = Date.UTC(2026, 0, 4, 0, 0, 0, 0) -function getWeekBounds(now: Date = new Date()): { weekIndex: number; startedAt: Date; endsAt: Date } { +export function getWeekBounds(now: Date = new Date()): { weekIndex: number; startedAt: Date; endsAt: Date } { const elapsed = Math.max(0, now.getTime() - EPOCH_START_MS) const weekIndex = Math.floor(elapsed / WEEK_MS) const startedAt = new Date(EPOCH_START_MS + weekIndex * WEEK_MS) @@ -84,7 +84,7 @@ function seededNoise(seed: string): number { return (hash >>> 0) / 4294967295 } -function formatScore(challenge: DistrictChallengeDefinition, score: number): string { +export function formatDistrictScore(challenge: DistrictChallengeDefinition, score: number): string { if (challenge.type === "revenue") return `$${score.toFixed(2)}` if (challenge.type === "uptime") return `${score.toFixed(1)}%` if (challenge.type === "speed") return `${score.toFixed(1)}m` @@ -92,7 +92,7 @@ function formatScore(challenge: DistrictChallengeDefinition, score: number): str return String(Math.round(score)) } -function getAgentScore(agent: MoltbotAgent, challenge: DistrictChallengeDefinition, weekIndex: number): number { +export function getAgentDistrictCompetitionScore(agent: MoltbotAgent, challenge: DistrictChallengeDefinition, weekIndex: number): number { const noise = seededNoise(`${weekIndex}:${challenge.type}:${agent.id}`) switch (challenge.type) { case "throughput": @@ -108,6 +108,11 @@ function getAgentScore(agent: MoltbotAgent, challenge: DistrictChallengeDefiniti } } +export function getWeeklyTasksCompleted(agent: MoltbotAgent, weekIndex: number): number { + const completionRatio = 0.12 + seededNoise(`${weekIndex}:weekly-tasks:${agent.id}`) * 0.28 + return Math.max(0, Math.round(agent.tasksCompleted * completionRatio)) +} + export function getActiveDistrictEvent(now: Date = new Date()): ActiveDistrictEvent { const bounds = getWeekBounds(now) const challenge = DISTRICT_CHALLENGES[bounds.weekIndex % DISTRICT_CHALLENGES.length] @@ -129,7 +134,7 @@ export function getDistrictStandings(agents: MoltbotAgent[], now: Date = new Dat const event = getActiveDistrictEvent(now) const standings = DISTRICTS.map((district) => { const districtAgents = agents.filter((agent) => agent.district === district.id) - const scoredAgents = districtAgents.map((agent) => ({ agent, score: getAgentScore(agent, event.challenge, event.weekIndex) })) + const scoredAgents = districtAgents.map((agent) => ({ agent, score: getAgentDistrictCompetitionScore(agent, event.challenge, event.weekIndex) })) const score = event.challenge.type === "speed" ? (scoredAgents.reduce((sum, entry) => sum + entry.score, 0) / Math.max(1, scoredAgents.length)) : scoredAgents.reduce((sum, entry) => sum + entry.score, 0) / (event.challenge.type === "uptime" || event.challenge.type === "skill" ? Math.max(1, scoredAgents.length) : 1) @@ -143,14 +148,14 @@ export function getDistrictStandings(agents: MoltbotAgent[], now: Date = new Dat districtName: entry.district.name, color: entry.district.color, score: Number(entry.score.toFixed(3)), - formattedScore: formatScore(event.challenge, entry.score), + formattedScore: formatDistrictScore(event.challenge, entry.score), rank: index + 1, multiplier: index === 0 ? 2 : index === 1 ? 1.5 : 1, topAgent: entry.top ? { id: entry.top.agent.id, name: entry.top.agent.name, score: Number(entry.top.score.toFixed(3)), - formattedScore: formatScore(event.challenge, entry.top.score), + formattedScore: formatDistrictScore(event.challenge, entry.top.score), } : null, })) }