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
46 changes: 46 additions & 0 deletions src/app/api/area/[areaId]/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Area events API route.
*
* Responsibilities:
* - Validate the area id from the route param.
* - Parse and normalize query-string date filters.
* - Delegate data fetching to the BigQuery layer.
* - Translate invalid input and not-found states into HTTP responses.
*/
import { NextResponse } from "next/server";
import { getPageData } from "@/lib/bq/areas";
import { getSessionUser } from "@/lib/auth/server";

export async function GET(
request: Request,
context: { params: Promise<{ areaId?: string }> },
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const params = await context.params;
const rawId = params?.areaId;
const areaId = Number(rawId);

if (!rawId || !Number.isFinite(areaId) || areaId <= 0) {
return NextResponse.json({ error: "Invalid area id" }, { status: 400 });
}

const { searchParams } = new URL(request.url);

const opts = {
range: searchParams.get("range") || undefined,
startDate: searchParams.get("startDate") || undefined,
endDate: searchParams.get("endDate") || undefined,
};

const data = await getPageData(areaId, user.email, opts);

if (!data.info) {
return NextResponse.json({ error: "Area not found" }, { status: 404 });
}

return NextResponse.json(data, { status: 200 });
}
46 changes: 46 additions & 0 deletions src/app/api/sector/[sectorId]/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Sector events API route.
*
* Responsibilities:
* - Validate the sector id from the route param.
* - Parse and normalize query-string date filters.
* - Delegate data fetching to the BigQuery layer.
* - Translate invalid input and not-found states into HTTP responses.
*/
import { NextResponse } from "next/server";
import { getPageData } from "@/lib/bq/sectors";
import { getSessionUser } from "@/lib/auth/server";

