diff --git a/src/app/stats/region/[regionId]/loader.ts b/src/app/stats/region/[regionId]/loader.ts index e6f4986..019306a 100644 --- a/src/app/stats/region/[regionId]/loader.ts +++ b/src/app/stats/region/[regionId]/loader.ts @@ -17,6 +17,9 @@ import { RegionKotterList, ChartData, RegionAchievementPax, + AOHeatmapData, + AOQDepthData, + AOPaxTrendData, } from "@/lib/types"; import { getPageData } from "@/lib/bq/regions"; @@ -74,6 +77,9 @@ export async function loadRegionData( kotter: (mergedPlain.kotter ?? []) as RegionKotterList[], charts: (mergedPlain.charts ?? []) as ChartData[], achievements: (mergedPlain.achievements ?? []) as RegionAchievementPax[], + ao_heatmap: (mergedPlain.ao_heatmap ?? []) as AOHeatmapData[], + ao_q_depth: (mergedPlain.ao_q_depth ?? []) as AOQDepthData[], + ao_pax_trend: (mergedPlain.ao_pax_trend ?? []) as AOPaxTrendData[], }; mergedSafe.events = (mergedSafe.events ?? []).map((e: EventData) => ({ diff --git a/src/app/stats/region/[regionId]/page.tsx b/src/app/stats/region/[regionId]/page.tsx index f77f81b..4c8044c 100644 --- a/src/app/stats/region/[regionId]/page.tsx +++ b/src/app/stats/region/[regionId]/page.tsx @@ -208,6 +208,9 @@ export default async function RegionDetailPage({ region_events={regionData.events || []} region_charts={regionData.charts || []} region_achievements={regionData.achievements || []} + region_ao_heatmap={regionData.ao_heatmap || []} + region_ao_q_depth={regionData.ao_q_depth || []} + region_ao_pax_trend={regionData.ao_pax_trend || []} searchParams={{ categoryIds, categoryMode, diff --git a/src/components/region/AOChartsCard.tsx b/src/components/region/AOChartsCard.tsx new file mode 100644 index 0000000..54413c2 --- /dev/null +++ b/src/components/region/AOChartsCard.tsx @@ -0,0 +1,572 @@ +"use client"; + +import { AOHeatmapData, AOPaxTrendData, AOQDepthData } from "@/lib/types"; +import { Fragment, useState } from "react"; +import { Card, CardBody, CardFooter, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; +import { + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, +} from "@heroui/dropdown"; +import { Button } from "@heroui/button"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const LINE_COLORS = [ + "#378ADD", + "#1D9E75", + "#EF9F27", + "#D85A30", + "#7F77DD", + "#E24B4A", +]; + +const tooltipStyle = { + contentStyle: { + backgroundColor: "hsl(var(--heroui-content1))", + border: "0.5px solid hsl(var(--heroui-default-200))", + borderRadius: "8px", + fontSize: "12px", + }, + itemStyle: { color: "hsl(var(--heroui-foreground))" }, + labelStyle: { color: "hsl(var(--heroui-default-500))", marginBottom: 2 }, +}; + +// ─── Heatmap ──────────────────────────────────────────────────────────────── + +export function HeatmapChart({ data }: { data: AOHeatmapData[] }) { + const aoNames = [...new Set(data.map((d) => d.ao_name))].sort(); + const allValues = data.map((d) => d.avg_pax); + const maxVal = Math.max(...allValues, 1); + + const cellMap = new Map(); + data.forEach((d) => cellMap.set(`${d.ao_name}|${d.day_of_week}`, d)); + + const hasData = data.length > 0; + + return ( + + +
Day-of-Week Attendance
+
+ + + {hasData ? ( +
+ {/* Header row */} +
+ {DAYS.map((day) => ( +
+ {day} +
+ ))} + {/* Data rows */} + {aoNames.map((ao) => ( + +
+ {ao} +
+ {DAYS.map((day) => { + const cell = cellMap.get(`${ao}|${day}`); + if (!cell) { + return ( +
+ ); + } + const alpha = 0.15 + (cell.avg_pax / maxVal) * 0.75; + const textColor = alpha > 0.6 ? "#fff" : undefined; + return ( +
+ ((e.currentTarget as HTMLDivElement).style.transform = + "scale(1.08)") + } + onMouseLeave={(e) => + ((e.currentTarget as HTMLDivElement).style.transform = + "scale(1)") + } + > + {cell.avg_pax} +
+ ); + })} + + ))} +
+ ) : ( +
+ No workout data available for this period. +
+ )} + + + +

+ Average PAX per AO per day of week, trailing 90 days. Darker cells + mean higher average attendance. +

+
+ + ); +} + +// ─── Q Depth ──────────────────────────────────────────────────────────────── + +function qDepthColor(pct: number) { + if (pct >= 70) return "#1D9E75"; + if (pct >= 50) return "#EF9F27"; + return "#E24B4A"; +} + +export function QDepthChart({ data }: { data: AOQDepthData[] }) { + const hasData = data.length > 0; + const chartHeight = Math.max(160, data.length * 28); + + return ( + + +
Q Depth per AO
+
+ + + {hasData ? ( + <> + + + + `${v}%`} + tick={{ fontSize: 10 }} + tickLine={false} + axisLine={false} + /> + + + { + const d = props.payload as AOQDepthData; + return [ + `${d.q_depth_pct}% (${d.unique_qs} Qs / ${d.total_workouts} workouts)`, + "Q Depth", + ]; + }} + {...tooltipStyle} + /> + { + const { x, y, width, height, value } = props as { + x: number; + y: number; + width: number; + height: number; + value: number; + }; + return ( + + ); + }} + /> + + + {/* Static legend */} +
+ {[ + { color: "#1D9E75", label: "Healthy (70%+)" }, + { color: "#EF9F27", label: "Watch (50–69%)" }, + { color: "#E24B4A", label: "At risk (<50%)" }, + ].map(({ color, label }) => ( +
+
+ + {label} + +
+ ))} +
+ + ) : ( +
+ No Q data available for this period. +
+ )} + + + +

+ Ratio of unique Qs to total workouts per AO, trailing 90 days. Low + depth means a site runs on one or two people — a key-man risk. +

+
+ + ); +} + +// ─── Unique Qs per AO ─────────────────────────────────────────────────────── + +export function UniqueQsPerAOChart({ data }: { data: AOQDepthData[] }) { + const hasData = data.length > 0; + const chartHeight = Math.max(160, data.length * 28); + const sorted = [...data].sort((a, b) => b.unique_qs - a.unique_qs); + + return ( + + +
Unique Qs per AO
+
+ + + {hasData ? ( + + + + + + { + const d = props.payload as AOQDepthData; + return [`${d.unique_qs} unique Qs`, "Unique Qs"]; + }} + {...tooltipStyle} + /> + + + + ) : ( +
+ No Q data available for this period. +
+ )} +
+ + +

