diff --git a/src/app/api/area/[areaId]/events/route.ts b/src/app/api/area/[areaId]/events/route.ts new file mode 100644 index 0000000..3032982 --- /dev/null +++ b/src/app/api/area/[areaId]/events/route.ts @@ -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 }); +} diff --git a/src/app/api/sector/[sectorId]/events/route.ts b/src/app/api/sector/[sectorId]/events/route.ts new file mode 100644 index 0000000..a54a918 --- /dev/null +++ b/src/app/api/sector/[sectorId]/events/route.ts @@ -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 }); +} diff --git a/src/app/stats/ao/[aoId]/page.tsx b/src/app/stats/ao/[aoId]/page.tsx index 33691ca..a5d4952 100644 --- a/src/app/stats/ao/[aoId]/page.tsx +++ b/src/app/stats/ao/[aoId]/page.tsx @@ -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"; @@ -161,6 +162,23 @@ export default async function AODetailPage({ return (
+ {/* Breadcrumb */} + + {/* Page Header */} { + 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; +} diff --git a/src/app/stats/area/[areaId]/loading.tsx b/src/app/stats/area/[areaId]/loading.tsx new file mode 100644 index 0000000..df0991b --- /dev/null +++ b/src/app/stats/area/[areaId]/loading.tsx @@ -0,0 +1,35 @@ +/** + * Loading skeleton for the Area stats page. + */ +function SkeletonCard({ height = "h-40" }: { height?: string }) { + return ( +
+ ); +} + +function SkeletonSection({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export default function Loading() { + return ( +
+ + + +
+ + +
+ + + +
+ ); +} diff --git a/src/app/stats/area/[areaId]/page.tsx b/src/app/stats/area/[areaId]/page.tsx new file mode 100644 index 0000000..989ef3a --- /dev/null +++ b/src/app/stats/area/[areaId]/page.tsx @@ -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 ( +
+ + +

+ Area Data Not Available +

+

+ This area exists, but no data is currently available to display. +

+
+ +

+ This usually means the regions within this area have not yet + migrated to F3 Nation Data. +

+
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} + + + {/* Page Header */} + + + {/* WIP disclaimer */} +
+ Work in progress: This page is not the final version. + Data and layout are subject to change. +
+ + {/* Summary */} +
+ +
+ {/* Region breakdown */} +
+ +
+ + {/* Charts */} +
+ +
+
+
+ ); +} diff --git a/src/app/stats/nation/page.tsx b/src/app/stats/nation/page.tsx index 301d260..5830f81 100644 --- a/src/app/stats/nation/page.tsx +++ b/src/app/stats/nation/page.tsx @@ -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 (
+
diff --git a/src/app/stats/pax/[paxId]/page.tsx b/src/app/stats/pax/[paxId]/page.tsx index 41c7abc..6d92007 100644 --- a/src/app/stats/pax/[paxId]/page.tsx +++ b/src/app/stats/pax/[paxId]/page.tsx @@ -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"; @@ -176,6 +177,22 @@ export default async function PaxDetailPage({ return (
+ {/* Breadcrumb */} + + {/* Page Header */}
+ {/* Breadcrumb */} + + {/* Page Header */} { + try { + const sectorData = await getPageData(sectorId, userIdentifier, filters); + + const mergedPlain = JSON.parse( + JSON.stringify(sectorData, (_k, v) => { + if (v && typeof v === "object" && "value" in v) return v.value; + if (typeof v === "bigint") return Number(v); + return v; + }), + ) as SectorData; + + const mergedSafe: SectorData = { + info: mergedPlain.info as SectorInfo, + summary: mergedPlain.summary as SectorSummary, + areaBreakdown: (mergedPlain.areaBreakdown ?? []) as SectorAreaBreakdown[], + charts: (mergedPlain.charts ?? []) as ChartData[], + }; + + return mergedSafe; + } catch (err) { + console.error(`Error fetching Sector data (sector=${sectorId}):`, err); + } + + return null; +} diff --git a/src/app/stats/sector/[sectorId]/loading.tsx b/src/app/stats/sector/[sectorId]/loading.tsx new file mode 100644 index 0000000..112875e --- /dev/null +++ b/src/app/stats/sector/[sectorId]/loading.tsx @@ -0,0 +1,35 @@ +/** + * Loading skeleton for the Sector stats page. + */ +function SkeletonCard({ height = "h-40" }: { height?: string }) { + return ( +
+ ); +} + +function SkeletonSection({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export default function Loading() { + return ( +
+ + + +
+ + +
+ + + +
+ ); +} diff --git a/src/app/stats/sector/[sectorId]/page.tsx b/src/app/stats/sector/[sectorId]/page.tsx new file mode 100644 index 0000000..951400f --- /dev/null +++ b/src/app/stats/sector/[sectorId]/page.tsx @@ -0,0 +1,100 @@ +/**** + * Sector stats page. + * + * Responsibilities: + * - Parse route params. + * - Load sector data via the BQ loader. + * - Render either an empty-state message or the full sector dashboard. + */ + +import { loadSectorData } from "./loader"; +import { PageHeader } from "@/components/pageHeader"; +import { SectorSummaryCard } from "@/components/sector/SummaryCard"; +import { AreaBreakdownCard } from "@/components/sector/AreaBreakdownCard"; +import { SectorChartsCard } from "@/components/sector/ChartsCard"; +import { Card, CardHeader, CardBody } from "@heroui/card"; +import { getSessionUser, requireAuth } from "@/lib/auth/server"; +import { Breadcrumb } from "@/components/breadcrumb"; + +interface PageProps { + params: Promise<{ sectorId: string }>; +} + +export default async function SectorDetailPage({ params }: PageProps) { + await requireAuth(); + const user = await getSessionUser(); + if (!user) throw new Error("User should never be null after requireAuth"); + + const { sectorId } = await params; + const sectorData = await loadSectorData(Number(sectorId), user.email); + + if (!sectorData?.info) { + return ( +
+ + +

+ Sector Data Not Available +

+

+ This sector exists, but no data is currently available to display. +

+
+ +

+ This usually means the areas and regions within this sector have + not yet migrated to F3 Nation Data. +

+
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumb */} + + + {/* Page Header — links up to Nation */} + + + {/* WIP disclaimer */} +
+ Work in progress: This page is not the final version. + Data and layout are subject to change. +
+ + {/* Summary */} +
+ +
+ + {/* Area breakdown */} +
+ +
+ + {/* Charts */} +
+ +
+
+
+ ); +} diff --git a/src/components/area/ChartsCard.tsx b/src/components/area/ChartsCard.tsx new file mode 100644 index 0000000..f2b1d38 --- /dev/null +++ b/src/components/area/ChartsCard.tsx @@ -0,0 +1,69 @@ +"use client"; + +/** + * AreaChartsCard + * + * Displays aggregate PAX and Q trend charts for an Area. + */ + +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { ChartData } from "@/lib/types"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type AreaChartsCardProps = { + charts: ChartData[]; +}; + +export function AreaChartsCard({ charts }: AreaChartsCardProps) { + return ( + + +
Area Charts
+
+ + + {charts.length > 0 ? ( + + + + + + + + + + + + + ) : ( +
+ No chart data available for this area. +
+ )} +
+
+ ); +} diff --git a/src/components/area/RegionBreakdownCard.tsx b/src/components/area/RegionBreakdownCard.tsx new file mode 100644 index 0000000..552f3d2 --- /dev/null +++ b/src/components/area/RegionBreakdownCard.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * RegionBreakdownCard + * + * Displays a table of child regions within an Area, each with their + * aggregate stats. Each region name links to its detail page. + */ + +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { Link } from "@heroui/link"; +import { AreaRegionBreakdown } from "@/lib/types"; +import { formatNumber } from "@/lib/utils"; + +type RegionBreakdownCardProps = { + regions: AreaRegionBreakdown[]; +}; + +function renderStat(value?: number, decimals?: number) { + if (typeof value !== "number") return "—"; + return formatNumber(value, decimals); +} + +export function RegionBreakdownCard({ regions }: RegionBreakdownCardProps) { + if (!regions || regions.length === 0) { + return ( + + +
Regions
+
+ + + No region data available. + +
+ ); + } + + return ( + + +
Regions
+
+ {regions.length} regions +
+
+ + + + + + + + + + + + + + + + {regions.map((region, idx) => ( + + + + + + + + + + ))} + +
+ Region + + Workouts + + AOs + + Active PAX + + Unique PAX + + Unique Qs + + Avg PAX +
+ + F3 {region.region_name} + + + {renderStat(region.event_count)} + + {renderStat(region.ao_count)} + + {renderStat(region.active_pax)} + + {renderStat(region.unique_pax)} + + {renderStat(region.unique_qs)} + + {renderStat(region.pax_count_average, 1)} +
+
+
+ ); +} diff --git a/src/components/area/SummaryCard.tsx b/src/components/area/SummaryCard.tsx new file mode 100644 index 0000000..885c827 --- /dev/null +++ b/src/components/area/SummaryCard.tsx @@ -0,0 +1,108 @@ +"use client"; + +/** + * AreaSummaryCard + * + * Displays high-level aggregate statistics for an Area + * (workouts, regions, AOs, PAX counts, Q counts, etc.). + */ + +import { useEffect, useState } from "react"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { AreaSummary } from "@/lib/types"; +import { formatNumber } from "@/lib/utils"; +import { HelpIcon } from "@/components/icons"; +import { Tooltip } from "@heroui/tooltip"; +import { Popover, PopoverTrigger, PopoverContent } from "@heroui/popover"; + +type AreaSummaryCardProps = { + summary: AreaSummary; +}; + +function renderStat(value?: number, decimals?: number, suffix?: string) { + if (typeof value !== "number") return "Unknown"; + const formatted = formatNumber(value, decimals); + return suffix ? `${formatted} ${suffix}` : formatted; +} + +export function AreaSummaryCard({ summary }: AreaSummaryCardProps) { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 640px)"); + const update = () => setIsMobile(mq.matches); + update(); + + if (typeof mq.addEventListener === "function") { + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + } + + mq.addListener(update); + return () => mq.removeListener(update); + }, []); + + return ( + + +
Area Summary
+
+ + +
+ Total Events: + {renderStat(summary.event_count, undefined, "Workouts")} +
+
+ Regions: + {renderStat(summary.region_count, undefined, "Regions")} +
+
+ AO Count: + {renderStat(summary.ao_count, undefined, "AOs")} +
+
+ + Active PAX: + {isMobile ? ( + + + + + + + + Number of active PAX in the area over last 30 days + + + ) : ( + + + + + + )} + + {renderStat(summary.active_pax, undefined, "PAX")} +
+
+ Unique PAX: + {renderStat(summary.unique_pax, undefined, "PAX")} +
+
+ Unique Qs: + {renderStat(summary.unique_qs, undefined, "Qs")} +
+
+ FNGs: + {renderStat(summary.fng_count, undefined, "FNGs")} +
+
+ Average PAX: + {renderStat(summary.pax_count_average, 2, "PAX")} +
+
+
+ ); +} diff --git a/src/components/breadcrumb.tsx b/src/components/breadcrumb.tsx new file mode 100644 index 0000000..d575a53 --- /dev/null +++ b/src/components/breadcrumb.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { + BreadcrumbItem, + Breadcrumbs as HeroBreadcrumbs, +} from "@heroui/breadcrumbs"; +import { useRouter } from "next/navigation"; + +export type BreadcrumbEntry = { + label: string; + href?: string; +}; + +type Props = { + items: BreadcrumbEntry[]; +}; + +/** + * Navigation bar with a back button on the left and a breadcrumb trail on + * the right. Intended for PWA use where the browser back button is hidden. + */ +export function Breadcrumb({ items }: Props) { + const router = useRouter(); + + return ( +
+ + + + {items.map((item, i) => ( + + {item.label} + + ))} + +
+ ); +} diff --git a/src/components/sector/AreaBreakdownCard.tsx b/src/components/sector/AreaBreakdownCard.tsx new file mode 100644 index 0000000..c5744fd --- /dev/null +++ b/src/components/sector/AreaBreakdownCard.tsx @@ -0,0 +1,115 @@ +"use client"; + +/** + * AreaBreakdownCard + * + * Displays a table of child areas within a Sector, each with their + * aggregate stats. Each area name links to its detail page. + */ + +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { Link } from "@heroui/link"; +import { SectorAreaBreakdown } from "@/lib/types"; +import { formatNumber } from "@/lib/utils"; + +type AreaBreakdownCardProps = { + areas: SectorAreaBreakdown[]; +}; + +function renderStat(value?: number, decimals?: number) { + if (typeof value !== "number") return "—"; + return formatNumber(value, decimals); +} + +export function AreaBreakdownCard({ areas }: AreaBreakdownCardProps) { + if (!areas || areas.length === 0) { + return ( + + +
Areas
+
+ + + No area data available. + +
+ ); + } + + return ( + + +
Areas
+
{areas.length} areas
+
+ + + + + + + + + + + + + + + + {areas.map((area, idx) => ( + + + + + + + + + + ))} + +
+ Area + + Workouts + + AOs + + Active PAX + + Unique PAX + + Unique Qs + + Avg PAX +
+ + F3 {area.area_name} + + + {renderStat(area.event_count)} + + {renderStat(area.ao_count)} + + {renderStat(area.active_pax)} + + {renderStat(area.unique_pax)} + + {renderStat(area.unique_qs)} + + {renderStat(area.pax_count_average, 1)} +
+
+
+ ); +} diff --git a/src/components/sector/ChartsCard.tsx b/src/components/sector/ChartsCard.tsx new file mode 100644 index 0000000..c18ff3d --- /dev/null +++ b/src/components/sector/ChartsCard.tsx @@ -0,0 +1,69 @@ +"use client"; + +/** + * SectorChartsCard + * + * Displays aggregate PAX and Q trend charts for a Sector. + */ + +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { ChartData } from "@/lib/types"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +type SectorChartsCardProps = { + charts: ChartData[]; +}; + +export function SectorChartsCard({ charts }: SectorChartsCardProps) { + return ( + + +
Sector Charts
+
+ + + {charts.length > 0 ? ( + + + + + + + + + + + + + ) : ( +
+ No chart data available for this sector. +
+ )} +
+
+ ); +} diff --git a/src/components/sector/SummaryCard.tsx b/src/components/sector/SummaryCard.tsx new file mode 100644 index 0000000..4f9b180 --- /dev/null +++ b/src/components/sector/SummaryCard.tsx @@ -0,0 +1,108 @@ +"use client"; + +/** + * SectorSummaryCard + * + * Displays high-level aggregate statistics for a Sector + * (workouts, areas, AOs, PAX counts, Q counts, etc.). + */ + +import { useEffect, useState } from "react"; +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { SectorSummary } from "@/lib/types"; +import { formatNumber } from "@/lib/utils"; +import { HelpIcon } from "@/components/icons"; +import { Tooltip } from "@heroui/tooltip"; +import { Popover, PopoverTrigger, PopoverContent } from "@heroui/popover"; + +type SectorSummaryCardProps = { + summary: SectorSummary; +}; + +function renderStat(value?: number, decimals?: number, suffix?: string) { + if (typeof value !== "number") return "Unknown"; + const formatted = formatNumber(value, decimals); + return suffix ? `${formatted} ${suffix}` : formatted; +} + +export function SectorSummaryCard({ summary }: SectorSummaryCardProps) { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 640px)"); + const update = () => setIsMobile(mq.matches); + update(); + + if (typeof mq.addEventListener === "function") { + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + } + + mq.addListener(update); + return () => mq.removeListener(update); + }, []); + + return ( + + +
Sector Summary
+
+ + +
+ Total Events: + {renderStat(summary.event_count, undefined, "Workouts")} +
+
+ Areas: + {renderStat(summary.area_count, undefined, "Areas")} +
+
+ AO Count: + {renderStat(summary.ao_count, undefined, "AOs")} +
+
+ + Active PAX: + {isMobile ? ( + + + + + + + + Number of active PAX in the sector over last 30 days + + + ) : ( + + + + + + )} + + {renderStat(summary.active_pax, undefined, "PAX")} +
+
+ Unique PAX: + {renderStat(summary.unique_pax, undefined, "PAX")} +
+
+ Unique Qs: + {renderStat(summary.unique_qs, undefined, "Qs")} +
+
+ FNGs: + {renderStat(summary.fng_count, undefined, "FNGs")} +
+
+ Average PAX: + {renderStat(summary.pax_count_average, 2, "PAX")} +
+
+
+ ); +} diff --git a/src/lib/bq/areas.ts b/src/lib/bq/areas.ts new file mode 100644 index 0000000..eaf7609 --- /dev/null +++ b/src/lib/bq/areas.ts @@ -0,0 +1,368 @@ +import { queryBigQuery } from "@/lib/db"; +import { + AreaInfo, + AreaSummary, + AreaRegionBreakdown, + ChartData, +} from "@/lib/types"; + +type EventFilterOpts = { + range?: string; + startDate?: string; // 'YYYY-MM-DD' + endDate?: string; // 'YYYY-MM-DD' +}; + +/** + * Convert a named range into UTC YYYY-MM-DD start/end strings. + * Identical logic to regions.ts. + */ +function buildRangeDates(range: string | undefined): { + startDate?: string; + endDate?: string; +} { + const now = new Date(); + const todayUTC = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + const dayOfWeek = todayUTC.getUTCDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const mondayThisWeek = new Date( + todayUTC.getTime() + mondayOffset * 24 * 60 * 60 * 1000, + ); + + let start: Date | undefined; + let end: Date | undefined; + + switch (range) { + case "YTD": + start = new Date(Date.UTC(todayUTC.getUTCFullYear(), 0, 1)); + break; + case "This Week": + start = mondayThisWeek; + break; + case "Last Week": + start = new Date(mondayThisWeek.getTime() - 7 * 24 * 60 * 60 * 1000); + end = new Date(mondayThisWeek.getTime() - 1 * 24 * 60 * 60 * 1000); + break; + case "This Month": + start = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth(), 1), + ); + break; + case "Last Month": + start = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth() - 1, 1), + ); + end = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth(), 0), + ); + break; + case "Last 90 Days": + start = new Date(todayUTC.getTime() - 90 * 24 * 60 * 60 * 1000); + break; + case "Last 180 Days": + start = new Date(todayUTC.getTime() - 180 * 24 * 60 * 60 * 1000); + break; + case "Prior Year": + start = new Date(Date.UTC(todayUTC.getUTCFullYear() - 1, 0, 1)); + end = new Date(Date.UTC(todayUTC.getUTCFullYear() - 1, 11, 31)); + break; + default: + break; + } + + return { + startDate: start?.toISOString().split("T")[0], + endDate: end?.toISOString().split("T")[0], + }; +} + +function buildDateFilterClauses(opts?: EventFilterOpts): string[] { + const rangeDates = buildRangeDates(opts?.range); + const startDate = opts?.startDate ?? rangeDates.startDate; + const endDate = opts?.endDate ?? rangeDates.endDate; + + const clauses: string[] = []; + + if (startDate && endDate) { + clauses.push( + `event_date BETWEEN DATE('${startDate}') AND DATE('${endDate}')`, + ); + } else if (startDate) { + clauses.push(`event_date >= DATE('${startDate}')`); + } else if (endDate) { + clauses.push(`event_date <= DATE('${endDate}')`); + } + + return clauses; +} + +export async function searchAreasByName( + q: string, + userIdentifier?: string, + includeInactive = false, +): Promise { + const term = (q || "").trim(); + if (term.length < 2) return []; + + const escapedTerm = term.replace(/'/g, "''").toLowerCase(); + + const query = `-- AREA SEARCH + SELECT + area_id, + area_name, + logo_url, + is_active + FROM pv_areas + WHERE area_name IS NOT NULL + AND LOWER(area_name) LIKE '%${escapedTerm}%' + ${includeInactive ? "" : "AND is_active = TRUE"} + ORDER BY area_name + LIMIT 50 + `; + + const results = await queryBigQuery( + query, + userIdentifier, + `search areas by name: ${q}`, + ); + return results ?? []; +} + +export async function getPageData( + areaId: number, + userIdentifier?: string, + opts?: EventFilterOpts, +): Promise<{ + info: AreaInfo | null; + summary: AreaSummary | null; + regionBreakdown: AreaRegionBreakdown[] | null; + charts: ChartData[] | null; +}> { + // Build date-only WHERE clauses for the events CTE. + const dateFilterClauses = buildDateFilterClauses(opts); + const dateFilterSql = dateFilterClauses.length + ? `AND ${dateFilterClauses.join("\n AND ")}` + : ""; + + // Determine chart granularity. + const rangeDates = buildRangeDates(opts?.range); + const effectiveStart = opts?.startDate ?? rangeDates.startDate; + const effectiveEnd = + opts?.endDate ?? + rangeDates.endDate ?? + new Date().toISOString().split("T")[0]; + const daysDiff = effectiveStart + ? (new Date(effectiveEnd).getTime() - new Date(effectiveStart).getTime()) / + 86_400_000 + : Infinity; + const chartGranularity = + daysDiff > 365 ? "monthly" : daysDiff > 180 ? "weekly" : "daily"; + const truncUnit = + chartGranularity === "monthly" + ? "MONTH" + : chartGranularity === "weekly" + ? "WEEK(MONDAY)" + : "DAY"; + const spineInterval = + chartGranularity === "monthly" + ? "INTERVAL 1 MONTH" + : chartGranularity === "weekly" + ? "INTERVAL 1 WEEK" + : "INTERVAL 1 DAY"; + + const query = `-- AREA PAGE LOAD + WITH + events AS ( + SELECT + event_id, + event_date, + pax_count, + fng_count, + ao_org_id, + region_org_id, + region_name, + attendance + FROM pv_events + WHERE area_org_id = ${areaId} + ${dateFilterSql} + ), + attendance_flat AS ( + SELECT + e.event_id, + e.event_date, + e.region_org_id, + a.user_id, + a.q_ind + FROM events e + LEFT JOIN UNNEST(e.attendance) a + WHERE a.user_id IS NOT NULL + ), + + -- Region-level event aggregates + region_event_stats AS ( + SELECT + region_org_id, + ANY_VALUE(region_name) AS region_name, + COUNT(DISTINCT event_id) AS event_count, + COUNT(DISTINCT ao_org_id) AS ao_count, + SUM(COALESCE(fng_count, 0)) AS fng_count, + AVG(CAST(pax_count AS FLOAT64)) AS pax_count_average + FROM events + GROUP BY region_org_id + ), + -- Region-level attendance aggregates + region_attendance_stats AS ( + SELECT + region_org_id, + COUNT( + DISTINCT IF( + event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY), + user_id, + NULL)) AS active_pax, + COUNT(DISTINCT user_id) AS unique_pax, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_qs + FROM attendance_flat + GROUP BY region_org_id + ), + + -- Area-level aggregates + area_event_metrics AS ( + SELECT + COUNT(DISTINCT event_id) AS event_count, + COUNT(DISTINCT region_org_id) AS region_count, + COUNT(DISTINCT ao_org_id) AS ao_count, + SUM(COALESCE(fng_count, 0)) AS fng_count, + AVG(CAST(pax_count AS FLOAT64)) AS pax_count_average + FROM events + ), + area_attendance_metrics AS ( + SELECT + COUNT( + DISTINCT IF( + event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY), + user_id, + NULL)) AS active_pax, + COUNT(DISTINCT user_id) AS unique_pax, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_qs + FROM attendance_flat + ), + + -- Chart aggregation + period_event_agg AS ( + SELECT + DATE_TRUNC(event_date, ${truncUnit}) AS period, + SUM(pax_count) AS pax_count, + SUM(fng_count) AS fng_count, + SUM((SELECT COUNTIF(a.q_ind = 1) FROM UNNEST(attendance) a)) AS q_count + FROM events + GROUP BY period + ), + period_attendance_agg AS ( + SELECT + DATE_TRUNC(event_date, ${truncUnit}) AS period, + COUNT(DISTINCT user_id) AS unique_pax_count, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_q_count + FROM attendance_flat + GROUP BY period + ), + period_agg AS ( + SELECT + ea.period, + ea.pax_count, + ea.fng_count, + ea.q_count, + COALESCE(aa.unique_pax_count, 0) AS unique_pax_count, + COALESCE(aa.unique_q_count, 0) AS unique_q_count + FROM period_event_agg ea + LEFT JOIN period_attendance_agg aa USING (period) + ), + date_spine AS ( + SELECT d AS date + FROM UNNEST( + GENERATE_DATE_ARRAY( + (SELECT MIN(period) FROM period_agg), + (SELECT MAX(period) FROM period_agg), + ${spineInterval} + ) + ) AS d + ) + SELECT + -- Area info + ( + SELECT AS STRUCT + area_id, area_name, sector_id, sector_name, logo_url, is_active, regions + FROM pv_areas + WHERE area_id = ${areaId} + LIMIT 1 + ) AS areaInfo, + + -- Area-level summary + ( + SELECT AS STRUCT + em.event_count, + em.region_count, + em.ao_count, + am.active_pax, + am.unique_pax, + am.unique_qs, + em.fng_count, + em.pax_count_average + FROM area_event_metrics em + CROSS JOIN area_attendance_metrics am + ) AS summary, + + -- Region breakdown + ( + SELECT + ARRAY_AGG( + STRUCT( + res.region_org_id AS region_id, + res.region_name, + res.event_count, + res.ao_count, + COALESCE(ras.active_pax, 0) AS active_pax, + COALESCE(ras.unique_pax, 0) AS unique_pax, + COALESCE(ras.unique_qs, 0) AS unique_qs, + res.fng_count, + res.pax_count_average + ) + ORDER BY res.event_count DESC + ) + FROM region_event_stats res + LEFT JOIN region_attendance_stats ras USING (region_org_id) + ) AS regionBreakdown, + + -- Charts with gap-filling + ( + SELECT + ARRAY_AGG( + STRUCT( + ds.date AS date, + COALESCE(pa.pax_count, 0) AS pax_count, + COALESCE(pa.fng_count, 0) AS fng_count, + COALESCE(pa.q_count, 0) AS q_count, + COALESCE(pa.unique_pax_count, 0) AS unique_pax_count, + COALESCE(pa.unique_q_count, 0) AS unique_q_count + ) + ORDER BY ds.date ASC + ) + FROM date_spine ds + LEFT JOIN period_agg pa ON pa.period = ds.date + ) AS charts; + `; + + const results = await queryBigQuery<{ + areaInfo: AreaInfo; + summary: AreaSummary; + regionBreakdown: AreaRegionBreakdown[]; + charts: ChartData[]; + }>(query, userIdentifier, `fetch area data for area ${areaId}`); + + return { + info: results?.[0]?.areaInfo || null, + summary: results?.[0]?.summary || null, + regionBreakdown: results?.[0]?.regionBreakdown || null, + charts: results?.[0]?.charts || null, + }; +} diff --git a/src/lib/bq/sectors.ts b/src/lib/bq/sectors.ts new file mode 100644 index 0000000..a082b3b --- /dev/null +++ b/src/lib/bq/sectors.ts @@ -0,0 +1,366 @@ +import { queryBigQuery } from "@/lib/db"; +import { + SectorInfo, + SectorSummary, + SectorAreaBreakdown, + ChartData, +} from "@/lib/types"; + +type EventFilterOpts = { + range?: string; + startDate?: string; // 'YYYY-MM-DD' + endDate?: string; // 'YYYY-MM-DD' +}; + +/** + * Convert a named range into UTC YYYY-MM-DD start/end strings. + */ +function buildRangeDates(range: string | undefined): { + startDate?: string; + endDate?: string; +} { + const now = new Date(); + const todayUTC = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + const dayOfWeek = todayUTC.getUTCDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const mondayThisWeek = new Date( + todayUTC.getTime() + mondayOffset * 24 * 60 * 60 * 1000, + ); + + let start: Date | undefined; + let end: Date | undefined; + + switch (range) { + case "YTD": + start = new Date(Date.UTC(todayUTC.getUTCFullYear(), 0, 1)); + break; + case "This Week": + start = mondayThisWeek; + break; + case "Last Week": + start = new Date(mondayThisWeek.getTime() - 7 * 24 * 60 * 60 * 1000); + end = new Date(mondayThisWeek.getTime() - 1 * 24 * 60 * 60 * 1000); + break; + case "This Month": + start = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth(), 1), + ); + break; + case "Last Month": + start = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth() - 1, 1), + ); + end = new Date( + Date.UTC(todayUTC.getUTCFullYear(), todayUTC.getUTCMonth(), 0), + ); + break; + case "Last 90 Days": + start = new Date(todayUTC.getTime() - 90 * 24 * 60 * 60 * 1000); + break; + case "Last 180 Days": + start = new Date(todayUTC.getTime() - 180 * 24 * 60 * 60 * 1000); + break; + case "Prior Year": + start = new Date(Date.UTC(todayUTC.getUTCFullYear() - 1, 0, 1)); + end = new Date(Date.UTC(todayUTC.getUTCFullYear() - 1, 11, 31)); + break; + default: + break; + } + + return { + startDate: start?.toISOString().split("T")[0], + endDate: end?.toISOString().split("T")[0], + }; +} + +function buildDateFilterClauses(opts?: EventFilterOpts): string[] { + const rangeDates = buildRangeDates(opts?.range); + const startDate = opts?.startDate ?? rangeDates.startDate; + const endDate = opts?.endDate ?? rangeDates.endDate; + + const clauses: string[] = []; + + if (startDate && endDate) { + clauses.push( + `event_date BETWEEN DATE('${startDate}') AND DATE('${endDate}')`, + ); + } else if (startDate) { + clauses.push(`event_date >= DATE('${startDate}')`); + } else if (endDate) { + clauses.push(`event_date <= DATE('${endDate}')`); + } + + return clauses; +} + +export async function searchSectorsByName( + q: string, + userIdentifier?: string, + includeInactive = false, +): Promise { + const term = (q || "").trim(); + if (term.length < 2) return []; + + const escapedTerm = term.replace(/'/g, "''").toLowerCase(); + + const query = `-- SECTOR SEARCH + SELECT + sector_id, + sector_name, + logo_url, + is_active + FROM pv_sectors + WHERE sector_name IS NOT NULL + AND LOWER(sector_name) LIKE '%${escapedTerm}%' + ${includeInactive ? "" : "AND is_active = TRUE"} + ORDER BY sector_name + LIMIT 50 + `; + + const results = await queryBigQuery( + query, + userIdentifier, + `search sectors by name: ${q}`, + ); + return results ?? []; +} + +export async function getPageData( + sectorId: number, + userIdentifier?: string, + opts?: EventFilterOpts, +): Promise<{ + info: SectorInfo | null; + summary: SectorSummary | null; + areaBreakdown: SectorAreaBreakdown[] | null; + charts: ChartData[] | null; +}> { + const dateFilterClauses = buildDateFilterClauses(opts); + const dateFilterSql = dateFilterClauses.length + ? `AND ${dateFilterClauses.join("\n AND ")}` + : ""; + + // Determine chart granularity. + const rangeDates = buildRangeDates(opts?.range); + const effectiveStart = opts?.startDate ?? rangeDates.startDate; + const effectiveEnd = + opts?.endDate ?? + rangeDates.endDate ?? + new Date().toISOString().split("T")[0]; + const daysDiff = effectiveStart + ? (new Date(effectiveEnd).getTime() - new Date(effectiveStart).getTime()) / + 86_400_000 + : Infinity; + const chartGranularity = + daysDiff > 365 ? "monthly" : daysDiff > 180 ? "weekly" : "daily"; + const truncUnit = + chartGranularity === "monthly" + ? "MONTH" + : chartGranularity === "weekly" + ? "WEEK(MONDAY)" + : "DAY"; + const spineInterval = + chartGranularity === "monthly" + ? "INTERVAL 1 MONTH" + : chartGranularity === "weekly" + ? "INTERVAL 1 WEEK" + : "INTERVAL 1 DAY"; + + const query = `-- SECTOR PAGE LOAD + WITH + events AS ( + SELECT + event_id, + event_date, + pax_count, + fng_count, + ao_org_id, + area_org_id, + area_name, + attendance + FROM pv_events + WHERE sector_org_id = ${sectorId} + ${dateFilterSql} + ), + attendance_flat AS ( + SELECT + e.event_id, + e.event_date, + e.area_org_id, + a.user_id, + a.q_ind + FROM events e + LEFT JOIN UNNEST(e.attendance) a + WHERE a.user_id IS NOT NULL + ), + + -- Area-level event aggregates + area_event_stats AS ( + SELECT + area_org_id, + ANY_VALUE(area_name) AS area_name, + COUNT(DISTINCT event_id) AS event_count, + COUNT(DISTINCT ao_org_id) AS ao_count, + SUM(COALESCE(fng_count, 0)) AS fng_count, + AVG(CAST(pax_count AS FLOAT64)) AS pax_count_average + FROM events + GROUP BY area_org_id + ), + -- Area-level attendance aggregates + area_attendance_stats AS ( + SELECT + area_org_id, + COUNT( + DISTINCT IF( + event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY), + user_id, + NULL)) AS active_pax, + COUNT(DISTINCT user_id) AS unique_pax, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_qs + FROM attendance_flat + GROUP BY area_org_id + ), + + -- Sector-level aggregates + sector_event_metrics AS ( + SELECT + COUNT(DISTINCT event_id) AS event_count, + COUNT(DISTINCT area_org_id) AS area_count, + COUNT(DISTINCT ao_org_id) AS ao_count, + SUM(COALESCE(fng_count, 0)) AS fng_count, + AVG(CAST(pax_count AS FLOAT64)) AS pax_count_average + FROM events + ), + sector_attendance_metrics AS ( + SELECT + COUNT( + DISTINCT IF( + event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY), + user_id, + NULL)) AS active_pax, + COUNT(DISTINCT user_id) AS unique_pax, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_qs + FROM attendance_flat + ), + + -- Chart aggregation + period_event_agg AS ( + SELECT + DATE_TRUNC(event_date, ${truncUnit}) AS period, + SUM(pax_count) AS pax_count, + SUM(fng_count) AS fng_count, + SUM((SELECT COUNTIF(a.q_ind = 1) FROM UNNEST(attendance) a)) AS q_count + FROM events + GROUP BY period + ), + period_attendance_agg AS ( + SELECT + DATE_TRUNC(event_date, ${truncUnit}) AS period, + COUNT(DISTINCT user_id) AS unique_pax_count, + COUNT(DISTINCT IF(q_ind = 1, user_id, NULL)) AS unique_q_count + FROM attendance_flat + GROUP BY period + ), + period_agg AS ( + SELECT + ea.period, + ea.pax_count, + ea.fng_count, + ea.q_count, + COALESCE(aa.unique_pax_count, 0) AS unique_pax_count, + COALESCE(aa.unique_q_count, 0) AS unique_q_count + FROM period_event_agg ea + LEFT JOIN period_attendance_agg aa USING (period) + ), + date_spine AS ( + SELECT d AS date + FROM UNNEST( + GENERATE_DATE_ARRAY( + (SELECT MIN(period) FROM period_agg), + (SELECT MAX(period) FROM period_agg), + ${spineInterval} + ) + ) AS d + ) + SELECT + -- Sector info + ( + SELECT AS STRUCT + sector_id, sector_name, logo_url, is_active, areas + FROM pv_sectors + WHERE sector_id = ${sectorId} + LIMIT 1 + ) AS sectorInfo, + + -- Sector-level summary + ( + SELECT AS STRUCT + em.event_count, + em.area_count, + em.ao_count, + am.active_pax, + am.unique_pax, + am.unique_qs, + em.fng_count, + em.pax_count_average + FROM sector_event_metrics em + CROSS JOIN sector_attendance_metrics am + ) AS summary, + + -- Area breakdown + ( + SELECT + ARRAY_AGG( + STRUCT( + aes.area_org_id AS area_id, + aes.area_name, + aes.event_count, + aes.ao_count, + COALESCE(aas.active_pax, 0) AS active_pax, + COALESCE(aas.unique_pax, 0) AS unique_pax, + COALESCE(aas.unique_qs, 0) AS unique_qs, + aes.fng_count, + aes.pax_count_average + ) + ORDER BY aes.event_count DESC + ) + FROM area_event_stats aes + LEFT JOIN area_attendance_stats aas USING (area_org_id) + ) AS areaBreakdown, + + -- Charts with gap-filling + ( + SELECT + ARRAY_AGG( + STRUCT( + ds.date AS date, + COALESCE(pa.pax_count, 0) AS pax_count, + COALESCE(pa.fng_count, 0) AS fng_count, + COALESCE(pa.q_count, 0) AS q_count, + COALESCE(pa.unique_pax_count, 0) AS unique_pax_count, + COALESCE(pa.unique_q_count, 0) AS unique_q_count + ) + ORDER BY ds.date ASC + ) + FROM date_spine ds + LEFT JOIN period_agg pa ON pa.period = ds.date + ) AS charts; + `; + + const results = await queryBigQuery<{ + sectorInfo: SectorInfo; + summary: SectorSummary; + areaBreakdown: SectorAreaBreakdown[]; + charts: ChartData[]; + }>(query, userIdentifier, `fetch sector data for sector ${sectorId}`); + + return { + info: results?.[0]?.sectorInfo || null, + summary: results?.[0]?.summary || null, + areaBreakdown: results?.[0]?.areaBreakdown || null, + charts: results?.[0]?.charts || null, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0f8b908..733138a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -243,6 +243,100 @@ export interface PaxAOBreakdown { total_q_count: number; // Number of Qs (leaders) held by the AO } +/* =========================================================== */ +/* AREA-SPECIFIC TYPES BELOW */ +/* =========================================================== */ + +// AREA DATA MODEL +export interface AreaData { + info: AreaInfo | null; + summary: AreaSummary | null; + regionBreakdown: AreaRegionBreakdown[] | null; + charts: ChartData[] | null; +} + +/* USED ONLY FOR AREA INFO */ +export interface AreaInfo { + area_id: number; + area_name: string; + sector_id: number | null; + sector_name: string | null; + logo_url: string | null; + is_active: boolean; + regions: { region_id: number; region_name: string; is_active: boolean }[]; +} + +/* USED ONLY FOR AREA SUMMARY STATS */ +export interface AreaSummary { + event_count: number; + region_count: number; + ao_count: number; + active_pax: number; + unique_pax: number; + unique_qs: number; + fng_count: number; + pax_count_average: number; +} + +/* USED FOR AREA REGION BREAKDOWN */ +export interface AreaRegionBreakdown { + region_id: number; + region_name: string; + event_count: number; + ao_count: number; + active_pax: number; + unique_pax: number; + unique_qs: number; + fng_count: number; + pax_count_average: number; +} + +/* =========================================================== */ +/* SECTOR-SPECIFIC TYPES BELOW */ +/* =========================================================== */ + +// SECTOR DATA MODEL +export interface SectorData { + info: SectorInfo | null; + summary: SectorSummary | null; + areaBreakdown: SectorAreaBreakdown[] | null; + charts: ChartData[] | null; +} + +/* USED ONLY FOR SECTOR INFO */ +export interface SectorInfo { + sector_id: number; + sector_name: string; + logo_url: string | null; + is_active: boolean; + areas: { area_id: number; area_name: string; is_active: boolean }[]; +} + +/* USED ONLY FOR SECTOR SUMMARY STATS */ +export interface SectorSummary { + event_count: number; + area_count: number; + ao_count: number; + active_pax: number; + unique_pax: number; + unique_qs: number; + fng_count: number; + pax_count_average: number; +} + +/* USED FOR SECTOR AREA BREAKDOWN */ +export interface SectorAreaBreakdown { + area_id: number; + area_name: string; + event_count: number; + ao_count: number; + active_pax: number; + unique_pax: number; + unique_qs: number; + fng_count: number; + pax_count_average: number; +} + /* ========================================================== */ /* AO-SPECIFIC TYPES BELOW */ /* =========================================================== */