export async function GET(
request: Request,
context: { params: Promise<{ sectorId?: string }> },
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const params = await context.params;
const rawId = params?.sectorId;
const sectorId = Number(rawId);

if (!rawId || !Number.isFinite(sectorId) || sectorId <= 0) {
return NextResponse.json({ error: "Invalid sector id" }, { status: 400 });
}

const { searchParams } = new URL(request.url);

const opts = {
range: searchParams.get("range") || undefined,
startDate: searchParams.get("startDate") || undefined,
endDate: searchParams.get("endDate") || undefined,
};

const data = await getPageData(sectorId, user.email, opts);

if (!data.info) {
return NextResponse.json({ error: "Sector not found" }, { status: 404 });
}

return NextResponse.json(data, { status: 200 });
}
18 changes: 18 additions & 0 deletions src/app/stats/ao/[aoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { loadAOData } from "./loader";
import { PageHeader } from "@/components/pageHeader";
import { AOPageWrapper } from "@/components/ao/PageWrapper";
import { Breadcrumb } from "@/components/breadcrumb";
import { Card, CardHeader, CardBody, CardFooter } from "@heroui/card";
import Link from "next/link";
import { getSessionUser, requireAuth } from "@/lib/auth/server";
Expand Down Expand Up @@ -161,6 +162,23 @@ export default async function AODetailPage({
return (
<main className="flex min-h-screen flex-col items-center justify-start pt-10 pb-10">
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl pb-6 px-4">
{/* Breadcrumb */}
<Breadcrumb
items={[
{ label: "Home", href: "/" },
{ label: "Nation", href: "/stats/nation" },
...(aoData.info?.region_id && aoData.info?.region_name
? [
{
label: aoData.info.region_name,
href: `/stats/region/${aoData.info.region_id}`,
},
]
: []),
{ label: aoData.info?.ao_name ?? "AO" },
]}
/>

{/* Page Header */}
<PageHeader
image={aoData.info?.logo_url ?? undefined}
Expand Down
54 changes: 54 additions & 0 deletions src/app/stats/area/[areaId]/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Area stats data loader.
*
* Responsibilities:
* - Call the BigQuery area data function.
* - Normalize BigQuery response (unwrap value wrappers, convert bigints).
* - Fail gracefully by returning null on error.
*/
import {
AreaData,
AreaInfo,
AreaSummary,
AreaRegionBreakdown,
ChartData,
} from "@/lib/types";
import { getPageData } from "@/lib/bq/areas";

type AreaFilterOpts = {
range?: string;
startDate?: string;
endDate?: string;
};

export async function loadAreaData(
areaId: number,
userIdentifier?: string,
filters?: AreaFilterOpts,
): Promise<AreaData | null> {
try {
const areaData = await getPageData(areaId, userIdentifier, filters);

const mergedPlain = JSON.parse(
JSON.stringify(areaData, (_k, v) => {
if (v && typeof v === "object" && "value" in v) return v.value;
if (typeof v === "bigint") return Number(v);
return v;
}),
) as AreaData;

const mergedSafe: AreaData = {
info: mergedPlain.info as AreaInfo,
summary: mergedPlain.summary as AreaSummary,
regionBreakdown: (mergedPlain.regionBreakdown ??
[]) as AreaRegionBreakdown[],
charts: (mergedPlain.charts ?? []) as ChartData[],
};

return mergedSafe;
} catch (err) {
console.error(`Error fetching Area data (area=${areaId}):`, err);
}

return null;
}
35 changes: 35 additions & 0 deletions src/app/stats/area/[areaId]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Loading skeleton for the Area stats page.
*/
function SkeletonCard({ height = "h-40" }: { height?: string }) {
return (
<div
className={`rounded-lg mb-6 bg-gray-200 dark:bg-gray-800 animate-pulse ${height}`}
/>
);
}

function SkeletonSection({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl px-4">
{children}
</div>
);
}

export default function Loading() {
return (
<main className="flex min-h-screen flex-col items-center justify-start pt-10 pb-10">
<SkeletonSection>
<SkeletonCard height="h-16" />
</SkeletonSection>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 w-full max-w-6xl px-4">
<SkeletonCard height="h-52" />
<SkeletonCard height="h-52" />
</div>
<SkeletonSection>
<SkeletonCard height="h-64" />
</SkeletonSection>
</main>
);
}
111 changes: 111 additions & 0 deletions src/app/stats/area/[areaId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/****
* Area stats page.
*
* Responsibilities:
* - Parse route params.
* - Load area data via the BQ loader.
* - Render either an empty-state message or the full area dashboard.
*/

import { loadAreaData } from "./loader";
import { PageHeader } from "@/components/pageHeader";
import { AreaSummaryCard } from "@/components/area/SummaryCard";
import { RegionBreakdownCard } from "@/components/area/RegionBreakdownCard";
import { AreaChartsCard } from "@/components/area/ChartsCard";
import { Card, CardHeader, CardBody } from "@heroui/card";
import { getSessionUser, requireAuth } from "@/lib/auth/server";
import { Breadcrumb } from "@/components/breadcrumb";

interface PageProps {
params: Promise<{ areaId: string }>;
}

export default async function AreaDetailPage({ params }: PageProps) {
await requireAuth();
const user = await getSessionUser();
if (!user) throw new Error("User should never be null after requireAuth");

const { areaId } = await params;
const areaData = await loadAreaData(Number(areaId), user.email);

if (!areaData?.info) {
return (
<main className="min-h-screen flex items-center justify-center px-4 pt-10 pb-10 bg-gradient-to-b from-background to-default-50">
<Card
className="w-full max-w-3xl bg-background/80 dark:bg-default-100/60"
shadow="lg"
>
<CardHeader className="flex flex-col gap-3">
<h1 className="text-2xl font-bold tracking-tight text-center">
Area Data Not Available
</h1>
<p className="text-sm text-foreground/70 text-center max-w-xl mx-auto">
This area exists, but no data is currently available to display.
</p>
</CardHeader>
<CardBody className="text-sm text-foreground/80">
<p>
This usually means the regions within this area have not yet
migrated to <strong>F3 Nation Data</strong>.
</p>
</CardBody>
</Card>
</main>
);
}

return (
<main className="flex min-h-screen flex-col items-center justify-start pt-10 pb-10">
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl pb-6 px-4">
{/* Breadcrumb */}
<Breadcrumb
items={[
{ label: "Home", href: "/" },
{ label: "Nation", href: "/stats/nation" },
...(areaData.info.sector_id && areaData.info.sector_name
? [
{
label: areaData.info.sector_name,
href: `/stats/sector/${areaData.info.sector_id}`,
},
]
: []),
{ label: areaData.info.area_name },
]}
/>

{/* Page Header */}
<PageHeader
image={areaData.info.logo_url ?? undefined}
name={`F3 ${areaData.info.area_name}`}
link={
areaData.info.sector_id
? `/stats/sector/${areaData.info.sector_id}`
: undefined
}
linkName={areaData.info.sector_name ?? undefined}
/>

{/* WIP disclaimer */}
<div className="w-full rounded-lg border border-warning-300 bg-warning-50 dark:bg-warning-900/20 px-4 py-3 text-sm text-warning-800 dark:text-warning-300">
<strong>Work in progress:</strong> This page is not the final version.
Data and layout are subject to change.
</div>

{/* Summary */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 w-full max-w-6xl">
<AreaSummaryCard summary={areaData.summary!} />
</div>
{/* Region breakdown */}
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl">
<RegionBreakdownCard regions={areaData.regionBreakdown || []} />
</div>

{/* Charts */}
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl hidden">
<AreaChartsCard charts={areaData.charts || []} />
</div>
</div>
</main>
);
}
4 changes: 4 additions & 0 deletions src/app/stats/nation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { Card, CardHeader, CardBody } from "@heroui/card";
import { Chip } from "@heroui/chip";
import { Progress } from "@heroui/progress";
import { Skeleton } from "@heroui/skeleton";
import { Breadcrumb } from "@/components/breadcrumb";

export default function PlaceholderPage() {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-b from-background to-default-50 px-4">
<div className="w-full max-w-4xl space-y-6">
<Breadcrumb
items={[{ label: "Home", href: "/" }, { label: "Nation" }]}
/>
<Card className="shadow-lg">
<CardHeader>
<div className="w-full flex flex-col gap-3">
Expand Down
17 changes: 17 additions & 0 deletions src/app/stats/pax/[paxId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { loadPaxData } from "./loader";
import { PageHeader } from "@/components/pageHeader";
import { PAXPageWrapper } from "@/components/pax/PageWrapper";
import { Breadcrumb } from "@/components/breadcrumb";
import { Card, CardHeader, CardBody, CardFooter } from "@heroui/card";
import Link from "next/link";
import { getSessionUser, requireAuth } from "@/lib/auth/server";
Expand Down Expand Up @@ -176,6 +177,22 @@ export default async function PaxDetailPage({
return (
<main className="flex min-h-screen flex-col items-center justify-start pt-10 pb-10">
<div className="grid grid-cols-1 gap-6 w-full max-w-6xl pb-6 px-4">
{/* Breadcrumb */}
<Breadcrumb
items={[
{ label: "Home", href: "/" },
...(paxData.info?.home_region_id && paxData.info?.home_region_name
? [
{
label: paxData.info.home_region_name,
href: `/stats/region/${paxData.info.home_region_id}`,
},
]
: []),
{ label: paxData.info?.f3_name ?? "PAX" },
]}
/>

{/* Page Header */}
<PageHeader
image={paxData.info?.avatar_url ?? undefined}
Expand Down
Loading
Loading