Skip to content
Open
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
86 changes: 86 additions & 0 deletions src/app/api/stats/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { GET } from "./route";

vi.mock("@/lib/supabase", () => {
return {
getSupabaseAdmin: () => ({
from: (table: string) => {
if (table === "developers") {
return {
select: (cols: string, opts?: { count?: string; head?: boolean }) => {
if (opts?.count === "exact") {
return {
eq: () => {
return Promise.resolve({ count: 42, data: null, error: null });
},
then: (resolve: (val: { count: number; data: null; error: null }) => void) =>
resolve({ count: 100, data: null, error: null }),
count: 100,
data: null,
error: null,
};
}

// solveResult
if (cols === "easy_solved, medium_solved, hard_solved") {
return Promise.resolve({
data: [
{ easy_solved: 10, medium_solved: 5, hard_solved: 2 },
{ easy_solved: 20, medium_solved: 15, hard_solved: 8 },
],
error: null,
});
}

// tallestResult
return {
order: () => ({
limit: () => ({

maybeSingle: () =>
Promise.resolve({
data: {
github_login: "top-coder",
easy_solved: 100,
medium_solved: 50,
hard_solved: 30,
},
error: null,
}),
}),
}),
};
},
};
}
return {};
},
}),
};
});


describe("GET /api/stats", () => {
it("returns aggregate city statistics with expected JSON structure", async () => {
const res = await GET();
expect(res.status).toBe(200);

const json = await res.json();
expect(json).toHaveProperty("totalDevelopers");
expect(json).toHaveProperty("claimedBuildings");
expect(json).toHaveProperty("totalSolves");
expect(json).toHaveProperty("tallestBuilding");
expect(json).toHaveProperty("generatedAt");

expect(json.tallestBuilding).toEqual({
username: "top-coder",
hardSolved: 30,
});
expect(json.totalDevelopers).toBe(100);
expect(json.claimedBuildings).toBe(42);
expect(json.totalSolves).toBe(60); // (10+5+2) + (20+15+8) = 60

expect(res.headers.get("Cache-Control")).toContain("public");

});
});
93 changes: 67 additions & 26 deletions src/app/api/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,77 @@ import { getSupabaseAdmin } from "@/lib/supabase";

/**
* Public aggregate statistics for the LeetCode City.
* No authentication required.
* Returns total developers, claimed buildings, total problem solves,
* and tallest building metrics. No authentication required.
*/
export async function GET() {
const sb = getSupabaseAdmin();
try {
const sb = getSupabaseAdmin();

const { data: stats, error: statsError } = await sb
.from("city_stats")
.select("total_developers, total_contributions")
.eq("id", 1)
.single();
const [totalResult, claimedResult, tallestResult, solveResult] = await Promise.all([
sb.from("developers").select("id", { count: "exact", head: true }),
sb.from("developers").select("id", { count: "exact", head: true }).eq("claimed", true),
sb
.from("developers")
.select("github_login, easy_solved, medium_solved, hard_solved")
.order("hard_solved", { ascending: false })
.limit(1)
.maybeSingle(),
sb.from("developers").select("easy_solved, medium_solved, hard_solved"),
]);

if (statsError) {
return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 });
}
const queryError =
totalResult.error ?? claimedResult.error ?? tallestResult.error ?? solveResult.error;
if (queryError) {
throw queryError;
}

const totalDevelopers = totalResult.count ?? 0;

const claimedBuildings = claimedResult.count ?? 0;

const solves = solveResult.data ?? [];
const totalSolves = solves.reduce(
(acc, d) => acc + (d.easy_solved ?? 0) + (d.medium_solved ?? 0) + (d.hard_solved ?? 0),
0
);

const { count: activeToday } = await sb
.from("developers")
.select("id", { count: "exact", head: true })
.gte("last_active_at", new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());