+ Number of distinct leaders who have Q'd at each AO, trailing 90 + days. Shows how many people can run each site. +

+
+
+ ); +} + +// ─── Avg PAX Trend ────────────────────────────────────────────────────────── + +export function AvgPaxTrendChart({ data }: { data: AOPaxTrendData[] }) { + const [selectedAO, setSelectedAO] = useState(null); + + const periods = [...new Set(data.map((d) => d.period))].sort((a, b) => { + return new Date(`1 ${a}`).getTime() - new Date(`1 ${b}`).getTime(); + }); + + const allAONames = [...new Set(data.map((d) => d.ao_name))].filter((ao) => { + return data.filter((d) => d.ao_name === ao).length >= 3; + }); + + // Top 5 by mean avg_pax across all periods + const top5 = [...allAONames] + .map((ao) => { + const rows = data.filter((d) => d.ao_name === ao); + const mean = rows.reduce((s, d) => s + d.avg_pax, 0) / rows.length; + return { ao, mean }; + }) + .sort((a, b) => b.mean - a.mean) + .slice(0, 5) + .map((x) => x.ao); + + // Region average per period (average of all AOs) + const regionAvgByPeriod = new Map(); + periods.forEach((p) => { + const rows = data.filter((d) => d.period === p); + if (rows.length) { + const avg = + Math.round( + (rows.reduce((s, d) => s + d.avg_pax, 0) / rows.length) * 10, + ) / 10; + regionAvgByPeriod.set(p, avg); + } + }); + + const activeAOs = selectedAO ? [selectedAO] : top5; + + const pivoted = periods.map((period) => { + const row: Record = { period }; + if (selectedAO) { + const match = data.find( + (d) => d.ao_name === selectedAO && d.period === period, + ); + if (match) row[selectedAO] = match.avg_pax; + const regionAvg = regionAvgByPeriod.get(period); + if (regionAvg !== undefined) row["Region Avg"] = regionAvg; + } else { + data + .filter((d) => d.period === period && top5.includes(d.ao_name)) + .forEach((d) => { + row[d.ao_name] = d.avg_pax; + }); + } + return row; + }); + + const hasData = allAONames.length > 0 && periods.length >= 2; + + return ( + + +
Avg PAX Trend per AO
+ {hasData && ( + + + + + { + const k = Array.from(keys as Set)[0]; + setSelectedAO(k === "__top5__" ? null : k); + }} + items={[ + { key: "__top5__", label: "Top 5 AOs" }, + ...allAONames.map((ao) => ({ key: ao, label: ao })), + ]} + > + {(item) => ( + {item.label} + )} + + + )} +
+ + + {hasData ? ( + <> + + + + + + + {selectedAO + ? [ + , + , + ] + : activeAOs.map((ao, i) => ( + + ))} + + + {!selectedAO && ( +
+ {activeAOs.map((ao, i) => ( +
+
+ + {ao} + +
+ ))} +
+ )} + + ) : ( +
+ Not enough history to show a trend yet. +
+ )} + + + +