return NextResponse.json(
{
total_developers: stats?.total_developers ?? 0,
total_contributions: stats?.total_contributions ?? 0,
active_today: activeToday ?? 0,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=600",
const tallestDev = tallestResult.data;
const tallestBuilding = {
username: tallestDev?.github_login ?? "—",
hardSolved: tallestDev?.hard_solved ?? 0,
};

return NextResponse.json(
{
totalDevelopers,
claimedBuildings,
totalSolves,
tallestBuilding,
generatedAt: new Date().toISOString(),
},
}
);
{
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=60",
},
}
);
} catch (error) {
console.error("[/api/stats] Error generating city stats:", error);
return NextResponse.json(
{
totalDevelopers: 0,
claimedBuildings: 0,
totalSolves: 0,
tallestBuilding: { username: "—", hardSolved: 0 },
generatedAt: new Date().toISOString(),
},
{
status: 500,
headers: {
"Cache-Control": "no-store",
},
}
);
}
}
10 changes: 8 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ import CityHUD from "@/components/hud/CityHUD";
import AuthManager from "@/components/hud/AuthManager";
import SettingsPanel from "@/components/hud/SettingsPanel";
import ModalsOverlay from "@/components/hud/ModalsOverlay";


import CityStatsBar from "@/components/CityStatsBar";

function HomeContent() {
const searchParams = useSearchParams();
Expand Down Expand Up @@ -336,6 +335,13 @@ function HomeContent() {
<SettingsPanel />
<ModalsOverlay />

{/* Aggregate City Statistics Bar Overlay */}
{!introMode && !flyMode && (
<div className="pointer-events-none fixed inset-x-0 top-14 z-[25] flex justify-center px-4 sm:top-16">
<CityStatsBar />
</div>
)}

{/* Multiplayer Chat Overlay */}
{!introMode && !flyMode && (
<CityChat
Expand Down
1 change: 1 addition & 0 deletions src/components/AtmosphereCycleManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useWeather } from "@/context/WeatherContext";


// Helper to interpolate two hex colors using THREE.Color
function lerpColor(c1: string, c2: string, alpha: number): string {
const color1 = new THREE.Color(c1);
Expand Down
97 changes: 97 additions & 0 deletions src/components/CityStatsBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import useSWR from "swr";
import { HiUsers, HiBuildingOffice2, HiCheckCircle, HiTrophy } from "react-icons/hi2";

const fetcher = async (url: string) => {
const r = await fetch(url);
if (!r.ok) {
throw new Error(`Failed to fetch ${url}: ${r.status} ${r.statusText}`);
}
return r.json();
};

export interface CityStatsData {
totalDevelopers?: number;
claimedBuildings?: number;
totalSolves?: number;
tallestBuilding?: {
username: string;
hardSolved: number;
};
generatedAt?: string;
}

export function CityStatsBar() {
const { data, error, isLoading } = useSWR<CityStatsData>("/api/stats", fetcher, {
refreshInterval: 300_000, // Refresh every 5 minutes
revalidateOnFocus: false,
});

const stats = [
{
label: "Developers",
value: isLoading
? "…"
: error || data?.totalDevelopers == null
? "—"
: data.totalDevelopers.toLocaleString(),
icon: HiUsers,
color: "text-amber-400",
},
{
label: "Buildings Claimed",
value: isLoading
? "…"
: error || data?.claimedBuildings == null
? "—"
: data.claimedBuildings.toLocaleString(),
icon: HiBuildingOffice2,
color: "text-emerald-400",
},
{
label: "Problems Solved",
value: isLoading
? "…"
: error || data?.totalSolves == null
? "—"
: data.totalSolves.toLocaleString(),
icon: HiCheckCircle,
color: "text-sky-400",
},
{
label: "Tallest Building",
value: isLoading
? "…"
: error || !data?.tallestBuilding?.username
? "—"
: `${data.tallestBuilding.username} (${data.tallestBuilding.hardSolved} Hard)`,
icon: HiTrophy,
color: "text-purple-400",
},
];

return (
<aside
aria-label="City Statistics Bar"
className="pointer-events-auto mx-auto my-2 flex w-full max-w-4xl flex-wrap items-center justify-between gap-2 rounded-xl border border-white/10 bg-black/60 px-4 py-2 text-xs font-medium backdrop-blur-md shadow-lg transition-all hover:border-white/20"
>
{stats.map((s) => {
const Icon = s.icon;
return (
<div
key={s.label}
className="flex items-center space-x-2 px-2 py-1 transition-transform hover:scale-105"
>

<Icon className={`h-4 w-4 ${s.color}`} />
<span className="font-bold text-white tracking-wide">{s.value}</span>
<span className="text-gray-400 text-[11px]">{s.label}</span>
</div>
);
})}
</aside>
);
}

export default CityStatsBar;
Loading