+ {selectedAO + ? `${selectedAO} vs. the average of all active AOs, last 6 months.` + : "Top 5 AOs by average attendance, last 6 months. Select an AO from the dropdown to compare it against the region average."} +

+
+ + ); +} diff --git a/src/components/region/AchievementsCard.tsx b/src/components/region/AchievementsCard.tsx index fdd2e20..65825c3 100644 --- a/src/components/region/AchievementsCard.tsx +++ b/src/components/region/AchievementsCard.tsx @@ -343,7 +343,7 @@ export function AchievementsCard({ - +
{visibleRows.length === 0 ? (
diff --git a/src/components/region/ChartsCard.tsx b/src/components/region/ChartsCard.tsx index 16d426f..0f2749c 100644 --- a/src/components/region/ChartsCard.tsx +++ b/src/components/region/ChartsCard.tsx @@ -1,76 +1,199 @@ "use client"; -/** - * Charting Card - * - * Displays high-level aggregate charts for a region - * (workouts, AOs, PAX counts, Q counts, etc.). - * - * This component is purely presentational and assumes data has - * already been validated and normalized upstream. - */ - -import { Card, CardBody, CardHeader } from "@heroui/card"; -import { Divider } from "@heroui/divider"; import { ChartData } from "@/lib/types"; +import { Card, CardBody, CardFooter, CardHeader } from "@heroui/card"; +import { Divider } from "@heroui/divider"; import { + Area, + AreaChart, Bar, BarChart, CartesianGrid, - Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; -type ChartCardProps = { +type GrowthChartsCardProps = { charts: ChartData[]; }; -const SimpleBarChart = (charts: ChartData[]) => { +function computeRollingAvg(data: ChartData[]): (number | null)[] { + return data.map((_, i) => { + const window = data.slice(Math.max(0, i - 3), i + 1); + const sum = window.reduce((acc, d) => acc + d.unique_pax_count, 0); + return Math.round((sum / window.length) * 10) / 10; + }); +} + +const tooltipStyle = { + contentStyle: { + backgroundColor: "hsl(var(--heroui-content1))", + border: "0.5px solid hsl(var(--heroui-default-200))", + borderRadius: "8px", + fontSize: "12px", + }, + itemStyle: { color: "hsl(var(--heroui-foreground))" }, + labelStyle: { color: "hsl(var(--heroui-default-500))", marginBottom: 2 }, +}; + +function UniquePaxChart({ charts }: { charts: ChartData[] }) { + const rollingAvg = computeRollingAvg(charts); + const enriched = charts.map((d, i) => ({ ...d, rolling_avg: rollingAvg[i] })); + const hasData = charts.filter((d) => d.unique_pax_count > 0).length >= 2; + return ( - - - - - - - - - - - - + + +
Unique PAX Over Time
+
+ + + {hasData ? ( + + + + + + + + + + + + [ + value, + name === "unique_pax_count" ? "Unique PAX" : "4-wk Avg", + ]} + {...tooltipStyle} + /> + + + + + ) : ( +
+ Not enough data to show a trend yet. +
+ )} +
+ + +

+ Tracks distinct PAX per period with a 4-week rolling average to smooth + week-to-week noise and reveal the real growth trend. +

+
+
); -}; +} + +function FngAcquisitionChart({ charts }: { charts: ChartData[] }) { + const hasData = charts.some((d) => d.fng_count > 0); -export function ChartCard({ charts }: ChartCardProps) { return ( -
Region Charts
+
FNG Acquisition
- {charts.length > 0 ? ( - SimpleBarChart(charts) + {hasData ? ( + + + + + + [value, "FNGs"]} + {...tooltipStyle} + /> + + + ) : ( -
- No chart data available for this region. +
+ No new members recorded in this period.
)} + + +

+ Each bar is one period of first-time attendees. Spikes reveal which + events or seasons drive new member growth. +

+
); } + +export function ChartCard({ charts }: GrowthChartsCardProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/region/PageWrapper.tsx b/src/components/region/PageWrapper.tsx index d375cdc..7af69ef 100644 --- a/src/components/region/PageWrapper.tsx +++ b/src/components/region/PageWrapper.tsx @@ -19,6 +19,8 @@ import { RegionInfo, ChartData, RegionAchievementPax, + AOHeatmapData, + AOQDepthData, } from "@/lib/types"; import { SummaryCard } from "./SummaryCard"; import { LeadersCard } from "../leaders"; @@ -28,6 +30,7 @@ import { EventsCard } from "../events"; import { Filter } from "../pageFilter"; import { useMemo } from "react"; import { ChartCard } from "./ChartsCard"; +import { HeatmapChart, QDepthChart, UniqueQsPerAOChart } from "./AOChartsCard"; import { AchievementsCard } from "./AchievementsCard"; type RegionalPageWrapperProps = { @@ -40,6 +43,9 @@ type RegionalPageWrapperProps = { region_events: EventData[]; region_charts: ChartData[]; region_achievements: RegionAchievementPax[]; + region_ao_heatmap: AOHeatmapData[]; + region_ao_q_depth: AOQDepthData[]; + region_ao_pax_trend: unknown[]; searchParams: { categoryIds?: string | string[]; categoryMode?: string; @@ -104,6 +110,8 @@ export function RegionalPageWrapper({ region_events, region_charts, region_achievements, + region_ao_heatmap, + region_ao_q_depth, searchParams, }: RegionalPageWrapperProps) { // Memoized query-string passed to events + filter components. @@ -140,26 +148,32 @@ export function RegionalPageWrapper({ />
{/* Charting */} -
+
- {/* Upcoming achievements */} + {/* Unique Qs + Q Depth */} +
+ + +
+ {/* Heatmap + Achievements */}
+ - {/* Kotters + upcoming events */} -
- - -
+
+ {/* Kotters + upcoming events */} +
+ +
{/* Event list */}
diff --git a/src/lib/bq/regions.ts b/src/lib/bq/regions.ts index 190cd05..da11f05 100644 --- a/src/lib/bq/regions.ts +++ b/src/lib/bq/regions.ts @@ -8,6 +8,9 @@ import { RegionKotterList, ChartData, RegionAchievementPax, + AOHeatmapData, + AOQDepthData, + AOPaxTrendData, } from "@/lib/types"; /** @@ -344,17 +347,11 @@ export async function getPageData( leaders: Leaders[] | null; upcoming: EventUpcoming[] | null; kotter: RegionKotterList[] | null; - charts: - | { - date: string; - pax_count: number; - fng_count: number; - q_count: number; - unique_pax_count: number; - unique_q_count: number; - }[] - | null; + charts: ChartData[] | null; achievements: RegionAchievementPax[] | null; + ao_heatmap: AOHeatmapData[] | null; + ao_q_depth: AOQDepthData[] | null; + ao_pax_trend: AOPaxTrendData[] | null; }> { // Build WHERE clause from common filters (region-scoped). const whereSql = buildEventsWhereSql(regionId, opts); @@ -721,6 +718,7 @@ export async function getPageData( period_event_agg AS ( SELECT DATE_TRUNC(event_date, ${truncUnit}) AS period, + COUNT(*) AS event_count, 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 @@ -738,6 +736,7 @@ export async function getPageData( period_agg AS ( SELECT ea.period, + ea.event_count, ea.pax_count, ea.fng_count, ea.q_count, @@ -760,6 +759,7 @@ export async function getPageData( ARRAY_AGG( STRUCT( ds.date AS date, + COALESCE(pa.event_count, 0) AS event_count, COALESCE(pa.pax_count, 0) AS pax_count, COALESCE(pa.fng_count, 0) AS fng_count, COALESCE(pa.q_count, 0) AS q_count, @@ -770,7 +770,83 @@ export async function getPageData( ) FROM date_spine ds LEFT JOIN period_agg pa ON pa.period = ds.date - ) AS charts; + ) AS charts, + + -- AO heatmap: avg PAX per AO per day-of-week, trailing 90 days (fixed window) + ( + WITH + ao_90day AS ( + SELECT ao_name, event_date, pax_count + FROM \`f3data.paxVault.pv_events\` + WHERE region_org_id = ${regionId} + AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) + AND first_f_ind = 1 + ), + ao_heatmap_agg AS ( + SELECT + ao_name, + FORMAT_DATE('%a', event_date) AS day_of_week, + ROUND(AVG(pax_count), 1) AS avg_pax, + COUNT(*) AS workout_count + FROM ao_90day + GROUP BY ao_name, day_of_week + ) + SELECT ARRAY_AGG(STRUCT(ao_name, day_of_week, avg_pax, workout_count)) + FROM ao_heatmap_agg + ) AS aoHeatmap, + + -- AO Q depth: unique Qs vs total workouts per AO, trailing 90 days (fixed window) + ( + WITH + ao_90day AS ( + SELECT ao_name, event_date, attendance + FROM \`f3data.paxVault.pv_events\` + WHERE region_org_id = ${regionId} + AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) + AND first_f_ind = 1 + ), + ao_event_counts AS ( + SELECT ao_name, COUNT(*) AS total_workouts + FROM ao_90day + GROUP BY ao_name + ), + ao_q_counts AS ( + SELECT e.ao_name, COUNT(DISTINCT IF(a.q_ind = 1, a.user_id, NULL)) AS unique_qs + FROM ao_90day e, UNNEST(e.attendance) a + GROUP BY e.ao_name + ), + ao_q_depth_agg AS ( + SELECT + ec.ao_name, + ec.total_workouts, + COALESCE(qc.unique_qs, 0) AS unique_qs, + ROUND(SAFE_DIVIDE(COALESCE(qc.unique_qs, 0), ec.total_workouts) * 100, 1) AS q_depth_pct + FROM ao_event_counts ec + LEFT JOIN ao_q_counts qc USING (ao_name) + ) + SELECT ARRAY_AGG(STRUCT(ao_name, unique_qs, total_workouts, q_depth_pct) ORDER BY q_depth_pct DESC) + FROM ao_q_depth_agg + ) AS aoQDepth, + + -- AO avg PAX trend: avg PAX per AO per month, trailing 6 months (fixed window) + ( + WITH + ao_pax_trend_agg AS ( + SELECT + ao_name, + FORMAT_DATE('%b %Y', DATE_TRUNC(event_date, MONTH)) AS period, + DATE_TRUNC(event_date, MONTH) AS period_date, + ROUND(AVG(pax_count), 1) AS avg_pax, + COUNT(*) AS workout_count + FROM \`f3data.paxVault.pv_events\` + WHERE region_org_id = ${regionId} + AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 6 MONTH) + AND first_f_ind = 1 + GROUP BY ao_name, period, period_date + ) + SELECT ARRAY_AGG(STRUCT(ao_name, period, avg_pax, workout_count) ORDER BY period_date, ao_name) + FROM ao_pax_trend_agg + ) AS aoPaxTrend; `; const results = await queryBigQuery<{ @@ -782,6 +858,9 @@ export async function getPageData( kotter: RegionKotterList[]; charts: ChartData[]; achievements: RegionAchievementPax[]; + aoHeatmap: AOHeatmapData[]; + aoQDepth: AOQDepthData[]; + aoPaxTrend: AOPaxTrendData[]; }>(query, userIdentifier, `fetch region data for region ${regionId}`); return { @@ -793,5 +872,8 @@ export async function getPageData( kotter: results?.[0]?.kotter || null, charts: results?.[0]?.charts || null, achievements: results?.[0]?.achievements || null, + ao_heatmap: results?.[0]?.aoHeatmap || null, + ao_q_depth: results?.[0]?.aoQDepth || null, + ao_pax_trend: results?.[0]?.aoPaxTrend || null, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 733138a..750fb11 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -98,12 +98,34 @@ export interface ChartData { q_count: number; // Number of Qs (leaders) for the chart data point unique_pax_count: number; // Number of unique participants (pax) for the chart data point unique_q_count: number; // Number of unique Qs (leaders) for the chart data point + event_count?: number; // Number of distinct workouts posted in the period } /* =========================================================== */ /* REGION-SPECIFIC TYPES BELOW */ /* =========================================================== */ +export interface AOHeatmapData { + ao_name: string; + day_of_week: string; + avg_pax: number; + workout_count: number; +} + +export interface AOQDepthData { + ao_name: string; + unique_qs: number; + total_workouts: number; + q_depth_pct: number; +} + +export interface AOPaxTrendData { + ao_name: string; + period: string; + avg_pax: number; + workout_count: number; +} + // REGION DATA MODEL export interface RegionData { info: RegionInfo | null; // Basic information about the region} @@ -114,6 +136,9 @@ export interface RegionData { kotter: RegionKotterList[] | null; // List of Kotter events in the region charts: ChartData[] | null; // List of chart data points for the region achievements: RegionAchievementPax[] | null; // PAX approaching milestones or with upcoming anniversaries + ao_heatmap: AOHeatmapData[] | null; + ao_q_depth: AOQDepthData[] | null; + ao_pax_trend: AOPaxTrendData[] | null; } /* USED ONLY FOR REGION INFO */